(2.15) [IMPROVED] MQTT: Pipeline QoS2 PUBLISH and PUBREL processing - #8416
(2.15) [IMPROVED] MQTT: Pipeline QoS2 PUBLISH and PUBREL processing#8416levb wants to merge 3 commits into
Conversation
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 6262eb7cfb
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
|
@claude review |
|
Claude finished @neilalexander's task in 6m 12s —— View job Code Review
This PR extends the QoS1 ack-pipeline approach to QoS2, eliminating 2–3 synchronous JetStream round-trips per exchange and adding an in-memory dedupe layer via Correctness of core invariantsState machine ( The three states per PI — absent, non-nil (pending), nil (released) — are correctly maintained:
All transitions look correct, including the reconnect case fixed in commit Discard-before-PUBCOMP ordering The PR claims the discard is FIFO-safe against PI reuse because it's "enqueued before the PUBCOMP that frees the PI." This holds because:
Both the discard and delivery store travel over the same
A subject-filtered stream purge rather than a sequence-based delete is a neat trick that eliminates the load entirely. One nuance worth checking: if a Observations / minor points
Released-entry accumulation
func (c *client) mqttMarkQoS2Released(pi uint16) {
if c.mqtt.qos2Exchanges == nil {
c.mqtt.qos2Exchanges = make(map[uint16]*mqttPublish)
} else if ...
c.mqtt.qos2Exchanges[pi] = nil
}This is called even on the JetStream fallback path for PIs that were never recorded. The nil entry is still correct (it screens subsequent retransmitted PUBRELs), and the allocation is a one-time cost, so this is fine as written.
Both Test coverage assessment
Coverage is comprehensive. One scenario not explicitly tested: a PUBREL arriving after the pipeline window is at SummaryThe implementation is well-designed and correct. The key invariants — FIFO discard-before-PUBCOMP ordering, the DUP reconnect fallback to JetStream, and the released-entry screen against retransmitted PUBRELs — all hold. No blocking issues found. |
2d44a89 to
e99060a
Compare
QoS2 ingest blocked the readLoop on four synchronous JetStream round-trips per message: the PUBLISH staged its copy and waited (one), and the PUBREL loaded that copy back, deleted it waiting for the ack, and stored it for delivery (three more). This extends the QoS1 pipeline treatment to QoS2. Where QoS1 has one acknowledged JetStream interaction per message (PUBLISH -> store -> PUBACK), QoS2 has two: PUBLISH -> staged store -> PUBREC, then PUBREL -> delivery store + staged-copy discard -> PUBCOMP. Both are now submitted asynchronously and acknowledged from the per-connection ack pipeline as the JetStream acks arrive, in packet order. The discard is fire-and-forget: by stream sequence where a load supplied one, else by the unique staging subject - needing no sequence is what made dropping the load possible. Submitting asynchronously costs the readLoop its read-your-own-writes view of JetStream, so the connection keeps the state it needs for deduplication while a JS interaction is in flight, in one PI-keyed map of its QoS2 exchanges (qos2Exchanges): - A non-nil entry is a copy of a pending PUBLISH, staged store to PUBREL. Duplicate PUBLISHes dedupe against it (first copy wins, mirroring the staging stream's max-msgs-per-subject; each still gets its PUBREC), and the delivery on PUBREL needs no JetStream load at all. Copies are bounded by the pipeline window; extras are not kept. - A nil entry marks a released exchange whose staged-copy discard is still in flight: a retransmitted PUBREL is answered with just the PUBCOMP, where a JetStream load could race the discard and deliver a duplicate [MQTT-4.3.3-1]. A new PUBLISH on the PI replaces the entry (a new publication per the spec). JetStream remains the source of truth: on a miss (a session resumed on a new connection, or past the cap) the PUBREL falls back to the JetStream load, as the synchronous code always did. The pipeline entries now carry their response packet type (PUBACK, PUBREC or PUBCOMP); the single FIFO preserves the per-packet-type ordering of [MQTT-4.6]. One behavior change: a PUBREL that loads an invalid staged message now fails the connection without first emitting the PUBCOMP that the old code sent via defer, and discards the corrupt message, so the PUBREL the client retries converges to a clean PUBCOMP. Added tests: PUBREC/PUBCOMP burst ordering with exactly-once delivery (TestMQTTQoS2AckPipelineOrder), and connection close with unreleased exchanges in flight - no leaked JSA reply registrations, released messages delivered, unreleased ones not (TestMQTTQoS2AckPipelineConnClose); a corrupt staged message failing the connection once, then converging (TestMQTTQoS2PubRelInvalidStagedMessage); and back-to-back PI reuse across releases with the discard still in flight (TestMQTTQoS2PIReuseAfterRelease). PUBREL-retransmission dedupe and PI reuse are also exercised by the existing TestMQTTQoS2RejectPublishDuplicates, which fails without the released marks. Measured on a real 3-node R3 cluster (GCP c2d, crossover methodology, QoS0 control 1.00, per-pass ratios agreeing): single connection, QoS2, 256-message window, ~690/s to ~18.1K/s (26x); p99 ack latency 384ms to 19ms. Before is flat at ~600-690/s at every window (four serialized JetStream round-trips per message); after rises monotonically with the client's window (1.8x at 1, 10x at 10, 15x at 20). The window-1 gain is pipelining-free: dropping the load and the waited delete halves the JetStream round-trips per exchange, so even strict stop-and-wait clients - which the QoS1 change could not help - benefit; the rest of the curve is the pipelining. Aggregate many-connection QoS2 throughput improves 1.4-1.5x (~12-13K/s to ~17.8K/s at 100/200 connections): unlike QoS1, pipelining moves the aggregate too, because it also removes per-message JS API round-trips, not just waiting. QoS1 (sweep, aggregate, MQTT.js) and QoS0 are unaffected (~1.0x). The remaining gap to QoS1's aggregate (~18K/s vs ~68K/s) is structural: two stream appends plus a discard per message, all on the single $MQTT_msgs R3 stream (#3116). Signed-off-by: Lev Brouk <levbrouk@gmail.com> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Lev Brouk <levbrouk@gmail.com>
A retransmitted PUBLISH on a resumed session was recorded in qos2Exchanges before the store proved it new, so its PUBREL could deliver the retransmit's bytes instead of the first-accepted staged message. A DUP PUBLISH unknown to the connection is no longer recorded: the PUBREL falls back to the JetStream load, which has the bytes that won. Added TestMQTTQoS2RejectPublishDuplicatesAcrossReconnect. Signed-off-by: Lev Brouk <levbrouk@gmail.com> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Lev Brouk <levbrouk@gmail.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 9ebd6fcc46
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| c.mqtt.sess.jsa.deleteMsg(mqttQoS2IncomingMsgsStreamName, stagedSeq, false) | ||
| } else { | ||
| c.mqtt.sess.jsa.deleteMsgBySubject(mqttQoS2IncomingMsgsStreamName, c.mqttQoS2InternalSubject(pi)) |
There was a problem hiding this comment.
Keep QoS2 release state until discard is durable
When a QoS2 publisher sends PUBREL and the connection drops before this fire-and-forget discard reaches JetStream, the only released marker is the connection-local qos2Exchanges entry and is lost on reconnect. If the client reconnects, especially to another server in a cluster where this send queue does not order the old discard before the new loadLastMsgFor, the retried PUBREL can load the still-staged copy and deliver it again even though the first connection already called mqttDeliverInboundMsg (immediately visible for QoS0 subscribers, or after the delivery store succeeds). The discard needs to be confirmed or the release marker made reconnect-visible before falling back to loading staged messages.
Useful? React with 👍 / 👎.
QoS2 ingest blocked the readLoop on four synchronous JetStream round-trips per message: the PUBLISH staged its copy and waited (one), and the PUBREL loaded that copy back, deleted it waiting for the ack, and stored it for delivery (three more).
This extends the QoS1 pipeline treatment to QoS2. Where QoS1 has one acknowledged JetStream interaction per message (PUBLISH -> store -> PUBACK), QoS2 has two: PUBLISH -> staged store -> PUBREC, then PUBREL -> delivery store + staged-copy discard -> PUBCOMP. Both are now submitted asynchronously and acknowledged from the per-connection ack pipeline as the JetStream acks arrive, in packet order. The discard is fire-and-forget: by stream sequence where a load supplied one, else by the unique staging subject - needing no sequence is what made dropping the load possible.
Submitting asynchronously costs the readLoop its read-your-own-writes view of JetStream, so the connection keeps the state it needs for deduplication while a JS interaction is in flight, in one PI-keyed map of its QoS2 exchanges (qos2Exchanges):
JetStream remains the source of truth: on a miss (a session resumed on a new connection, or past the cap) the PUBREL falls back to the JetStream load, as the synchronous code always did.
The pipeline entries now carry their response packet type (PUBACK, PUBREC or PUBCOMP); the single FIFO preserves the per-packet-type ordering of [MQTT-4.6]. One behavior change: a PUBREL that loads an invalid staged message now fails the connection without first emitting the PUBCOMP that the old code sent via defer, and discards the corrupt message, so the PUBREL the client retries converges to a clean PUBCOMP.
Added tests: PUBREC/PUBCOMP burst ordering with exactly-once delivery (TestMQTTQoS2AckPipelineOrder), and connection close with unreleased exchanges in flight - no leaked JSA reply registrations, released messages delivered, unreleased ones not
(TestMQTTQoS2AckPipelineConnClose); a corrupt staged message failing the connection once, then converging (TestMQTTQoS2PubRelInvalidStagedMessage); and back-to-back PI reuse across releases with the discard still in flight (TestMQTTQoS2PIReuseAfterRelease). PUBREL-retransmission dedupe and PI reuse are also exercised by the existing
TestMQTTQoS2RejectPublishDuplicates, which fails without the released marks.
Measured on a real 3-node R3 cluster (GCP c2d, crossover methodology, QoS0 control 1.00, per-pass ratios agreeing): single connection, QoS2, 256-message window, ~690/s to ~18.1K/s (26x); p99 ack latency 384ms to 19ms. Before is flat at ~600-690/s at every window (four serialized JetStream round-trips per message); after rises monotonically with the client's window (1.8x at 1, 10x at 10, 15x at 20). The window-1 gain is pipelining-free: dropping the load and the waited delete halves the JetStream round-trips per exchange, so even strict stop-and-wait clients - which the QoS1 change could not help - benefit; the rest of the curve is the pipelining. Aggregate many-connection QoS2 throughput improves 1.4-1.5x (~12-13K/s to ~17.8K/s at 100/200 connections): unlike QoS1, pipelining moves the aggregate too, because it also removes per-message JS API round-trips, not just waiting. QoS1 (sweep, aggregate, MQTT.js) and QoS0 are unaffected (~1.0x). The remaining gap to QoS1's aggregate (~18K/s vs ~68K/s) is structural: two stream appends plus a discard per message, all on the single $MQTT_msgs R3 stream.