Conversation
Store the granted qos per subscriber channel in SubscribeRepository and allocate a packet id from each subscriber's own id space when fanning out publishes, instead of hard-coding AT_MOST_ONCE and reusing the publisher's packet id for every subscriber. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Aias00
left a comment
There was a problem hiding this comment.
This is a solid fix that brings the MQTT fan-out into line with the spec: it now delivers at min(published QoS, granted QoS) and allocates a fresh packet identifier from each subscriber channel's own id space, instead of hard-coding AT_MOST_ONCE and reusing the publisher's packet id. The change is well-scoped and backed by thorough unit tests (PublishTest, SubscribeRepositoryTest, MqttPacketIdGeneratorTest) covering granted-QoS fan-out, QoS0 → packetId 0, per-subscriber id spaces, max-QoS merge, FAILURE filtering, and id wrap-around at 0xFFFF.
A few non-blocking notes:
- Dead code:
int packetId = msg.variableHeader().packetId();inPublish.publishis now unused (it used to be passed tosend). Please remove it, along with the now-resolved//// todo qoscomment. - Id-space cleanup:
MqttPacketIdGenerator.CHANNEL_PACKET_ID_FACTORYis a staticMap<Channel, AtomicInteger>with strong references, cleaned only viaDisconnect.cleanChannel. If a channel is torn down without going throughcleanChannel(e.g. abnormal disconnect), its id-space entry leaks for the process lifetime. Consider aWeakHashMapkeyed byChannel, or guaranteeing cleanup on all close paths. - Buffer sharing (pre-existing):
sendwraps the shared incomingpayloadwithUnpooled.wrappedBuffer(payload)for every subscriber. SinceWrappedByteBufshares the underlying buffer's reference count, fan-out to multiple active subscribers releases the same buffer N times. This is pre-existing behavior, but worth double-checking the reference-count handling (e.g.payload.retain()per subscriber) so it doesn't over-release under multi-subscriber fan-out.
I also confirmed SubscribeRepository's BaseRepository type change (List<Channel> → Map<Channel, MqttQoS>) doesn't break other callers — the only callers (Subscribe.add(channel, subscriptions), Unsubscribe.remove(topics, channel), and Publish itself) use unchanged signatures.
Approving.
…n-out Use a weak-keyed map for per-channel packet id spaces so channels closed without a DISCONNECT do not leak entries, and retain the publish payload per subscriber so each outbound message owns a reference. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Remove the closing channel from ChannelRepository, SubscribeRepository and the packet id space in MqttTransportHandler.operationComplete so ungraceful disconnects do not leak entries. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Thank you for the code review on this PR. Fix: prevent mqtt packet id space leak and payload over-release on fan-out — addresses two review findings:
The "dead code" note from the review was verified against the code: the //// todo qos comment had already been removed in the prior commit, and packetId is still used by the |
Aias00
left a comment
There was a problem hiding this comment.
Summary (re-review after update)
Re-reviewed after the branch was updated (prior review was dismissed by a rebase). The current version already addresses both issues I raised before: MqttPacketIdGenerator now uses Collections.synchronizedMap(new WeakHashMap<>()) so a channel torn down without cleanChannel does not leak its id space, and send uses payload.retain() per subscriber so the shared payload survives fan-out without under/over-releasing.
What is correct:
- Per-subscriber packet id space:
MqttPacketIdGeneratorkeeps aWeakHashMap<Channel, AtomicInteger>(synchronized) so each channel gets its own id sequence, wrapping at 0xFFFF;remove(channel)is called on disconnect/close (viaMqttTransportHandler.operationCompleteandDisconnect.cleanChannel). This fixes the old behavior of reusing a shared/hard-coded id across subscribers. - QoS correctness:
qos1sends a properPUBACK;qos2now sendsPUBREC(previously incorrectly sentPUBACKfor QoS 2). NewPubRel/PUBCOMPhandler completes the QoS 2 handshake and is wired intoMqttFactory(case PUBREL). - Delivery QoS = min(published, granted):
sendcomputesminQoS(publishQoS, grantedQoS)per subscriber and allocates a packet id only for QoS > 0.SubscribeRepositorystoresMap<Channel, MqttQoS>and merges overlapping subscriptions withmaxQoS. - ByteBuf refcount fix:
sendusespayload.retain()per subscriber;testPublishFanOutRetainsPayloadPerSubscriberasserts the refcount — the old code under-counted on multi-subscriber fan-out. - Cleanup on close:
MqttTransportHandler.operationCompleteremoves the channel fromChannelRepository,SubscribeRepository, andMqttPacketIdGenerator, preventing leaked state after disconnect. - Test coverage is comprehensive:
PublishTest,PubRelTest,MqttTransportHandlerTest,SubscribeRepositoryTest,MqttPacketIdGeneratorTest.
Conclusion
Approved. The QoS 2 completion, per-subscriber id allocation, and ByteBuf refcount handling are all real correctness fixes, and the prior leak/refcount concerns are now resolved.
Non-blocking suggestions
- Merge ordering (important): this PR rewrites
SubscribeRepositorytoMap<Channel, MqttQoS>(changingget(topic)'s return type), while #6902 and #6906 keepList<Channel>and addgetChannelsByTopic. These three MQTT PRs conflict onSubscribeRepository/Publish.java. Merge #6902/#6906 first (or rebase this one on top of them), then this PR, so theSubscribeRepositoryshape is decided once. After that, #6902'spublishWill(random packet id) could be updated to useMqttPacketIdGeneratorfor consistency. - Async-payload note:
Publish.publishstill captures the incomingpayloadByteBuf into aCompletableFuture.runAsyncused after the method returns. The newretain()makes fan-out safe, but the caller must still not release the payload before the asyncsendruns — same contract as before, just more robust. A short comment would help future maintainers.
Thanks for addressing the prior feedback.
# Conflicts: # shenyu-protocol/shenyu-protocol-mqtt/src/main/java/org/apache/shenyu/protocol/mqtt/MqttTransportHandler.java # shenyu-protocol/shenyu-protocol-mqtt/src/main/java/org/apache/shenyu/protocol/mqtt/repositories/SubscribeRepository.java # shenyu-protocol/shenyu-protocol-mqtt/src/test/java/org/apache/shenyu/protocol/mqtt/MqttTransportHandlerTest.java # shenyu-protocol/shenyu-protocol-mqtt/src/test/java/org/apache/shenyu/protocol/mqtt/PublishTest.java # shenyu-protocol/shenyu-protocol-mqtt/src/test/java/org/apache/shenyu/protocol/mqtt/repositories/SubscribeRepositoryTest.java
- SubscribeRepository.remove(List, Channel) kept two blocks after the merge, one of which assigns a Map to a List and does not compile - release the static repository maps around every test in PublishTest, SubscribeRepositoryTest and MqttTransportHandlerTest so the shared state of one test class cannot leak into another - cover the non-MQTT read path of MqttTransportHandler, the per-topic QoS merging of SubscribeRepository and the retained publish handling
There was a problem hiding this comment.
🟡 Changes recommended
QoS grants, acknowledgement state machines, packet-ID reuse, and asynchronous cleanup remain incorrect.
Get a fresh assessment by requesting another Copilot review.
Pull request overview
Updates MQTT delivery to preserve subscriber QoS, allocate channel-specific packet IDs, complete QoS 2 acknowledgements, and clean state on channel closure.
Changes:
- Stores subscriber QoS and applies it during publish fan-out.
- Adds packet-ID generation and PUBREC/PUBREL/PUBCOMP handling.
- Adds repository cleanup and expanded unit tests.
File summaries
| File | Description |
|---|---|
MqttPacketIdGeneratorTest.java |
Tests packet-ID allocation and reset. |
SubscribeRepositoryTest.java |
Tests QoS-aware subscriptions and cleanup. |
PubRelTest.java |
Tests PUBCOMP responses. |
PublishTest.java |
Tests fan-out QoS, packet IDs, and acknowledgements. |
MqttTransportHandlerTest.java |
Tests close-time repository cleanup. |
MqttPacketIdGenerator.java |
Adds per-channel packet-ID generation. |
SubscribeRepository.java |
Stores subscriber QoS by topic. |
PubRel.java |
Handles PUBREL with PUBCOMP. |
Publish.java |
Applies granted QoS and emits PUBACK/PUBREC. |
MqttTransportHandler.java |
Cleans repositories when channels close. |
MqttFactory.java |
Dispatches PUBREL messages. |
MessageType.java |
Delegates PUBREL processing. |
Disconnect.java |
Clears packet-ID state. |
AbstractMessageType.java |
Adds the PUBREL handler contract. |
Review details
- Files reviewed: 14/14 changed files
- Comments generated: 8
- Review effort level: Balanced
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| case PUBREL: | ||
| messageType.pubRel(ctx, msg); | ||
| break; |
| } | ||
| int packetId = msg.variableHeader().packetId(); | ||
| CompletableFuture.runAsync(() -> send(topic, payload, packetId)); | ||
| CompletableFuture.runAsync(() -> send(topic, payload, mqttQoS)); |
| AtomicInteger packetId = CHANNEL_PACKET_ID_FACTORY.computeIfAbsent(channel, key -> new AtomicInteger()); | ||
| return packetId.updateAndGet(current -> current >= MAX_PACKET_ID ? MIN_PACKET_ID : current + 1); |
| Singleton.INST.get(ChannelRepository.class).remove(channel); | ||
| MqttPacketIdGenerator.remove(channel); |
| public void pubRel(final ChannelHandlerContext ctx, final MqttMessage msg) { | ||
| MqttMessageIdVariableHeader variableHeader = (MqttMessageIdVariableHeader) msg.variableHeader(); | ||
| MqttFixedHeader mqttFixedHeader = new MqttFixedHeader(PUBCOMP, false, MqttQoS.AT_MOST_ONCE, false, 0); | ||
| MqttMessage mqttPubCompMessage = new MqttMessage(mqttFixedHeader, variableHeader); | ||
| ctx.writeAndFlush(mqttPubCompMessage); | ||
| } |
| .filter(s -> s.qualityOfService() != MqttQoS.FAILURE) | ||
| .forEach(s -> TOPIC_CHANNEL_FACTORY | ||
| .computeIfAbsent(s.topicName(), key -> new ConcurrentHashMap<>()) | ||
| .merge(channel, s.qualityOfService(), SubscribeRepository::maxQoS))); |
| .forEach(s -> TOPIC_CHANNEL_FACTORY | ||
| .computeIfAbsent(s.topicName(), key -> new ConcurrentHashMap<>()) | ||
| .merge(channel, s.qualityOfService(), SubscribeRepository::maxQoS))); |
| public void remove(final Channel channel) { | ||
| CompletableFuture.runAsync(() -> TOPIC_CHANNEL_FACTORY.values().parallelStream() | ||
| .forEach(subscribers -> subscribers.remove(channel))); |
Aias00
left a comment
There was a problem hiding this comment.
Approved as PMC (Aias00). Coherent fix with regression tests; green CI, mergeable. Reviewed the diff.
Store the granted qos per subscriber channel in SubscribeRepository and allocate a packet id from each subscriber's own id space when fanning out publishes, instead of hard-coding AT_MOST_ONCE and reusing the publisher's packet id for every subscriber.
Make sure that:
./mvnw clean install -Dmaven.javadoc.skip=true.Summary
Fixed MQTT broker→subscriber fan-out to respect granted QoS and per-subscriber packet IDs.
Changes
subscriptions.
publisher's packet ID.
Tests — 13 new unit tests covering the generator, repository, and publish fan-out behavior; all pass with checkstyle.
close #6850
New Changs
#6743 — QoS 2 PUBLISH answered with PUBACK instead of PUBREC
Publish.java
spec-compliant clients treat as a protocol error
PUBREL → PUBCOMP half-handshake (new)
Tests: testPublishQos2SendsPubRecToPublisher verifies PUBREC type/flags/message-id; new PubRelTest verifies PUBCOMP echo.
#6744 — No cleanup on channel close → repository leak
MqttTransportHandler.java
SubscribeRepository.java
Test: new MqttTransportHandlerTest uses an EmbeddedChannel to verify all three repositories no longer hold the channel after close, and the packet-id space resets.
close #6743
close #6744