Skip to content

[fix][client] Commit ByteBufPair frames as a single outbound pipeline entry - #26459

Closed
nodece wants to merge 1 commit into
apache:masterfrom
nodece:fix-encoder-frame-atomicity
Closed

[fix][client] Commit ByteBufPair frames as a single outbound pipeline entry#26459
nodece wants to merge 1 commit into
apache:masterfrom
nodece:fix-encoder-frame-atomicity

Conversation

@nodece

@nodece nodece commented Sep 4, 2026

Copy link
Copy Markdown
Member

Motivation

ByteBufPair.Encoder — the outbound encoder that both the client and broker PulsarChannelInitializer install for the binary protocol — serialized each frame as two independent pipeline writes: the header half with the void promise, then the payload half carrying the real write promise. The two entries share no failure domain.

If anything fails after the header half is enqueued but before the payload half is claimed — most notably when the send-timeout path concurrently releases the pair's buffers while the frame is being written on the event loop — a header-only entry is committed to the channel outbound buffer and reaches the wire. (In ProducerImpl, the cnx == null reconnect-window branch of failPendingMessages disposes in-flight ops on the timer thread, which can race the write of the same op on the connection's event loop.)

The consequence is severe: the peer's LengthFieldBasedFrameDecoder permanently loses frame sync on that connection, and the broker rejects everything that follows:

ERROR org.apache.pulsar.broker.service.Producer - [...] Failed to verify checksum
WARN  org.apache.pulsar.broker.service.ServerCnx - [...] Got exception io.netty.handler.codec.TooLongFrameException: Adjusted frame length exceeds 5253120: 1515870814 - discarded

(1515870814 is 0x5A5A5A5A — the broker read message payload bytes as the frame length, i.e. the stream was already misaligned mid-payload.) The connection is then closed and all in-flight messages on it are lost.

This is a long-lived latent defect that only surfaces under a specific conjunction of conditions, which is why intermittent reports with this exact signature have historically been attributed to the network layer or intermediaries (e.g. #21557):

  • batching with a short sendTimeout (e.g. 3s);
  • a connection drop / reconnect window (broker or proxy restart, cluster churn);
  • sender-thread stalls — e.g. a tight -XX:MaxDirectMemorySize makes Bits.reserveMemory block the allocating thread for seconds near the cap — aging messages past the timeout exactly while their writes are queued on the event loop.

Modifications

  • Encoder and CopyingEncoder now build each frame as a single CompositeByteBuf (component claims taken via retain() in Encoder, copies in CopyingEncoder) and commit it with one ctx.write() carrying the real promise: a frame reaches the outbound buffer whole or not at all.
  • Any failure during frame construction releases the composite (the composite allocation itself is inside the protected region), fails the write promise, and commits nothing.
  • The pair keeps its original component claims, so writing a pair under an extra claim (resend after reconnect) keeps working.

Verifying this change

  • Make sure that the change passes the CI checks.

This change added tests and can be verified as follows:

ByteBufPairTest now covers:

  • a frame is committed as a single outbound message (both encoders, including the TLS copying encoder);
  • the same pair can be written again under an extra claim (resend semantics, mirroring op.cmd.retain());
  • when a pair's component is concurrently released, only complete frames reach the wire — testEncoderFailedBuildKeepsStreamAligned is the minimal deterministic repro: on the previous two-write encoder it fails with a header-only "head!" entry committed between the good frames;
  • a failed build fails the write promise and commits nothing;
  • the composite allocation failure path releases the pair and fails the write.

End-to-end reproduction (how the defect was confirmed and the patch validated):

  1. standalone broker + Pulsar proxy as separate docker containers (hard socket kill on proxy restart);
  2. producer with batchingMaxMessages=1000, batchingMaxBytes=128KB, batchingMaxPublishDelay=1ms, sendTimeout(3, SECONDS), no compression, 4 threads x 8KB payloads, client JVM -XX:MaxDirectMemorySize=48m, connecting through the proxy;
  3. docker restart the proxy during active traffic (twice per 50s round);
  4. check the broker log for Failed to verify checksum / TooLongFrameException / unknown tag type.

Results: the unmodified master client corrupted the stream in round 2 of this harness with the exact signature quoted above; with this patch, 34 rounds (~770k messages) produced zero corrupt entries — the client-side send errors after each restart were transient and always recovered.

Does this pull request potentially affect one of the following parts:

  • Dependencies (does it pull in a new dependency?): no
  • The public API: no
  • The schema registry: no
  • The default values of configurations: no
  • The wire protocol: no — the byte stream is unchanged, only the atomicity of committing it
  • The rest endpoints: no
  • The admin cli options: no
  • Anything that affects deployment: no

Documentation

  • no-doc needed (internal fix, no user-facing behavior or configuration change)

… entry

Motivation

ByteBufPair.Encoder wrote each frame as two independent pipeline writes:
the header half with the void promise, then the payload half with the
real promise. The two entries had no common failure domain: if claiming
or writing the payload half failed after the header half was already
enqueued (for example when a concurrent send-timeout disposal released
the pair's buffers between the two writes), a header-only entry reached
the outbound buffer and permanently desynchronized the peer's frame
stream. The broker then rejects everything that follows with
"Failed to verify checksum" / TooLongFrameException and closes the
connection. Because the failure depends on a race between the timeout
disposal and the write path, it surfaced only under memory pressure and
connection churn, not in steady state.

Modifications

- Encoder and CopyingEncoder now build a single CompositeByteBuf per
  frame (retain-based component ownership for Encoder, copies for
  CopyingEncoder) and commit it with one ctx.write() carrying the real
  promise: a frame is handed to the outbound buffer whole or not at
  all, and on a failed build the write promise is failed and nothing
  is committed.
- The pair keeps its original component references, so a pair written
  multiple times (resend after reconnect) still works.
- Add regression tests: single-message commit, repeated writes of the
  same pair, and stream alignment when a component is concurrently
  released (no partial entry may reach the wire).

@lhotari lhotari left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Before merging, please test performance implications of this approach with async-profiler in the PulsarProfilingTest integration test, support added by #26460.

@dao-jun dao-jun left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@merlimat

merlimat commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

@nodece If the first write fails, the second must fail too.

Add a composite bytebuf here does not make much sense. ByteBufPair was created precisely to avoid the overhead of the general purpose composite bytebuf.

@lhotari

lhotari commented Sep 5, 2026

Copy link
Copy Markdown
Member

If anything fails after the header half is enqueued but before the payload half is claimed — most notably when the send-timeout path concurrently releases the pair's buffers while the frame is being written on the event loop — a header-only entry is committed to the channel outbound buffer and reaches the wire.

It seems that Netty is used in the wrong way if this would happen. Concurrently releasing something that is already queued should be addressed instead. It could multiple other issues if already queued buffers are released. When ctx.write is called, the ownership is handed over from the caller to the channel pipeline. The channel is responsible of releasing the buffer (or other ReferenceCounted object). If the send-timeout pair releases the buffer after it's already written to the pipeline, that's a bug.

@nodece

nodece commented Sep 7, 2026

Copy link
Copy Markdown
Member Author

@merlimat @lhotari thanks for the review — I re-validated today and you were both right.

I tested the exact head of #26456 alone (bytecode-verified: the two-write encoder untouched, only the release-side fix in), in the same harness where unmodified master corrupts the broker within the first round (Failed to verify checksum + TooLongFrameException). Result: 22/22 rounds, zero broker-side corruption — including one round with ~49k client send errors during a heavy restart window, still no corrupt bytes on the wire.

My earlier conclusion that the encoder also needed a change came from a run whose jar was built from a dirty working tree; it doesn't reproduce with the isolated commit. That's on me.

Closing this PR. #26456 is the right fix — it's exactly the "address the concurrent release at the source" that @lhotari described, and @merlimat's point about the ByteBufPair design intent stands.

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants