Skip to content

Re-hydrate claim-check [Blob] properties on a local queue#3048

Merged
jeremydmiller merged 2 commits into
JasperFx:mainfrom
prom3theu5:fix/claimcheck-local-queue-rehydration
Jun 9, 2026
Merged

Re-hydrate claim-check [Blob] properties on a local queue#3048
jeremydmiller merged 2 commits into
JasperFx:mainfrom
prom3theu5:fix/claimcheck-local-queue-rehydration

Conversation

@prom3theu5

Copy link
Copy Markdown
Contributor

Fixes #3047.

The bug

A [Blob]-marked message delivered through an in-process durable local queue reaches its handler with the blob property null. ClaimCheckMessageSerializer.Write off-loads the payload and calls Clear(message), nulling the property on the live in-memory message. A durable local queue serializes the envelope on store (DurableLocalQueue.writeMessageDataSerializer.Write) but then re-enqueues that same Envelope instance, and the receive path skips deserialization when envelope.Message != null (HandlerPipeline: if (envelope.Message == null) is false). So ReadFromData/LoadBlobsAsync never 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 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; 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.

Tests

Adds local_queue_round_trip (CoreTests) with two guards on the durable-local path:

  1. the handler must see the re-hydrated body (the bug), and
  2. the serialized envelope body must not contain the off-loaded payload (header token only) — so the restore can't silently defeat claim-check.

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_trip suite stays green. Full wolverine.slnx builds clean in Release.

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.
Copilot AI review requested due to automatic review settings June 8, 2026 00:15

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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 ClaimCheckMessageSerializer via try/finally around 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.
@prom3theu5

Copy link
Copy Markdown
Contributor Author

Follow-up (250bb8e): hardened the restore against a partial off-load failure. Previously the restore finally only wrapped the inner serializer; if the store itself threw after an earlier blob on the same message had already been cleared (e.g. it rejects the 2nd of two blobs), the exception unwound past the finally and left the live message with cleared properties — which would then leak into subsequent in-process handling of the same Envelope.

The off-load now runs inside the same try/finally as the inner write, and StoreBlobsAsync appends each cleared property to a caller-owned list the moment it clears it, so every property cleared before a failure is restored before the exception propagates. Added claim_check_serializer_restore covering the sync and async Write paths (fail on the 2nd store call against a two-blob message, assert both properties come back intact); verified it has teeth by moving the off-load back outside the try.

@jeremydmiller jeremydmiller merged commit 3c7b0d5 into JasperFx:main Jun 9, 2026
23 of 24 checks passed
jeremydmiller added a commit that referenced this pull request Jun 9, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Claim-check [Blob] properties are off-loaded but not re-hydrated on a local queue

3 participants