Skip to content

fix(binding-mqtt, binding-mqtt-kafka, binding-kafka): flow control and null-safety defects on the MQTT publish path - #2523

Closed
sfr-oc wants to merge 8 commits into
aklivity:developfrom
sfr-oc:pr/mqtt-publish-flow-control
Closed

fix(binding-mqtt, binding-mqtt-kafka, binding-kafka): flow control and null-safety defects on the MQTT publish path#2523
sfr-oc wants to merge 8 commits into
aklivity:developfrom
sfr-oc:pr/mqtt-publish-flow-control

Conversation

@sfr-oc

@sfr-oc sfr-oc commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Summary

An MQTT client publishing small QoS 0 messages through an mqtt-kafka proxy to a multi-partition Kafka topic stops being served after roughly 60 messages. Zilla closes the connection itself, with no exception and no event in the log — the client only observes that the connection disappeared. Chasing that symptom surfaced seven distinct defects across three bindings; they are reported individually and fixed here in one commit each, so any subset can be taken independently.

One of the seven, #2522, has meanwhile been fixed upstream in #2534 and is no longer part of this branch, which has been rebased onto current develop.

The reported message count varies with payload size and socket buffering, which is why the same defect was observed at 42, 61 and 239 messages before its cause was understood.

Commits

Commit Issue Module
guard doKafkaData against unattached kafka stream #2520 binding-mqtt-kafka
guard KafkaSignalStream flush against non-fetch merged kind #2519 binding-mqtt-kafka
stop reserving padding once the publish stream's window is exhausted #2521 binding-mqtt
emit a window when a produce flush is acknowledged #2517 binding-kafka
account for the retained stream when granting the publish window #2518 binding-mqtt-kafka
derive the merged produce window from the least budget offered #2516 binding-kafka
cover the merged produce window with an idle partition test

The primary defect (#2516)

KafkaMergedFactory.doMergedInitialWindow aggregates produce partitions with two independent reductions — the largest unacknowledged byte count, and the smallest maximum. A partition carrying traffic holds its acknowledge and grows its maximum, while idle partitions keep the maximum they were opened with, so the two reductions select different partitions and pair values that describe neither one's budget. The merged window then shrinks by every byte written until it reaches zero. With a single partition both reductions select the same stream, which is why this only appears on topics with more than one partition.

Verification

merged.produce.message.values.partition.idle opens two produce partitions, sends all traffic to one and leaves the other idle. Without the fix it stalls at the idle partition's maximum:

CacheMergedIT.shouldProduceMergedMessageValuesPartitionIdle
  org.junit.runners.model.TestTimedOutException: test timed out after 10 seconds

Also reproduced end to end against a live engine (examples/mqtt.kafka.proxy, Kafka 4.1.1, 5-partition topic):

published forwarded
before 200 60
after 600 600

Regression across the touched modules and their spec projects — binding-kafka, binding-mqtt-kafka, binding-mqtt and each .spec — passes with 0 failures, checkstyle and license checks clean.

An earlier revision of #2516 broke CacheMergedIT/ClientMergedIT shouldProduceMergedMessageValue100k; the committed form is arithmetically identical to the previous computation for a single partition, and both tests pass.

Test coverage — please read

Coverage is uneven and I would rather say so up front:

Issue k3po coverage
#2519, #2521, #2516 new scenarios, fail without the fix
#2518 new scenario, but it passes with and without the fix — the transport acknowledges a flush's reserved immediately and only ever writes reserved=0 flushes, so the condition cannot be constructed
#2520, #2517 none

Status

Verified against the reporting environment's Kafka test cluster: 1000 messages published without a disconnect, where the same client previously stalled. Not yet verified in that environment's production deployment.

Given how much of this was found by inspection rather than by a failing test, independent verification before merging would be welcome — in particular #2520 and #2518, where the remedy involves a judgement call rather than a mechanical correction. #2520 guards a write that would otherwise throw; if the intended behaviour is to defer it until the stream attaches, or to fail the session explicitly, the patch should follow that instead.

@jfallows

jfallows commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

@sfr-oc please provide the reproduction recipe for the externally observed issue, including the zilla.yaml configuration and the mqtt command line used to trigger the issue.

@sfr-oc

sfr-oc commented Sep 4, 2026

Copy link
Copy Markdown
Contributor Author

Reproduction recipe

The shipped examples/mqtt.kafka.proxy reproduces it unchanged, with a single modification: the message topic must have more than one partition. With the example's default of one partition the defect does not appear.

1. Topic setup

In examples/mqtt.kafka.proxy/compose.yaml, the mqtt-devices topic is created without an explicit partition count. Change that one line to create it with five:

- kafka-topics.sh --bootstrap-server $${KAFKA_BOOTSTRAP_SERVER} --create --if-not-exists --topic mqtt-devices --config cleanup.policy=compact
+ kafka-topics.sh --bootstrap-server $${KAFKA_BOOTSTRAP_SERVER} --create --if-not-exists --topic mqtt-devices --partitions 5 --config cleanup.policy=compact

Everything else in the example is used as-is. The relevant part of etc/zilla.yaml is unmodified:

  north_mqtt_kafka_mapping:
    type: mqtt-kafka
    kind: proxy
    options:
      topics:
        sessions: mqtt-sessions
        messages: mqtt-messages
        retained: mqtt-retained
      clients:
        - place/{identity}/#
    routes:
      - when:
          - publish:
              - topic: place/+/device/#
              - topic: device/#
          - subscribe:
              - topic: place/+/device/#
              - topic: device/#
        with:
          messages: mqtt-devices
        exit: north_kafka_cache_client
    exit: north_kafka_cache_client

The binding chain is the example's: tcp server → mqtt server → mqtt-kafka proxy → kafka cache_client → kafka cache_server → kafka client → tcp client. No TLS is needed to reproduce.

cd examples/mqtt.kafka.proxy
docker compose up -d

2. Client

A single MQTT 3.1.1 client publishing QoS 0 messages, no retain flag. All messages carry the same topic, so they hash to one partition and the remaining four stay idle — that is what makes the two reductions in doMergedInitialWindow select different partitions.

docker compose exec mosquitto-cli sh -c '
  i=0
  while [ $i -lt 600 ]; do
    echo "{\"seq\":$i,\"test\":\"payload\"}"
    sleep 0.01
    i=$((i+1))
  done | mosquitto_pub -h zilla -p 7183 -d -l \
    -t "device/loadtest/state" -q 0 -i "loadtest-client"'

Payload is ~30 bytes, which works out to ~542 bytes reserved per message on the produce stream.

3. Observed

published forwarded to Kafka
develop 200 60
with this PR 600 600

60 × 542 ≈ 32768, i.e. bufferPool.slotCapacity() — the merged window is consumed once and never replenished. The client itself reports no error at that point; mosquitto_pub keeps writing into socket buffers and only fails later, which is why the same defect was first reported at 42, 61 and 239 messages depending on payload size and buffering.

The collapse can be watched directly with -Dzilla.binding.kafka.debug.produce=true on the zilla service: the merged window steps down by one message's reserved per publish and is never raised again, while the partition carrying the traffic reports a healthy budget of its own.

4. Without Docker

CacheMergedIT.shouldProduceMergedMessageValuesPartitionIdle, added in this PR, covers the same condition against a live engine: two produce partitions, all traffic to one, the other idle. On develop it fails with

org.junit.runners.model.TestTimedOutException: test timed out after 10 seconds

stalling after the 8th of eleven 1024-byte messages — 8 × 1024 = 8192, the idle partition's maximum, which CacheMergedIT configures via ENGINE_BUFFER_SLOT_CAPACITY.

5. Originally reported environment

For completeness, the environment where this first surfaced additionally used TLS with mutual auth on both the MQTT listener and the Kafka client, and a three-broker cluster with a five-partition topic (replication factor 1, no under-replicated partitions). Neither TLS nor the broker count is required to reproduce — the partition count is.

sfr-oc and others added 8 commits September 4, 2026 09:52
…tch merged kind

KafkaMergedFlushEx is a union discriminated by kind (produce, fetch,
consumer). KafkaSignalStream.onKafkaFlush called .fetch() on it
unconditionally, without checking that the flush was actually of the
fetch sub-kind. Since this stream both produces will/expiry signals
and fetches other instances' signals on the same merged Kafka
subscription, a produce-kind (or consumer-kind) flush is a normal,
frequent occurrence -- every produce ack emits one. Reading .fetch()
off a non-fetch-kind union member returns an unwrapped flyweight,
and iterating its "progress" array throws a NullPointerException on
Array32FW.buffer(), terminating the whole engine worker.

Guard the read with the same kind() check already used one line
above for the outer union, mirroring the existing pattern in this
method.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…indow is exhausted

MqttPublishStream.initialBudget() returns initialMax - (initialSeq -
initialAck), unlike MqttSessionStream's version which also subtracts
initialPad. decodePublishPayload computed the reserved size as
Math.max(publisher.initialPad, Math.min(lengthMax + initialPad, initialBudget)),
which floors at initialPad even once the window can no longer hold it.

Once the granted window is exhausted, this kept emitting zero-length DATA
frames that still reserved initialPad bytes, advancing initialSeq with no
decode progress. Every subsequent WINDOW re-entered the same path and burned
another initialPad, walking initialSeq past initialAck + initialMax. The
downstream peer eventually sees sequence > acknowledge + maximum and RESETs,
which propagates to onDecodeError with a reason code the MQTT v3.1.1 CONNACK
encoding can't carry (reasonCode > MAX_CONNACK_REASONCODE_V4), so
doNetworkEnd tears down the connection with no message sent to the client at
all - the client just sees the TCP/TLS connection disappear.

The threshold is bytes, not messages: it trips as soon as cumulative
payload + padding first exhausts the granted initial window, so message
count before the cutoff scales inversely with payload size - independent of
any will-message handling.

Compute the payload cap as Math.min(lengthMax, initialBudget - initialPad)
and only proceed once it and the resulting claimed size are non-negative, so
once the window is exhausted no frame is emitted and no forward progress is
recorded - the stream just waits for the next WINDOW like every other
backpressured decode path in this file.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
onClientInitialFlush advanced initialAck itself before asking
doClientInitialWindow to publish it. That method only writes a WINDOW frame
when it observes the acknowledge or the maximum change, and it derives the
new acknowledge from the caller's noAck: with initialAck already advanced,
initialSeq - noAck resolves back to the current initialAck, so the guard
never fires and the frame is never written. The credit is released
internally and never reaches the sender, whose view of the window shrinks by
every flush until it reaches zero.

Pass the outstanding byte count without pre-applying it, so
doClientInitialWindow advances the acknowledge and emits the frame, and keep
the maximum from regressing below the one already advertised. With a
zero-reserved flush this resolves to the previous behaviour.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…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>
…udget offered

doMergedInitialWindow aggregated the produce partitions by taking the largest
unacknowledged byte count across them and, separately, the smallest maximum.
A partition carrying traffic holds its acknowledge and grows its maximum,
while idle partitions keep the maximum they were opened with, so those two
reductions select different partitions: the unacknowledged bytes of the
busiest one get paired with the maximum of an idle one. The merged window
then shrinks by every byte written until it reaches zero and the stream
stalls for good. With a single partition both reductions select the same
stream, which is why this only appears on topics with more than one
partition.

Reduce over the budget each partition actually offers instead, and add the
unacknowledged bytes back to express it as a maximum. For a single partition
this is arithmetically identical to the previous computation.

Adds a merged window trace under the existing produce debug property, since
this aggregation was not observable from a running gateway.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…tition

merged.produce.message.values.partition.idle opens two produce partitions,
sends all traffic to one and leaves the other idle. Reducing the maximum
across partitions independently of the unacknowledged bytes pins the merged
window to the idle partition's maximum while the busy partition's
unacknowledged bytes keep growing, so the stream stalls once that maximum is
reached; the scenario asserts a further write still succeeds beyond it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…tream

KafkaSessionStream.kafka is only assigned inside a subclass's doKafkaBegin
override, so it stays null until that handshake step runs. Every other
outbound method on the class (doKafkaEnd, doKafkaAbort, doKafkaReset,
doKafkaWindow) already guards on kafka != null, but the three doKafkaData
overloads did not.

Reachable independently of the QoS2+Will+CleanStart timing race already
covered by shouldSendWillSignalOnQos2AbortBeforeSessionEstablished: any
uncaught exception on the engine worker triggers EngineWorker.onClose's
forced synthetic-abort teardown, which calls onMqttAbort -> sendWillSignal /
sendExpirySignal -> doKafkaData on every still-open MqttSessionProxy,
including ones whose Kafka-side session stream never finished attaching.
That unconditional receiver.accept(...) in the shared doData helper then
NPEs, escalating the original fault into a second crash instead of a clean
per-worker restart.

Add the same kafka != null guard to all three doKafkaData overloads, at the
single shared point that covers sendWillSignal, sendExpirySignal, and every
other caller.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@jfallows

jfallows commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

@sfr-oc thank you for filing the issues and providing most of the fixes in your PR. 🙏

We've split this PR into a separate PR per issue, each with test coverage and following IT scenario naming conventions.
Each PR has been merged to develop and the issues are closed, so closing this PR now as well.

The reproduction recipe now works as desired.

The fixes will be included in the next 2.x release, which is imminent.

@jfallows jfallows closed this Sep 5, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants