Repository navigation
[fix][client] Fix buffer ownership on the send failure paths - #26455
Conversation
|
One additional case worth considering is partial success during multi-batch construction. For example, sub-batch A successfully creates an Repeated retries may retain additional command buffers and cause direct-memory growth or eventually OOM. With |
|
@void-ptr974 Good catch — confirmed. Two notes from digging in:
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). |
|
One remaining case is an encryption failure in the non-batch path. |
|
One minor lifecycle detail remains: |
|
@void-ptr974 Right — fixed in 6fc4bf2. One detail kept it from being a plain |
|
@void-ptr974 Right — the caller-side cleanup was missing. Fixed in 9eed0a9: new |
|
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. |
|
@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 |
…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.
78768d1 to
7f69f2f
Compare
lhotari
left a comment
There was a problem hiding this comment.
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.
|
@lhotari All four points addressed, plus one more leak the wiring work surfaced — in 5031cf2 and e47b27f. Send-path wiring — RawBatch encrypted-output release — the container gained a skipBytes(8) — fixed, with a comment noting both length fields. Reflection — the producers in these tests are now built through their real constructors ( 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
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
left a comment
There was a problem hiding this comment.
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
…use-after-release
lhotari
left a comment
There was a problem hiding this comment.
LGTM. The remaining test setup now uses the real producer constructor and preserves the failure-path assertions.
(cherry picked from commit b886d77)
… 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.
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
rePopulateclosure.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 internaldiscard()released the same buffer again; the multi-message path released the payload unconditionally ahead of thediscard()that owns the release. The swallowedIllegalReferenceCountExceptionhid 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
OpSendMsgexisted to release it through the send lifecycle. A non-batch compression failure aftercanEnqueueRequestpropagated out ofsendAsync()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 theChunkedMessageCtx, which only created ops release.Chunked memory reservation released once per message: every chunk op carried the full message's uncompressed size (
OpSendMsg.createreads it from the sharedMessageImpl), 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 onlyuncompressedSize - readStartIndex(leaking the already-chunked bytes once the earlier ops stopped carrying a share) plustotalChunks - chunkIdpermits 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'sChunkedMessageCtxclaim leaked, and the op stayed inpendingMessagesunrecycled. In the resend recovery loop, a rebuilt command exceeding the max message size took theisMessageSizeExceededpath — which already released the accounting and completed the callback — but the barecontinueleft 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 theclear()that followed dropped the buffer's reference without freeing it.Modifications
batchPayloadOwnedflag 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, andresetPayloadAfterFailedPublishing()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.BatchMessageContainerImpl.createOpSendMsg()): the single-message path dropsbatchPayloadOwnedbefore releasing the command, so the internaldiscard()no longer releases the already-freed container claim; the multi-message path routes the check throughreleasePayloadIfOrphaned(), releasing only a payload whose ownership left the container (compression/encryption) and leaving a container-owned buffer todiscard()'s single release.BatchMessageContainerImpl.discard()): the batch-buffer release, the ownership-flag reset, andclear()moved into afinally, so an exception escaping the application callback cannot skip them.toByteBuf()on failure instead of orphaning them.Error(discard(Exception)cannot take anError).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.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).sendMessageOrReleasePayload()used at both command-building sites inserializeAndSendMessage(): a failed serialization releases the encrypted payload (and the chunk slice's claim on the base buffer) before rethrowing.OpSendMsgtracks the deferred payload (pendingPayload);rePopulate()clears it once the payload moved into the command, andrecycle()releases it when the op is failed before that.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.completeCallbackAndReleaseSemaphore: the permit acquired and the memory reserved bycanEnqueueRequestare released and the returned future completes exceptionally, instead of the exception escapingsendAsync()with the future left incomplete.ChunkedMessageCtxclaims of the failing and never-built chunks, which only created ops would have released.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 ofsendAsync().processOpSendMsg()takes the op back out ofpendingMessages(newOpSendMsgQueue.remove(OpSendMsg)with the message-count accounting; the iterator'sremovealready had it), drops the reference retained for a write that never got queued, and releases the op's own command and the op itself; thesendCompleteinside it is guarded like every other call site, so a throwing application callback cannot skip the cleanup.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, theprocessOpSendMsgcatch with a rejecting event loop (± throwing callback), anddiscard()with a throwing callback.Verifying this change
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:
Documentation
no-docneeded (internal leak fixes, no user-facing behavior or configuration change)