fix(binding-mqtt-kafka): account for the retained stream when granting the publish window - #2548
Merged
jfallows merged 3 commits intoSep 5, 2026
Conversation
…g the publish window A publish proxy on a route that has a retained topic configured charges the retained stream a full reserved on every non-retained PUBLISH, because onMqttData issues a flush on it for each message that does not carry the retain flag. The window granted upstream, however, was derived from the messages stream alone: doMqttWindow guarded the retained-aware computation with hasPublishFlagRetained, a per-message flag that onMqttData resets after every FIN, leaving the branch unreachable in steady state. The retained stream's sequence could therefore advance past the window it was granted, and its credit never constrained what the client was allowed to send. Gate the computation on retainAvailable, the per-stream capability that actually decides whether the retained stream is charged. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… publish window The messages and retained streams advertise credit in different shapes: the messages stream holds its acknowledge at zero and grows its maximum, while the retained stream advances its acknowledge against a fixed maximum. Minimizing acknowledge and maximum independently across the two therefore pairs the acknowledge of one with the maximum of the other, describing neither stream's actual budget, and caps the window offered upstream at a fixed maximum whose acknowledge never moves - a limit on the total bytes a connection can publish rather than on bytes in flight. Compare the budget each stream offers and express the smaller of the two against the chosen maximum, keeping the acknowledge monotonic. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…een messages and retained The prior commit's own scenario (publish.many.messages.retain.available) still passes with the second commit reverted: both streams share the k3po stub's declared window, so messages.max and retained.max can never diverge under the scenario's default flow control, and the fix is a no-op whenever they're equal (provably: min(max_m,max_r) - seq + min(ack_m,ack_r) reduces to the same expression on both sides of the fix when max_m == max_r, regardless of how far ack_m/ack_r diverge). Reproduce the divergence using existing k3po options rather than new harness code: `option zilla:update "handshake"` on the shared accept stops the auto-acknowledge that a STREAM-mode DATA read would otherwise trigger, so messages' own ack stays pinned at its initial handshake value once we explicitly grow its max via `read option zilla:window` -- retained's flush-triggered auto-acknowledge is unconditional regardless of update mode, so it keeps advancing its ack against its original, unchanged max. This reproduces "messages holds ack, grows max; retained advances ack, fixed max" -- the exact mismatch the second commit's own description names -- without needing a second address, a second accept, or any new option. Reverting just the second commit now times out publishing partway through: the pre-fix budget mixes messages' pinned ack with retained's smaller fixed max, understating the real combined budget until the client can no longer make progress. Restoring the fix keeps the scenario passing. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015YVNaqKvEXZVzmg3HoGVnt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
Fixes #2518
When a route has a
retainedtopic configured,MqttKafkaPublishFactorymaintains two downstream Kafka streams per publish connection:messages(the main topic — every publish goes here) andretained(the retained-messages topic — even a non-retained publish still issues adoKafkaFlushon this stream carrying the message's fullreservedbytes, so its budget shrinks in lockstep withmessages).doMqttWindowcombines both streams' windows into the one granted upstream to the MQTT client, and had two independent defects:Wrong gate. The retained-aware branch was guarded by
hasPublishFlagRetained(publishFlags)— a per-message flag, true only while a message carrying RETAIN is being processed and reset after every FIN. In steady state that branch was effectively dead code. Fixed to gate onretainAvailable, a per-stream capability decided once at BEGIN, so the retained stream's budget is factored in for the stream's whole life.Wrong arithmetic. Even when reachable, the computation was
min(messages.ack, retained.ack)paired withmax(messages.max, retained.max). Butmessagesholds its ack pinned at zero and grows its max, whileretainedadvances its ack against a fixed max — two different representations of "room left". Minimizing ack and maximizing max independently combines the ack of one stream with the max of the other, describing neither stream's actual budget, and (sinceretained.maxnever changes) permanently caps the connection's lifetime publishable bytes once it exceedsmessages.max, rather than bounding bytes in flight. Fixed to compute each stream's own available budget uniformly asmax - (seq - ack), take the smaller of the two, and express it against the chosen max via a monotonic ack.Credit to community contributor @sfr-oc, who diagnosed and fixed this as part of the combined #2523. This PR cherry-picks both commits standalone (unmodified,
98148a89and208f10f6), since #2523 bundles seven independently-scoped defects across three bindings into one PR — each deserves its own focused review, and this one is ready on its own.Test coverage
The first commit's own scenario (
publish.many.messages.retain.available) still passed with the second commit reverted: bothmessagesandretainedareacceptedchildren of the sameaccept "zilla://streams/kafka0"in the k3po test stub, so they always share the identical declared window andmessages.maxcan never diverge fromretained.max. I can show algebraically that the pre-fix formula reduces to the exact same expression as the fix whenevermax_m == max_r, regardless of how far the ack values diverge — so the defect was structurally unreachable from that scenario as written.Reproduced the divergence using existing k3po options, no new harness code:
option zilla:update "handshake"on the shared accept stops the automatic acknowledge aSTREAM-mode DATA read would otherwise trigger, somessages' own ack stays pinned at its initial handshake value once its max is explicitly grown viaread option zilla:window.retained's flush-triggered acknowledge is unconditional regardless of update mode, so it keeps advancing normally against its original, unchanged max — reproducing exactly the "messages holds ack, grows max; retained advances ack, fixed max" mismatch the second commit's own description names.specs/binding-mqtt-kafka.spec'sstreams/kafka/publish.many.messages.retain.available/server.rpt(2 additions, no new scenario needed — the existing runtime ITMqttKafkaPublishProxyIT#shouldPublishManyMessagesRetainAvailableand self-consistencyKafkaIT#shouldPublishManyMessagesRetainAvailableboth exercise it)Verification
KafkaITscenario still passes independent of the engine, so the two scripts remain mutually consistentbinding-mqtt-kafkaandbinding-mqtt-kafka.specIT suites — both clean🤖 Generated with Claude Code
https://claude.ai/code/session_015YVNaqKvEXZVzmg3HoGVnt
Generated by Claude Code