[fix][client] Fix buffer ownership on the send failure paths - #26455
[fix][client] Fix buffer ownership on the send failure paths#26455nodece wants to merge 8 commits into
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. |
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.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.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.Verifying this change
This change added tests and can be verified as follows:
BatchMessageContainerImplTest(failure recovery, multi-batch partial build, entry-bucket stamping),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).Does this pull request potentially affect one of the following parts:
Documentation
no-docneeded (internal leak fixes, no user-facing behavior or configuration change)