Skip to content

GH-4556: apply an ISendMyself published from a projection side effect, on all three stores - #4575

Merged
jeremydmiller merged 2 commits into
mainfrom
gh-4556-send-myself-side-effects
Sep 23, 2026
Merged

jeremydmiller merged 2 commits into
mainfrom
gh-4556-send-myself-side-effects

Conversation

@jeremydmiller

Copy link
Copy Markdown
Member

Supersedes #4557 and closes #4556. The diagnosis, the ProjectionSideEffectContext design and the
original Marten test are @erdtsieck's — this branch carries their commit authorship. What is
added here is the review follow-up plus the two stores the original fix did not reach.

Problem

MessageBus.PublishAsync only lets an ISendMyself apply itself when options == null — the guard
that keeps TimeoutMessage from looping. A projection side effect always passes delivery options, at
minimum the tenant id, so a ToEndpoint(), DelayedFor() or SignalR ToWebSocketGroup() published
out of RaiseSideEffects was routed as the wrapper type, found no subscriber, and was dropped as
NoRoutes.

Fix

The bridge spots an ISendMyself and applies it against its own ProjectionSideEffectContext: a
MessageContext enlisted in the same store session, with the batch's storage, so a durable send
still commits with the projection. The metadata is stamped in TrackEnvelopeCorrelation — the one
point every outgoing envelope passes through, whether it leaves via PublishAsync, EndpointFor()
or a topic — rather than mapped onto a single DeliveryOptions that only the outermost send would
have seen. Anything the message set on its own options wins. A context per message rather than
mutating TenantId/CorrelationId on the shared batch context for the duration of ApplyAsync(),
because slices publish concurrently.

The options == null guard in MessageBus is left alone; it is load bearing.

What this adds over #4557

1. The correlation id no longer goes null. MessageMetadata.CorrelationId is plain null unless
the projection set one (CorrelationIdEnabled is literally is not null), so taking it over
unconditionally stamped null on the envelope — stripping the correlation id that every other
side-effect message out of the same batch carries. It now falls back to the batch context's id, so a
whole projection page stays on one correlation id.

Reproduced before fixing, with a plain message published from the same slice as the control:

failed  send_myself_published_without_metadata_is_applied
  Shouldly.ShouldAssertException : envelope.CorrelationId
      should not be null but was
succeeded  CONTROL_plain_message_without_metadata_still_gets_a_correlation_id

Every store's tests now publish the ISendMyself and a plain message out of the same slice, so
the two paths are compared inside a single run. That control is what catches the ISendMyself path
drifting away from the ordinary one again.

2. The side-effect contexts are cleared once flushed. Marten only discards its cached message
batch after a successful commit — DocumentSessionBase.SaveChangesAsync does not reset it in a
finally — so a session retrying a failed commit gets the same batch back, and without a clear it
would accumulate, and re-flush, every context from every failed attempt for as long as the session
lived. Each entry is a full MessageContext (three envelope lists, one of which is never cleared)
plus an IEnvelopeTransaction holding the session, so that is the one shape of this that grows
without bound. The list is also lazily allocated now: a batch is built for every commit of every
session in any app running IntegrateWithWolverine(), and almost none of them publish an
ISendMyself. The Add/iterate locking is symmetric.

3. Polecat and Fisher had the identical defect. PolecatToWolverineMessageBatch and
FisherToWolverineMessageBatch were byte-identical to Marten's down to the doc comments, and both
still dropped every ISendMyself. Rather than triplicate the fix, the whole behaviour moved into the
store-agnostic ProjectionSideEffectSink in core and all three IMessageBatch implementations
delegate to it. The GH-2545 metadata-to-DeliveryOptions mapping was already copy-pasted three ways
— which is exactly how GH-4556 came to be fixed on one store and broken on two.

The second commit changes core behaviour — it can be dropped on its own

BroadcastToTopicAsync was the one send path that never called TrackEnvelopeCorrelation.
MessageRoute.CreateForSending sets no Source, no CorrelationId, no ParentId and no Store,
and nothing downstream filled them in, so a topic broadcast went out bare while every other route
did not. That is why ToTopic() was the one ISendMyself wrapper that would have been applied
correctly here and still carried none of its causation id or custom headers.

Fixing it is a one-line addition that only adds metadata — but it applies to every topic
broadcast in every application
, not just projection side effects, so it is deliberately its own
commit with its own test. Drop Stamp envelope correlation on a topic broadcast if you would rather
take that separately; the GH-4556 commit stands alone without it.

Verification

Red baseline in a separate worktree built at origin/main with only the new test files copied
in — all nine fail there, for the right reason:

base-marten:   total: 4   failed: 4    | RoutedToEndpointMessageSendMyselfCommand   | NoRoutes |
base-polecat:  total: 2   failed: 2    | RoutedToEndpointMessagePcSendMyselfCommand | NoRoutes |
base-fisher:   total: 2   failed: 2    | RoutedToEndpointMessageFiSendMyselfCommand | NoRoutes |
base-core:     total: 1   failed: 1    envelope.CorrelationId should not be null but was

Green on this branch: 4/4 Marten, 2/2 Polecat, 2/2 Fisher, 1/1 CoreTests.

Regression runs:

Suite Result
dotnet build wolverine.slnx -c Release -f net9.0 0 errors, 0 warnings
CoreTests (full) 3035 tests, 0 failed
MartenTests (full) 702 tests, 0 failed
PolecatTests (full) 324 tests, 0 failed (2 skipped)
FisherTests (full) 66 tests, 0 failed

Known gap, deliberately left

MessageMetadata.CurrentUserName / UserNameEnabled is still ignored — by the plain path and the
ISendMyself path alike, on all three stores. Mapping it would make the ISendMyself path better
than the ordinary one, so it belongs in its own change alongside the plain mapping, not here.

🤖 Generated with Claude Code

erdtsieck and others added 2 commits September 23, 2026 09:42
MessageBus only lets an ISendMyself apply itself when no DeliveryOptions are
passed -- the guard that keeps TimeoutMessage from looping. A projection side
effect always passes some, at minimum the tenant id, so a ToEndpoint(),
DelayedFor() or SignalR ToWebSocketGroup() published out of RaiseSideEffects was
routed as the wrapper type, found no subscriber, and was dropped as NoRoutes.

The bridge now spots an ISendMyself and applies it against its own
ProjectionSideEffectContext: a MessageContext enlisted in the same store session,
with the batch's storage, so a durable send still commits with the projection.
The metadata is stamped in TrackEnvelopeCorrelation -- the one point every
outgoing envelope passes through, whether it leaves via PublishAsync,
EndpointFor() or a topic -- rather than mapped onto a single DeliveryOptions that
only the outermost send would have seen. Anything the message set on its own
options wins. A context per message rather than mutating TenantId/CorrelationId
on the shared batch context for the duration of ApplyAsync(), because slices
publish concurrently.

The MessageBus options == null guard is left alone; it is load bearing.

Three things beyond the original diagnosis:

- The correlation id is only taken over when the projection actually supplied
  one. MessageMetadata.CorrelationId is plain null otherwise, so stamping it
  unconditionally stripped the correlation id that every other side effect
  message from the same batch carries. It now falls back to the batch context's
  id, which keeps a whole projection page on one correlation id.

- The side effect contexts are CLEARED once flushed. Marten only discards its
  cached message batch after a SUCCESSFUL commit -- SaveChangesAsync does not
  reset it in a finally -- so a session retrying a failed commit gets the same
  batch back and would otherwise accumulate, and re-flush, every context from
  every failed attempt for as long as the session lived. The list is also lazily
  allocated now: a batch is built for every commit of every session in any app
  running IntegrateWithWolverine(), and almost none of them publish an
  ISendMyself.

- Polecat and Fisher had the identical defect. Rather than triplicating the fix
  into two more byte-identical bridges, the whole behaviour moved into the store
  agnostic ProjectionSideEffectSink and all three IMessageBatch implementations
  delegate to it. The metadata-to-DeliveryOptions mapping from GH-2545 was
  already copy-pasted three ways, which is how GH-4556 came to be fixed on one
  store and broken on two.

Each store's tests publish the ISendMyself and a plain message out of the same
slice, so the two paths are compared inside one run -- that control is what
catches the ISendMyself path drifting away from the ordinary one again.

Co-Authored-By: Anne Erdtsieck <anne.erdtsieck@topicus.nl>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
BroadcastToTopicAsync was the one send path that never called
TrackEnvelopeCorrelation. MessageRoute.CreateForSending sets no Source, no
CorrelationId, no ParentId and no Store, and nothing downstream filled them in,
so a topic broadcast went out bare while every other route did not.

This surfaced from GH-4556: a projection side effect's metadata rides
TrackEnvelopeCorrelation, so ToTopic() was the one ISendMyself wrapper that
would have been applied correctly and still carried none of its causation id or
custom headers.

Separable from the GH-4556 fix on purpose -- it changes behaviour for every
topic broadcast in every application, not just projection side effects, so it is
its own commit and can be dropped on its own.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@jeremydmiller
jeremydmiller merged commit 7940fd2 into main Sep 23, 2026
44 checks passed
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.

ISendMyself published from a projection side effect is dropped (e.g. ToWebSocketGroup, ToEndpoint)

2 participants