Re-hydrate claim-check [Blob] properties on a local queue#3048
Conversation
A [Blob]-marked message delivered through an in-process (durable) local queue reached its handler with the blob property null. The claim-check serializer's Write off-loaded the payload and called Clear(message), nulling the property on the *live* in-memory message. A durable local queue serializes the envelope on store (DurableLocalQueue.writeMessageData -> Serializer.Write) but then re-enqueues that same Envelope instance, and the receive path skips deserialization when envelope.Message is not null (HandlerPipeline: `if (envelope.Message == null)` is false). So ReadFromData/LoadBlobsAsync never ran and the off-loaded property was never re-hydrated. The bug is invisible across a real transport because that path forces a serialize -> deserialize round trip. Fix mirrors the EncryptingMessageSerializer precedent: a serializer- decorator must not leave the in-memory message mutated. StoreBlobsAsync still nulls each blob property so the inner serializer omits it from the persisted/wire body, but now returns the off-loaded payloads, and Write/WriteAsync/WriteMessage restore them onto the live message (via the same ApplyLoaded routine the receive path uses) in a finally, after the inner serializer has produced the bytes. The bytes on the bus are unchanged (blob absent, header token present); only the leaked mutation is undone. Adds local_queue_round_trip with two guards: the handler must see the re-hydrated body, AND the serialized envelope body must NOT contain the off-loaded payload (header token only) so the restore cannot silently defeat claim-check.
There was a problem hiding this comment.
Pull request overview
Note
Copilot was unable to run its full agentic suite in this review.
Fixes a claim-check mutation bug on durable local queues by restoring offloaded blob properties on the live in-memory message after serialization, while ensuring the serialized envelope body still excludes the blob payload.
Changes:
- Track and restore offloaded blob payloads in
ClaimCheckMessageSerializerviatry/finallyaround inner serialization. - Update blob offload routine to return the captured payloads for later restoration.
- Add durable local-queue regression tests for both string and byte[] blob properties, including assertions that body bytes do not contain the offloaded payload.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
| src/Wolverine/Persistence/ClaimCheck/Internal/ClaimCheckMessageSerializer.cs | Restores blob properties after serialization to avoid leaking Clear() mutations into in-process delivery. |
| src/Testing/CoreTests/Persistence/ClaimCheck/local_queue_round_trip.cs | Adds regression coverage for durable local-queue claim-check behavior and body-payload exclusion. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
The restore finally only covered exceptions from the inner serializer; an exception from StoreBlobsAsync itself (e.g. the store rejecting the second of two blobs after the first was already cleared) unwound past the finally and left the live message with cleared blob properties. That cleared state would then leak into subsequent in-process handling of the same Envelope. Move the off-load inside the same try/finally as the inner write and have StoreBlobsAsync append each cleared property to a caller-owned list as soon as it clears it. A partial off-load is now fully visible to the finally, so every property cleared before the failure is restored before the exception propagates. Adds claim_check_serializer_restore covering the sync and async Write paths: fail on the second store call against a two-blob message and assert both properties come back intact. Verified teeth by moving the off-load back outside the try — the sync test fails as expected.
|
Follow-up (250bb8e): hardened the restore against a partial off-load failure. Previously the restore The off-load now runs inside the same |
…ow-up to #3048) (#3058) Documents the behavior #3048 established: after the envelope body is serialized, the claim-check decorator restores the off-loaded [Blob] properties onto the live in-memory message, so in-process (local-queue) routing that reuses the same message instance still sees the full payload. Adds: - a "How it works" paragraph on the post-serialization restore and why it matters for in-process routing, - a note that a [Blob] Stream is buffered and re-materialized as a fresh read-only MemoryStream, - operational bullets on durable vs. buffered local queues and the envelope requirement for off-loading. Docs-only. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Fixes #3047.
The bug
A
[Blob]-marked message delivered through an in-process durable local queue reaches its handler with the blob propertynull.ClaimCheckMessageSerializer.Writeoff-loads the payload and callsClear(message), nulling the property on the live in-memory message. A durable local queue serializes the envelope on store (DurableLocalQueue.writeMessageData→Serializer.Write) but then re-enqueues that sameEnvelopeinstance, and the receive path skips deserialization whenenvelope.Message != null(HandlerPipeline:if (envelope.Message == null)is false). SoReadFromData/LoadBlobsAsyncnever run and the off-loaded property is never re-hydrated.Invisible across a real transport because that path forces a serialize→deserialize round-trip.
The fix
Mirror the
EncryptingMessageSerializerprecedent: a serializer-decorator must not leave the in-memory message mutated.StoreBlobsAsyncstill nulls each blob property so the inner serializer omits it from the persisted/wire body, but now returns the off-loaded payloads;Write/WriteAsync/WriteMessagerestore them onto the live message (via the sameApplyLoadedroutine the receive path uses) in afinally, after the inner serializer has produced the bytes.The bytes on the bus are unchanged — blob absent, header token present. Only the leaked mutation is undone.
Tests
Adds
local_queue_round_trip(CoreTests) with two guards on the durable-local path:Verified by temporarily moving the restore before serialization: guard (2) fails as expected, confirming it has teeth.
The existing cross-transport
end_to_end_round_tripsuite stays green. Fullwolverine.slnxbuilds clean in Release.