Skip to content

[fix][client] Fix buffer ownership on the send failure paths - #26455

Merged
nodece merged 21 commits into
apache:masterfrom
nodece:fix-batch-buffer-use-after-release
Sep 14, 2026
Merged

nodece merged 21 commits into
apache:masterfrom
nodece:fix-batch-buffer-use-after-release

Conversation

@nodece

@nodece nodece commented Sep 3, 2026 •

Copy link
Copy Markdown
Member

Motivation

When a send fails midway, every buffer handed through the pipeline must be released by the component that last took its ownership. This PR fixes the paths that violated this and leaked buffers instead — leaks that accumulate exactly under the sustained-failure conditions (e.g. direct-memory pressure) that trigger the failures in the first place.

Batch flush failure recovery (original scope): when compression or encryption releases the batch buffer before a flush that then fails, resetPayloadAfterFailedPublishing() could not tell whether the container still owned the buffer: retrying reused the released buffer (use-after-free), and the compressed/encrypted payload that replaced it was left orphaned (leak).

Multi-batch partial build (from review, @void-ptr974): when a later sub-batch fails to build, the operations already built never reached the send queue and their commands leaked.

Chunked payload base claim: for chunked persistent messages the non-last chunks retain their slices while the base payload's own ref-count claim is carried by the last chunk. A serialization failure on a non-last chunk orphaned that claim, leaking the shared payload buffer on every failed chunked send (found while building the wiring tests for this PR).

Encrypted payload on the send failure paths: the payload buffer produced by the encryption step leaked at all three failure points of its lifecycle — during encryption (the partially built output buffer), after encryption (a failed command serialization orphaned it; for chunked messages the retained slice also leaked its claim on the shared base buffer), and when the command was deferred until schema registration and the op was failed first (send timeout, producer close) — the payload leaked with the unreachable rePopulate closure.

Oversized batch rejection (from review): both max-message-size rejection paths in BatchMessageContainerImpl.createOpSendMsg() could release the batch buffer a second time after it was already freed, in the no-compression/no-encryption case where the payload IS the container's own buffer. The single-message path released the command (whose payload was the container's claim) and then the internal discard() released the same buffer again; the multi-message path released the payload unconditionally ahead of the discard() that owns the release. The swallowed IllegalReferenceCountException hid the double release, but a buffer already recycled in between could be corrupted by the second release.

Queue accounting stranded by pre-op failures (from review, @void-ptr974): two failure points leaked accounting acquired before an OpSendMsg existed to release it through the send lifecycle. A non-batch compression failure after canEnqueueRequest propagated out of sendAsync() synchronously, leaving the returned future incomplete with the send permit consumed and the memory reserved — repeated failures exhausted the producer queue and the client memory limit. A chunk-build failure in non-blocking mode stranded the send permits pre-acquired for the never-built chunks (failing the first of N chunks permanently cost N-1) and their claims on the ChunkedMessageCtx, which only created ops release.

Chunked memory reservation released once per message: every chunk op carried the full message's uncompressed size (OpSendMsg.create reads it from the shared MessageImpl), so each of the N chunk acks released the whole reservation again — the memory gauge drifted low by (N-1) sizes, silently disabling the client memory limit for chunked producers. The blocking-mode chunk-enqueue failure branch had the mirror-image defect: it released only uncompressedSize - readStartIndex (leaking the already-chunked bytes once the earlier ops stopped carrying a share) plus totalChunks - chunkId permits that blocking mode never acquires (each chunk takes its permit inside the loop, and the failing attempt aborts before acquiring).

Failure-path op cleanup: the catch of processOpSendMsg() (from #23738) released only the permit and the memory when the connection's event loop rejected the write task — the cmd leaked both references (the op's own and the one retained for a write that never got queued), a chunked op's ChunkedMessageCtx claim leaked, and the op stayed in pendingMessages unrecycled. In the resend recovery loop, a rebuilt command exceeding the max message size took the isMessageSizeExceeded path — which already released the accounting and completed the callback — but the bare continue left the op in the queue with its fresh command, so the send timeout would later fail it and release the accounting a second time.

discard() with a throwing callback: BatchMessageContainerImpl.discard() invoked the application callback and released the batch buffer in the same try block (a shape inherited from upstream); an exception escaping the callback skipped the release, and the clear() that followed dropped the buffer's reference without freeing it.

Modifications

  • BatchMessageContainerImpl: track batch-buffer ownership with an explicit batchPayloadOwned flag instead of inferring it from the buffer reference. A build only starts while the container owns its buffer; compression/encryption that releases it clears the flag, and resetPayloadAfterFailedPublishing() reallocates instead of reusing released memory. The compressed payload is released when encryption fails and the container no longer owns it (fixes the orphaned-payload leak). resetPayloadAfterFailedPublishing() also skips reallocation when the container has no messages left, since key-based batching forwards the reset to sub-batches that already succeeded and cleared.
  • Oversized batch rejection (BatchMessageContainerImpl.createOpSendMsg()): the single-message path drops batchPayloadOwned before releasing the command, so the internal discard() no longer releases the already-freed container claim; the multi-message path routes the check through releasePayloadIfOrphaned(), releasing only a payload whose ownership left the container (compression/encryption) and leaving a container-owned buffer to discard()'s single release.
  • discard() release isolation (BatchMessageContainerImpl.discard()): the batch-buffer release, the ownership-flag reset, and clear() moved into a finally, so an exception escaping the application callback cannot skip them.
  • RawBatchMessageContainerImpl: symmetrically release the compressed payload and any partially built encrypted buffer when encryption fails, and release the serialized payloads in toByteBuf() on failure instead of orphaning them.
  • StrategicTwoPhaseCompactor: always clear the batch container on a failed flush, including when the failure is an Error (discard(Exception) cannot take an Error).
  • Multi-batch build (BatchMessageKeyBasedContainer, EntryBucketBatchContainer): build sub-batches in an explicit loop; when a later sub-batch fails, release the commands already built (ownership-aware: the header is always solely owned by the command; the payload only when its ownership left the container). Messages stay in their sub-batches and are retried, so their semaphore permits and memory reservations settle when the retry completes.
  • ProducerImpl:
    • encryptMessage() releases the partially built output buffer on every exit that does not hand it to the caller; the source payload remains the caller's responsibility (unchanged contract).
    • New sendMessageOrReleasePayload() used at both command-building sites in serializeAndSendMessage(): a failed serialization releases the encrypted payload (and the chunk slice's claim on the base buffer) before rethrowing.
    • OpSendMsg tracks the deferred payload (pendingPayload); rePopulate() clears it once the payload moved into the command, and recycle() releases it when the op is failed before that.
    • The chunk loop in sendAsync() releases the base payload's own ref-count claim when a non-last chunk's serialization fails; the last chunk is excluded because its unretained slice already is that claim, released by the serialization-stage helper.
    • A compression failure at the non-batch stage now completes the callback through completeCallbackAndReleaseSemaphore: the permit acquired and the memory reserved by canEnqueueRequest are released and the returned future completes exceptionally, instead of the exception escaping sendAsync() with the future left incomplete.
    • The chunk-build failure handling returns the send permits pre-acquired for the never-built chunks (non-blocking mode only; blocking acquires per chunk inside the loop) and releases the ChunkedMessageCtx claims of the failing and never-built chunks, which only created ops would have released.
    • Only the last chunk's op carries the message's uncompressedSize (the others carry 0), so every release site — per-op ack, failPendingMessages, terminal state, recoverNotAllowedError — releases the reservation exactly once across the message's ops; a partial chunk-build failure releases the whole reservation once in the outer catch of sendAsync().
    • The blocking-mode chunk-enqueue failure branch (interruption only) releases the full memory reservation and no permits.
    • The catch of processOpSendMsg() takes the op back out of pendingMessages (new OpSendMsgQueue.remove(OpSendMsg) with the message-count accounting; the iterator's remove already had it), drops the reference retained for a write that never got queued, and releases the op's own command and the op itself; the sendComplete inside it is guarded like every other call site, so a throwing application callback cannot skip the cleanup.
    • The resend recovery loop's size-exceeded path removes the op from the iteration and releases its freshly built command instead of leaving it in the queue for a double accounting release at timeout.
  • Tests: failure recovery with/without compression, encryption-failure buffer release, the fail-fast guard against re-entering a build without reset, multi-batch partial-build failure (leaked command, with and without compression), the three encrypted-payload failure points, and the oversized rejection paths (single and multi-message, with and without compression) asserting the batch buffer is released exactly once (the failure-path tests build their producers through the real constructor — useConstructor + CALLS_REAL_METHODS — and read controllable state through package-visible fields, with no reflection into private state). Queue-accounting regressions: a pre-op compression failure (permit + memory + future), a chunked non-blocking failure returning the pre-acquired permits, a blocking-mode interruption returning the whole reservation, the chunk ops carrying the reservation exactly once, the processOpSendMsg catch with a rejecting event loop (± throwing callback), and discard() with a throwing callback.

Verifying this change

  • Make sure that the change passes the CI checks.

This change added tests and can be verified as follows: BatchMessageContainerImplTest (failure recovery, multi-batch partial build, entry-bucket stamping, oversized rejection, discard with a throwing callback), RawBatchMessageContainerImplTest (encryption-failure releases), ProducerImplTest (partial encrypted-buffer release on crypto failure — rethrow and SEND fallback, payload release on failed command serialization, deferred payload release on recycle, queue-accounting regressions for the compression / chunked non-blocking / chunked blocking / chunk reservation / processOpSendMsg-catch paths).

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 rest endpoints: no
  • The admin cli options: no
  • Anything that affects deployment: no

Documentation

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

@void-ptr974

Copy link
Copy Markdown
Contributor

One additional case worth considering is partial success during multi-batch construction. For example, sub-batch A successfully creates an OpSendMsg, but sub-batch B fails while constructing its command. In this situation, A’s operation becomes unreachable and its ByteBufPair is not released.

Repeated retries may retain additional command buffers and cause direct-memory growth or eventually OOM. With CompressionType.NONE, the abandoned command may also reference the payload reused during retry, resulting in inconsistent buffer ownership.

@nodece

nodece commented Sep 7, 2026

Copy link
Copy Markdown
Member Author

@void-ptr974 Good catch — confirmed. createOpSendMsgs() built the sub-batches through a stream, so a mid-loop failure dropped the already-built operations and leaked their commands.

Two notes from digging in:

  1. The messages are not lost — createOpSendMsg() doesn't consume them, so they stay in their sub-batches and are retried on the next flush; the damage is the leaked command buffers (plus their memory reservations never settling).
  2. The cleanup has to be ownership-aware: the payload is only released when compression/encryption handed it to the command; without compression the container still owns the buffer and the retry reuses it.

Fixed in 6eb6971 for both the key-based and the entry-bucket containers, with a regression test that fails on the previous code (leaked header buffer).

@nodece nodece changed the title [fix][client] Fix batch container buffer ownership on the failure-recovery path [fix][client] Fix buffer leaks on the send failure paths Sep 7, 2026
@void-ptr974

void-ptr974 commented Sep 7, 2026 •

Copy link
Copy Markdown
Contributor

One remaining case is an encryption failure in the non-batch path. encryptMessage() leaves the source payload to its caller, but the caller does not release it when the exception propagates. Repeated failures can therefore leak direct memory. A caller-path cleanup and regression test would cover this case.

@void-ptr974

void-ptr974 commented Sep 7, 2026 •

Copy link
Copy Markdown
Contributor

One minor lifecycle detail remains: releaseOrphanedOpCmd() releases the internal buffers, but does not release the ByteBufPair or recycle the OpSendMsg. Repeated failures may therefore add unnecessary heap allocation and GC pressure.

@nodece

nodece commented Sep 8, 2026

Copy link
Copy Markdown
Member Author

@void-ptr974 Right — fixed in 6fc4bf2. One detail kept it from being a plain op.cmd.release(): without compression or encryption the payload claim is shared between the pair and the container (single refcount), so releasing the pair directly would free the buffer the container still reuses for the retry. The fix takes the container's claim out of the pair with a retain() first, then releases the pair itself (back to its recycler) and recycles the op. The regression test now also asserts the pair's refCnt reaches 0.

@nodece nodece changed the title [fix][client] Fix buffer leaks on the send failure paths [fix][client] Fix buffer ownership on the send failure paths Sep 8, 2026
@nodece

nodece commented Sep 8, 2026

Copy link
Copy Markdown
Member Author

@void-ptr974 Right — the caller-side cleanup was missing. Fixed in 9eed0a9: new applyCompressionOrReleaseSource() / encryptMessageOrReleaseSource() wrap both stages, releasing the source payload (and the chunk slice's claim on the shared base buffer) when the stage throws, mirroring sendMessageOrReleasePayload() on the serialization stage. Applied at all three call sites that lacked cleanup — the two in serializeAndSendMessage() and the pre-chunking compression in the send loop. Both helpers have regression tests.

@void-ptr974

Copy link
Copy Markdown
Contributor

Thanks for adding the deferred-payload cleanup. There is one retry case worth covering here. For example, a multi-schema producer sends the first message with a new schema, schema registration succeeds, and Commands.newSend fails once while allocating the SEND header under direct-memory pressure. rePopulate clears pendingPayload before sendMessageOrReleasePayload runs, so the failure releases the payload, but the op remains in pendingMessages with cmd == null. If the producer later reconnects and resends its pending operations, the same closure uses the released buffer again, which can cause an IllegalReferenceCountException and leave the send stuck. Keeping pendingPayload until command construction succeeds, or removing and failing the op when releasing it, together with a test covering a failed first construction followed by recovery, would handle this case.

@nodece

nodece commented Sep 8, 2026

Copy link
Copy Markdown
Member Author

@void-ptr974 Good catch — the release-on-throw semantics were wrong for this path: the op itself is the retry owner of the deferred payload. Fixed in 78768d1: the closure body is now buildDeferredCommand(), which builds from op.pendingPayload and clears the field only after the construction succeeds — a failed construction keeps the payload with the op for the next resend, and recycle() still releases it when the op is failed instead. Added the requested test: a failed first construction followed by recovery, asserting the payload survives the failure, the retry rebuilds the command from the same buffer, and a later recycle does not double-release.

@void-ptr974 void-ptr974 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

LGTM

…overy path

When compression or encryption releases the batch buffer before a flush that
then fails, the old recovery reused the released buffer (use-after-free) or
orphaned the compressed payload (leak).

Track batch-buffer ownership explicitly instead of nulling the buffer inside
the build method:

- BatchMessageContainerImpl: add a batchPayloadOwned flag; a build only starts
  while the container owns its buffer. Compression/encryption that releases it
  clears the flag, and resetPayloadAfterFailedPublishing() reallocates instead
  of reusing released memory. Releasing the compressed payload when encryption
  fails fixes the orphaned-payload leak.
- resetPayloadAfterFailedPublishing(): skip reallocation when the container has
  no messages left, since key-based batching forwards the reset to sub-batches
  that already succeeded and cleared.
- RawBatchMessageContainerImpl: symmetrically release the compressed payload
  and any partially built encrypted buffer when encryption fails, and release
  the serialized payloads in toByteBuf() on failure instead of orphaning them.
- StrategicTwoPhaseCompactor: always clear the batch container on a failed
  flush, including when the failure is an Error (discard(Exception) cannot take
  an Error).
- Tests: cover failure recovery with/without compression, encryption-failure
  buffer release, and the fail-fast guard against re-entering a build without
  reset.

Assisted-by: Claude Code
Assisted-by: Codex
…atch fails

Motivation

createOpSendMsgs() built all sub-batches through a stream that collected
into a list. When a later sub-batch failed to build, the stream aborted
and the operations already built were unreachable: they never reached the
send queue, so nothing released their commands. Every failed flush then
leaked the command buffers (serialized header plus the transferred batch
payload), and repeated retries under a persistent failure cause would
keep growing the direct memory usage.

Modifications

- BatchMessageKeyBasedContainer and EntryBucketBatchContainer build their
  sub-batches in an explicit loop; on failure they release the commands
  of the operations already built before rethrowing.
- The release is ownership-aware (releaseOrphanedOpCmd): the serialized
  header is always solely owned by the command, while the payload is only
  released when its ownership left the container (compression or
  encryption); otherwise the container keeps the buffer and the retry
  after resetPayloadAfterFailedPublishing() reuses it.
- The messages stay in their sub-batches, so their semaphore permits and
  memory reservations settle when the retry completes.
- Add a regression test for the partial multi-batch failure with and
  without compression (fails on the previous code with a leaked header).
Motivation

The payload buffer produced by the encryption step must be released when
a send fails midway - by whichever component last took its ownership.
All three failure points along its lifecycle leaked it instead, so every
failed send leaked direct memory, accumulating exactly under the
sustained-failure conditions (e.g. direct-memory pressure) that trigger
these failures in the first place:

1. During encryption: encryptMessage() leaked the partially built output
   buffer whenever the crypto failed - on the PulsarClientException
   rethrow, on the SEND (publish-unencrypted) fallback, and on any
   RuntimeException or Error escaping the crypto, which had no cleanup
   at all. The batch container path only released the source payload
   around this call (previous commit); the output buffer allocated
   inside was unreachable to it.
2. After encryption: serializeAndSendMessage() orphaned the encrypted
   payload when building the send command threw (e.g. a header
   allocation failure). For chunked messages the retained slice also
   leaked its claim on the shared base buffer, so the base never
   returned to the pool.
3. Deferred command: an operation whose command is built only after the
   schema registration completes (rePopulate) captured the encrypted
   payload in a closure; if the op was failed first (send timeout,
   producer close), the payload leaked with the unreachable closure.

This completes the failure-path ownership fixes for the non-batch send
path; the batch flush path is covered by the previous commits.

Modifications

- encryptMessage() releases the partially built output buffer on every
  exit that does not hand it to the caller; the source payload remains
  the caller's responsibility (unchanged contract).
- New sendMessageOrReleasePayload() used at both command-building sites
  in serializeAndSendMessage(): a failed serialization releases the
  encrypted payload (and the chunk slice's claim on the base buffer)
  before rethrowing.
- OpSendMsg tracks the deferred payload (pendingPayload); rePopulate()
  clears it once the payload moved into the command, and recycle()
  releases it when the op is failed before that.
- Tests for all three failure points.
…eanup

Motivation

releaseOrphanedOpCmd() freed the pair's component buffers but left the
ByteBufPair and the OpSendMsg unreleased: both are recycler-based, so on
repeated multi-batch build failures they were garbage-collected instead
of returned to their pools, adding heap allocation and GC pressure (noted
in review).

Modifications

- When the payload claim is shared with the container (no compression or
  encryption), take it out of the pair with a retain() before releasing,
  so resetPayloadAfterFailedPublishing() keeps reusing the buffer.
- Release the pair itself (returning it to its recycler) and recycle the
  orphaned op.
- The regression test now also asserts the pair itself is released.
…on fails

Motivation

Both applyCompression() and encryptMessage() leave their source payload with
the caller on failure (they release it only after success), but the non-batch
send path had no caller-side cleanup: when either stage threw, the exception
propagated out of serializeAndSendMessage() and the source payload was
orphaned. For chunked messages the retained slice's claim on the shared base
buffer leaked as well, so the base never returned to the pool. Repeated
failures therefore leaked direct memory (noted in review).

The batch-container path already defends around these calls via its ownership
tracking; the non-batch path did not.

Modifications

- New applyCompressionOrReleaseSource() and encryptMessageOrReleaseSource()
  used in serializeAndSendMessage(): on failure they release the source
  payload (and the chunk slice's claim) before rethrowing, mirroring
  sendMessageOrReleasePayload() on the serialization stage.
- Tests for both stages.
…on fails

Motivation

A deferred command (schema registration pending) released its payload when
the construction failed: rePopulate cleared pendingPayload before calling
the releasing serialization wrapper. The op stays in pendingMessages after
such a failure, and the next resend invokes the closure again - on the
released buffer, producing an IllegalReferenceCountException on every
reconnect and leaving the send stuck (noted in review).

The release-on-throw semantics were wrong for this path: unlike the
non-deferred send path, the op itself is the retry owner of the payload.

Modifications

- Extract the closure body into buildDeferredCommand(): the command is
  built from op.pendingPayload and the field is cleared only after the
  construction succeeds. A failed construction keeps the payload with the
  op for the next resend; recycle() still releases it when the op is
  failed instead.
- Regression test: a failed first construction followed by recovery -
  the payload survives the failure, the retry rebuilds the command from
  the same buffer, and recycling the op afterwards does not double-release.
@nodece
nodece force-pushed the fix-batch-buffer-use-after-release branch from 78768d1 to 7f69f2f Compare September 9, 2026 03:14

@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.

The buffer-ownership fixes look sound, including cleanup of partially built multi-batch operations and retaining the deferred payload until command construction succeeds. I found no new production regression. The remaining comments concern regression-test coverage, the SEND-frame parsing offset, and the new private-state reflection in tests.

… reflection

Motivation

Review follow-up on the test coverage:

- The failure regression tests called the release helpers directly, so a
  call site reverting to the bare method would not have failed any test.
- The RawBatch encryption-failure test asserted only the source buffer:
  removing the partially-built-encrypted-output release still passed.
- The SEND-frame parse test skipped 4 bytes too few, so CMD_SIZE was
  parsed as part of the command.
- New tests reached private state through reflection, which CODING.md
  forbids in favor of @VisibleForTesting accessors or normal construction.

Modifications

- testSendPathFailureReleasesPayloadThroughTheStageHelpers drives the real
  sendAsync(): a failing compression stage must release the message
  payload, and a failing command serialization must release the compressed
  payload handed to it. Both call-site reversions (bare applyCompression /
  bare sendMessage) make it fail; verified by running them.
- RawBatchMessageContainerImpl gains a @VisibleForTesting constructor
  taking the allocator; the encryption-failure test tracks every buffer
  the container allocates and asserts all are freed. Removing the
  encrypted-output release in the Throwable catch fails it; verified.
- The SEND-frame parse skips both length fields (8 bytes) before
  parseFrom, so the command is parsed within its declared bounds.
- The encryption and container tests build the producer through its real
  constructor (useConstructor + CALLS_REAL_METHODS): conf, log, client
  and msgCrypto hold real values, and the crypto mock is installed via
  conf.setMessageCrypto() through the same constructor branch production
  uses. No new reflection; producer stubs use the doAnswer form so
  registering a stub does not execute the real method.
…ld fails

Motivation

For chunked persistent messages, the non-last chunks retain their slices
while the base payload's own ref-count claim is carried by the last chunk
(its slice is never retained). When serialization failed on a non-last
chunk, the send-path helper released that chunk's retained-slice claim,
but the base's own claim was orphaned: every failed chunked send leaked
the shared payload buffer.

Modifications

- The chunk loop in sendAsync() releases the base payload's own claim
  when a non-last chunk's serialization fails. The last chunk is
  excluded because its unretained slice already is the base claim, which
  the send-path helper releases; the persistence condition mirrors the
  slicing condition in serializeAndSendMessage().
- Wiring-level regression tests: chunked failures with and without
  compression assert the base is fully released (reverting the release
  fails them, verified); a deferred-schema test drives the real
  sendAsync() into the deferred branch and asserts the pendingPayload
  assignment, the failed-first-build retention, the resend rebuild, and
  the recycle release; the RawBatch PulsarClientException branch and the
  entry-bucket partial-build loop get the same tracked-buffer coverage.
@nodece

nodece commented Sep 10, 2026

Copy link
Copy Markdown
Member Author

@lhotari All four points addressed, plus one more leak the wiring work surfaced — in 5031cf2 and e47b27f.

Send-path wiring — testSendPathFailureReleasesPayloadThroughTheStageHelpers drives the real sendAsync(): a failing compression stage and a failing command serialization both release the payload, and reverting either call site to the bare method fails the test (verified by running the reversions). testDeferredSchemaOpWiringThroughSendPath additionally drives the real deferred branch: it captures the op and asserts the pendingPayload assignment, the failed-first-build retention, the resend rebuild, and the recycle release. Two chunked variants (testChunkedSendFailure*) cover the chunk-loop call sites.

RawBatch encrypted-output release — the container gained a @VisibleForTesting constructor taking the allocator; both encryption-failure tests now track every buffer the container allocates and assert all of them are freed, so removing the encrypted-output release fails them (verified). The PulsarClientException branch gets the same tracked coverage, asserting the batch is discarded for container reuse.

skipBytes(8) — fixed, with a comment noting both length fields.

Reflection — the producers in these tests are now built through their real constructors (useConstructor + CALLS_REAL_METHODS): conf, log, client and msgCrypto hold real values, and the crypto mock is installed via conf.setMessageCrypto() through the same constructor branch production uses. No new reflection; producer stubs use the doAnswer form throughout, since when-form registration executes the real method on these mocks.

New leak found while building the wiring tests: a failing non-last chunk of a chunked persistent message orphaned the base payload's own ref-count claim — the last chunk carries it (its slice is never retained), so a mid-loop failure leaked the shared buffer on every failed chunked send. The chunk loop now releases it (e47b27f), with the two chunked tests failing on the reversion.

…n arm

Review follow-up on apache#26455: the multi-message oversized regression test
covered only the no-compression arm, where discard() owns the single
release of the container-owned batch buffer. The compression arm was
unpinned: dropping the orphan-aware release in the oversized check would
leak the compressed buffer (refCnt stays 1) without failing any test.

Modifications:
- Parameterize the test over CompressionType (NONE / ZLIB).
- The ZLIB variant uses deterministic incompressible payload so the
  compressed size still exceeds the 5MB limit, and asserts the compressed
  buffer is released by the oversized check.
- Both arms assert the batch buffer ends up freed with release() invoked
  exactly once.

Assisted-by: Codex
@nodece
nodece requested a review from lhotari September 11, 2026 08:35
Comment thread pulsar-client/src/main/java/org/apache/pulsar/client/impl/ProducerImpl.java Outdated
@lhotari lhotari modified the milestones: 5.0.0-M2, 5.0.0 Sep 12, 2026
Conflict with apache#26547 (transactional/non-transactional batch isolation)
in BatchMessageContainerImplTest: both sides appended tests at the end
of the class. Resolution keeps both sets — apache#26547's transaction-batch
tests and this PR's buffer-ownership tests — with the import block
rebuilt as the sorted union.

Verified: BatchMessageContainerImplTest 21/21, ProducerImplTest 23/23,
spotless + checkstyle clean.
Two review findings on apache#26455, both about accounting acquired before an
OpSendMsg exists to release it through the send lifecycle:

- Non-batch compression failure: canEnqueueRequest has already acquired
  the send permit and reserved the message size when the compression
  stage throws, and no op is ever created, so nothing released them.
  The exception propagated out of sendAsync() synchronously, leaving
  the returned future incomplete, the permit consumed, and the memory
  reserved - repeated compression failures exhausted the producer queue
  and the client memory limit. The failure now completes the callback
  through completeCallbackAndReleaseSemaphore, releasing the accounting
  and failing the future with the cause.

- Chunked non-blocking failure: the chunk loop pre-acquires a permit
  for every chunk of the message before building any of them. A
  chunk-build failure released only the failing chunk's permit (via the
  outer catch), stranding the totalChunks - chunkId - 1 permits of the
  chunks that were never built - failing the first of 11 chunks with a
  queue of 100 left 90 - and their claims on the ChunkedMessageCtx,
  which only created ops release. The failure handling now returns the
  never-built chunks' permits (non-blocking mode only; blocking mode
  acquires per chunk inside the loop) and releases the corresponding
  context claims.

Regression tests drive the real sendAsync(): both failures must leave
the memory usage at zero and the available permits back at their
starting point. The tests set maxPendingMessages explicitly because the
configuration default of 0 means an unbounded queue with no permit
accounting. Verified against the previous code: the chunked test fails
with "expected [100] but found [90]", and the compression test sees the
raw RuntimeException escape sendAsync() with the future incomplete.

The existing send-path wiring test is updated to the new contract: a
failing compression stage completes the callback instead of throwing
synchronously.

Assisted-by: Claude Code
Tighten the failure-path comments to the invariant they protect and drop
the lines that repeat what the next statement already says; the
ownership reasoning itself stays.
…t chunk op only

The client memory for a chunked message is reserved once, by
canEnqueueRequest before the chunk loop, but every release site
(per-op ack, failPendingMessages, terminal state, recoverNotAllowedError)
releases op.uncompressedSize - and every chunk op carried the FULL
message size, since OpSendMsg.create reads it from the shared
MessageImpl. Each of the N chunk acks therefore released the whole
reservation again, and the memory gauge drifted low by (N-1) sizes,
silently disabling the client memory limit for chunked producers.

Only the last chunk's op now carries the size, so across all the
release paths the reservation is released exactly once: the last
chunk's ack in the success path, the last chunk op in the pending
queue in the failure paths, and the outer catch of sendAsync when the
last chunk was never built (the failure paths of a partial chunk loop
release 0 for the never-completed remainder).

Pre-existing, surfaced by the queue-accounting audit in apache#26455.
Regression test drives the real sendAsync chunk loop and asserts the
ops of one message carry the reservation exactly once in total
(fails with "chunk 0 of 11 ... expected [0] but found [10240]" on the
previous code).

Assisted-by: Claude Code
…lback throws

Review finding on apache#26455: discard() invoked the application callback
and released the batch buffer inside the same try block. The callback
runs application future handlers, so it can throw; the escaping
exception skipped the release, and the clear() that followed dropped
the buffer's reference without freeing it - every failed batch with a
throwing handler leaked its off-heap memory. The release, the
ownership flag reset, and clear() now run in a finally, with the
callback exception still caught and logged in isolation.

The try shape predates this PR (upstream has the same callback-then-
release ordering, without the ownership flag); the reworked discard
inherited it.

Regression test: a throwing sendComplete must leave the batch buffer
released and the container cleared (fails with "expected [0] but found
[1]" on the previous code).

Assisted-by: Claude Code
…e failure

Review finding on apache#26455: the blocking-mode chunk failure branch (per-chunk
canEnqueueRequest failing, which in blocking mode only happens on
interruption) released uncompressedSize - readStartIndex of the memory and
totalChunks - chunkId permits. Both are now wrong since 07dab10 ("Carry
a chunked message's memory reservation on the last chunk op only"): the
earlier chunk ops carry no memory share, so the already-chunked readStartIndex
bytes were never released and the gauge crept up on every failure - a leak.
The permit release was also an over-release all along: in blocking mode the
chunks after the failing one were never acquired (each chunk takes its permit
inside the loop), and the failing chunk's own attempt aborts before acquiring.

The branch now releases the whole memory reservation and no permits; the
already-built chunks' permits are returned by their own operations.

Also:
- completeCallbackAndReleaseSemaphore documents why its unconditional permit
  release is safe: the constructor forbids combining
  memoryLimitAdmittedUpstream with a maxPendingMessages limit, so a producer
  with admission upstream has no semaphore to release from.
- RawBatchMessageContainerImpl.encrypt's Throwable-branch comment now states
  precisely why the batch is left to the caller's discard: the failure escapes
  toByteBuf() before its serialization try (whose finally clears the
  container).

Regression test interrupts the thread between chunk builds
(Semaphore.acquire() throws immediately on an interrupt flag) and asserts
the memory gauge and the permit count return to their starting points
(fails with "expected [0] but found [1005]" on the previous code).

Assisted-by: Claude Code
…ls it

Two review findings on apache#26455, both queue/buffer paths left stranded by
a failure:

- processOpSendMsg's catch (from apache#23738) released only the permit and
  the memory when the body threw - realistically when the connection's
  event loop rejected the write task while shutting down. The op stayed
  in pendingMessages unrecycled, its cmd leaked both references (the
  op's own and the one retained for a write that never got queued), and
  a chunked op's ChunkedMessageCtx claim leaked with it. The catch now
  takes the op back out of the queue (OpSendMsgQueue gained a
  remove(OpSendMsg) that keeps the message-count accounting; the
  iterator's remove already did), drops the orphaned write reference,
  and releases the op's own command via releaseOpCmdAndRecycle, whose
  inline fallback covers the rejecting event loop.

- In the resend recovery loop, a rebuilt command that exceeds the max
  message size took the isMessageSizeExceeded path, which already
  released the accounting and completed the callback - but the bare
  continue left the op in pendingMessages with its fresh cmd, so it sat
  until the send timeout failed it and released the accounting a second
  time. The continue now removes the op from the iteration and releases
  its command and the op itself.

Regression test drives the real processOpSendMsg with an event loop
that rejects every task and asserts the queue is empty, the accounting
follows, and the cmd reaches refCnt 0 (fails with "expected [0] but
found [1]" queue residue on the previous code).

Assisted-by: Claude Code
The catch's op.sendComplete() was the only unprotected call site: the
callback runs application future handlers and can throw, and by the time
it runs the op is already out of pendingMessages with its accounting
released - an escaping exception skipped the cmd references, the
chunked-message context claim, and the op recycling for good. Wrap it
like every other sendComplete site (failPendingMessages, ackReceived,
recoverNotAllowedError), keeping the releases after it unconditional.

Regression test combines the rejecting event loop with a throwing
callback and asserts the queue is empty and the cmd reaches refCnt 0
(the previous code fails with the callback's RuntimeException escaping).

Assisted-by: Claude Code

@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.

The requested send-path coverage and buffer-release assertions are now in place. One test-construction issue remains in two later-added processOpSendMsg regression tests.

…heir real constructors

Review follow-up from lhotari: the two tests added for the catch cleanup
used FieldUtils.writeField to replace the private log, client and
pendingMessages state, which CODING.md's testing conventions forbid for
new tests. Both now use the same constructProducer(...) seam as the
other new tests (real constructor + CALLS_REAL_METHODS), set the state
through setState(), and read the pending queue through its
package-visible field - no reflection.

Assisted-by: Claude Code

@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.

LGTM. The remaining test setup now uses the real producer constructor and preserves the failure-path assertions.

@nodece
nodece merged commit b886d77 into apache:master Sep 14, 2026
44 checks passed
@nodece
nodece deleted the fix-batch-buffer-use-after-release branch September 14, 2026 08:58
@nodece nodece self-assigned this Sep 14, 2026
lhotari pushed a commit that referenced this pull request Sep 14, 2026
lhotari pushed a commit that referenced this pull request Sep 14, 2026
(cherry picked from commit b886d77)
(cherry picked from commit 1e65905)
nodece added a commit to ascentstream/pulsar that referenced this pull request Sep 29, 2026
… WrappedByteBuf visibility

Same treatment as the pulsar-client tests in apache#26455: bridge the package-private
WrappedByteBuf from a class under io.netty.buffer so FailingWriteByteBuf can
subclass it.
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