Repository navigation
perf(clients): coalesce acks for buffered publishes into one write; fast ack encoder - #552
Open
barisvelioglu-maestrohub wants to merge 1 commit into
Open
barisvelioglu-maestrohub wants to merge 1 commit into
barisvelioglu-maestrohub wants to merge 1 commit into
Conversation
…ast ack encoder Every QoS 1 PUBLISH had its PUBACK encoded and written synchronously with its own write(2). The existing outbuf only batched while WriteLoop's queue was non-empty, and a publishing client's queue is empty. In production profiling of a broker with ~1,000 publishing clients, write syscalls were ~13% of all CPU, almost all of it PUBACKs from processPublish. Coalescing: after decoding a packet, Read checks whether the next packet is already complete in the bufio.Reader (a Peek over buffered bytes, no I/O). While it is, success acks (PUBACK/PUBREC from processPublish, PUBREL, PUBCOMP) are appended to outbuf under the existing write lock. outbuf is flushed when the reader would next block on the socket, after 1 ms of burst, when it reaches ClientNetWriteBufferSize (the same blocking flush as before, so back-pressure is unchanged), on any other packet written, before processDisconnect stops the client, and when Read returns. A lone packet has nothing behind it and is acked immediately, as before. Per-client order is call order, since every write still goes through outbuf under one lock. Ack encoder: PUBACK/PUBREC/PUBREL/PUBCOMP with no Reason String or User Properties (the only properties they can carry) are written as their fixed 2-3 byte body: ~620 ns -> ~10 ns. Pinned byte-for-byte against the general encoder over 1,200 combinations. ReadPacket no longer copies every inbound packet a second time (-1 alloc per packet). Also fixes the TestServerProcessPublishACLCheckDeny flake: it asserted Closed() before processPublish had returned. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KH4BjF1aiSybX9GxM8GXBR
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.
What
A burst of QoS 1 publishes from one client now gets its PUBACKs in a few writes instead of one
write(2)per message. Also:ReadPacketdrops a redundant second copy of every inbound packet.A single publish with nothing behind it is still acked immediately by its own write. No exported API or config changes.
Why
processPublishwrites the PUBACK synchronously throughWritePacket, so every QoS 1 message costs one encode and onewrite(2).WritePacketalready has a write buffer (outbuf), but it only holds bytes whileWriteLoop's outbound queue is non-empty. A client that only publishes has an empty queue, so every ack goes straight to the socket.Production profiling of a broker with ~1,000 publishing clients (QoS 1, MQTT 5) showed this clearly:
writesyscalls were ~13% of all CPU.WritePackettime came fromprocessPublish, i.e. one PUBACK per message.PubackEncodewas ~16% ofWritePacket. Most of that wasProperties.Encodewalking every property kind through nested map lookups, for a packet type that can carry only two properties.Fix
Coalesce acks while more input is already buffered. After decoding a packet,
Client.Readchecks whether the next packet is already complete in itsbufio.Reader. This is aPeekover bytes already buffered, so it does no I/O. While that is true, success acks are appended tooutbufunder the existing client lock instead of being written. They go through a new unexportedwriteAck, which covers:processPublishprocessPubrecprocessPubreloutbufis flushed when any of these happens:maxAckCoalesceDelay(1 ms)OnPublishhooks on later packets can't hold earlier acks backoutbufreachesClientNetWriteBufferSizeoutbufunder one lock, so per-client order is call orderprocessDisconnect, beforeStopReadreturnsWrite deadlines are unchanged.
OnPacketSentfires when the bytes are buffered, which is what already happened whenWritePacketbuffered behind a non-empty outbound queue.Fast ack encoder. These four packet types can only carry Reason String and User Properties. Both are suppressed when the client disallowed problem info. When neither will be written, the packet is a fixed 2–3 byte body, so it is now written directly (~620 ns → ~10 ns). Anything else falls through to the existing encoder, unchanged.
TestEncodePubAckRelRecCompFastPathMatchesGeneralcompares the two paths byte for byte over 1,200 combinations of type, protocol version, reason code, properties,DisallowProblemInfoandMaxSize.ReadPacketallocatedpfresh for every packet and then copied it into another fresh slice before decoding. The copy bought no isolation, so it is removed (−1 allocation and one memmove per inbound packet).A note on QoS 1
In
processPublishthe inbound QoS 1 inflight entry is deleted right after the PUBACK is handed towriteAck. With this change the PUBACK may still be inoutbufat that point, for at most the coalescing window (1 ms, or until the burst ends). If the connection dies inside that window, the client never sees the PUBACK and resends. The broker has already delivered the message, so it is delivered twice. QoS 1 permits this ("at least once"), and it could already happen when a PUBACK was lost afterwrite(2)returned. The window is wider, though, so it seemed worth stating. QoS 2 is unaffected: its PUBREC entry stays inflight until PUBREL.Benchmarks
ack_coalesce_bench_test.godrives the broker through a real TCP listener on loopback. Each client keeps 32 QoS 1 publishes in flight, oneconn.Writeper publish, like a client library would. It counts the broker'sconn.Writecalls through a wrappednet.Listener.mainvs this PR, interleaved,benchstatn=8, 32-thread host:OnPublishOnPublishOnPublishAll changes are p ≤ 0.016. allocs/op is 11 → 10 in every case. The idle row is the one that must not regress: with nothing buffered behind a publish, its PUBACK still goes out in its own write, immediately. The small latency gain there comes from the cheaper encoder. The
OnPublishrows use a hook that busy-waits 20 µs to stand in for real per-message work.Tests
Real TCP (
ack_coalesce_tcp_test.go):TestAckCoalesceBurstInOrderWithFewerWritesmain: 200)TestAckCoalesceLonePublishFlushedImmediatelyTestAckCoalescePartialNextPacketDoesNotHoldAcksTestAckCoalesceDisconnectInSameSegmentKeepsAcksTestAckCoalesceHeldAckFlushedAfterDelayBoundUnit (
ack_coalesce_test.go, overnet.Pipe):ClientNetWriteBufferSizebefore the writer blocks, and nothing is lost or reordered once it reads.Readflushes held acks when the burst ends in a handler error.nextPacketBuffered: zero-length packet, multi-byte length, partial length, malformed length.The assertions are on bytes and write counts, not elapsed time.
Each guard was checked by mutation: removing the DISCONNECT flush, the delay bound, the
Readexit flush, or the partial-packet check makes the matching test fail.The PR also fixes the
TestServerProcessPublishACLCheckDenyflake. It assertedcl.Closed()beforewg.Wait(), butStopcloses the conn (ending the test'sReadAll) before it marks the client closed. With the assertion afterwg.Wait()it passes 200/200 under-race.Commands run:
Related
Independent of #507, #508, #549, #550 and #551. It merges cleanly with each of them (checked with
git merge-tree), so they can land in any order.🤖 Generated with Claude Code
https://claude.ai/code/session_01KH4BjF1aiSybX9GxM8GXBR