Skip to content

Carry the correlation a slice's events agree on onto its side effects (#1101) - #1105

Merged
jeremydmiller merged 1 commit into
mainfrom
fix/1101-side-effect-correlation
Nov 2, 2026
Merged

jeremydmiller merged 1 commit into
mainfrom
fix/1101-side-effect-correlation

Conversation

@jeremydmiller

Copy link
Copy Markdown
Member

Closes #1101.

A message published with slice.PublishMessage(message) went out with its tenant alone, even though the slice held the correlation id of the event that caused it. Under the async daemon the bus then fell back to one id per page, so two unrelated flows landing in one page shared a correlation id. Inline, each flow lost its id.

What changes

When a message has no explicit metadata, its slice now supplies the correlation id if every appended event in the slice agrees on one. This applies on the async path and both inline paths, which all go through one method on EventSlice, so they can't drift apart.

  • No agreement means no change. A null on an appended event, a disagreement or an empty slice makes exactly the old tenant-only call. Taking the last event's value would be a guess, because a slice can span several flows.
  • Stand-in events don't count. slice.Reference<T>() and composite stages add events nobody appended, and their null ids say nothing about the flow. Counting them would have left every enriched slice with the bug. References<T> (and so Updated<T>) and ProjectionDeleted<TDoc, TId> now carry an internal ISyntheticEvent marker.
  • MessageMetadata.From(IEvent) is new, so the mixed case is one line: slice.PublishMessage(msg, MessageMetadata.From(e)). It takes the event's tenant and correlation id.
  • slice.PublishMessage(message, metadata) still wins, and the order between the two lists is unchanged.

Decisions worth checking

  • This is the default, with no option. An opt-in is the state the issue says is the problem.
  • Causation is deliberately left out, on both the default path and From. The issue suggested IEvent.CausationId, but it does damage here. Wolverine sets ConversationId = originalEnvelope.Id while handling a message, so today a side effect handler's events correctly name that side effect as their cause. A causation-id header takes precedence over that in OutboxedSessionFactory, so copying the event's causation would make those events name the root message instead, and flatten the chain. MessageMetadata.CausationId stays available to anyone who sets it on purpose. A test pins this.

Release note

With event metadata on, side effects from slice.PublishMessage(message) now carry the correlation id that all of the slice's appended events agree on. The id used to be the daemon page's id (async) or a fresh one (inline). This changes the envelope's correlation id for existing side effects. A custom IMessageSink or IProjectionBatch that overrides the metadata overload now receives these calls instead of the tenant-only overload.

With event metadata off, or when nothing agrees, nothing changes. New public MessageMetadata.From(IEvent).

Known limits

  • Still falls back: fan-out and ReplaceEvent slices, because CloneEventWithNewData doesn't copy CorrelationId, despite its doc comment. Fixing that may change what stores write on projected documents, so it needs its own issue.
  • Composite stages: a stage holding only Updated<T> events has no appended event to read, so its side effects fall back too.
  • Wolverine half: with event metadata off, the page-wide id still merges flows. That belongs in Wolverine.

Testing

Full gate on this branch, locally on net9.0 and net10.0: 7,788 passed, 0 failed, 62 skipped. With the call sites or the stand-in skip reverted, the new tests failed, and they also failed against a "take the last value" mutation.

That was the third gate run on this commit. The first two each failed on a test this change doesn't touch:

  • CommandLineTests.DescribeCommandTests.write_file_smoke_test_as_htm: Collection was modified inside AnsiConsole.ExportHtml, 2 of 12 in a loop. CommandLineTests has no reference to JasperFx.Events.
  • SideEffectGateWarmupThrottleTests.the_gate_flip_is_driven_by_committed_progression_not_by_what_was_loaded, at a load average around 60. By reading the code it's a race between LastCommitted and the gate flip, but that wasn't reproduced.

Marten, Polecat and Fisher need nothing: each IProjectionBatch overrides the metadata overload and forwards it, and all three Wolverine bridges map the correlation id. That comes from reading their source. This has not been run end to end against a store or Wolverine, and no compliance fact proves that a store forwards metadata.

🤖 Generated with Claude Code

…#1101)

A message published with slice.PublishMessage(message) went out with its
tenant alone, even though the slice held the correlation id of the event
that caused it. The bus then fell back to its own context, which under the
daemon is one context per batch, so two unrelated flows landing in one page
were given the same correlation id. Inline, each flow lost its id.

The override from #186 works, but it needs every projection author to know
about it, and the people who need the correlation usually don't own the
projection. So this is now the default rather than an option. The envelope's
correlation id changes for existing side effects whenever event metadata is
on.

PublishMessage(message) doesn't say which event it came from, so the slice
only vouches for what all of its events agree on. A null on an appended
event, a disagreement or an empty slice carries nothing, and taking the last
event's value would be a guess. With no agreement the call is the tenant-only
one as before, so a store or sink sees no change with event metadata off.

Synthetic events don't count. slice.Reference<T>() and a composite's upstream
stages put stand-ins beside the appended events, and those were never
appended, so their null ids say nothing about the flow. Counting them would
have left every enriched slice with the bug. ICanWrapEvent can't recognise
them because plain References<T> doesn't implement it, so they get a marker.

Causation is deliberately left out. Wolverine lets a causation-id header win
over the conversation id when it sets up a handler's session, and that
conversation id is the handler's own envelope id. Copying the event's
causation would have a side effect's handler name the root message as the
cause of what it appends, and flatten the chain from there.
MessageMetadata.CausationId stays available to anyone who sets it on purpose.

The decision lives on EventSlice so the daemon and both inline paths cannot
drift. MessageMetadata.From(IEvent) covers the mixed case, where only the
projection knows which event a message belongs to, and gives the same answer
as the default. It takes the event's tenant, because the message answers to
that one event.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@jeremydmiller
jeremydmiller merged commit 026b20e into main Nov 2, 2026
1 check passed
erdtsieck pushed a commit to erdtsieck/jasperfx that referenced this pull request Nov 3, 2026
CloneEventWithNewData and WithData dropped CorrelationId and CausationId, so
a slice built by fan out or ReplaceEvent held nulls and PublishMessage(message)
fell back to the tenant-only call that JasperFx#1105 fixed for every other slice.

There are two clone methods and two FanOut extensions. A slicer's fan out rules
and ReplaceEvent use CloneEventWithNewData, while EventSlice.FanOut, and so
SliceGroup.FanOutOnEach, uses WithData. WithData now delegates so the two
cannot drift apart again.

No store writes these ids from an event: Marten and Fisher read only Version and
Sequence from the last event, Polecat reads nothing, and the document columns
and ITracked come from the session. A projection that reads them off a clone
itself now sees the source's value instead of null.

Co-Authored-By: Claude Opus 5.5 <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.

Projection side effects drop the correlation id of the event that caused them, and the async daemon merges unrelated flows

1 participant