Repository navigation
Persist progression when a composite single-pass replay ends on an empty page - #989
Merged
jeremydmiller merged 1 commit intoOct 9, 2026
Conversation
…pty page CompositeReplayExecutor only called MarkSuccessAsync(ceiling) for an empty page, so the in-memory position moved to the ceiling but no progression row was written (floor 0) or the row stayed at the last event. The next continuous page then updated a row that was missing or behind, threw ProgressionProgressOutOfOrderException and stopped the shard. Commit the empty range through the composite execution instead. Claude-Session: https://claude.ai/code/session_01C5ySTvFt8fb8TmQakcKRx9
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
We hit this in production on JasperFx.Events 2.81.0 + Marten 9.47.0 with per-tenant partitioned events: 269 tenant agents of one composite projection ended up Stopped with
ProgressionProgressOutOfOrderException, and nothing restarted them.The composite has a composite-level
IncludeTypefilter. When its agent starts continuous with no progression row (LastCommitted == 0) and withoutGateSideEffectsBehindPriorVersion,SubscriptionAgenttakes the single-pass replay path (SubscriptionAgent.cs, theCommandType.Startbranch that callsTryBuildReplayExecutor) throughCompositeReplayExecutor.For a tenant whose events are all filtered out, the first replay page is empty.
CompositeReplayExecutorthen only callscontroller.MarkSuccessAsync(ceiling). That movesLastCommittedto the ceiling in memory, but no progression row is written. The next continuous page has floor = ceiling, so Marten'sProjectionBatch.RecordProgresstakes the update path (floor != 0).UpdateProjectionProgress(... where name = ? and last_seq_id = ?) matches no row and throwsProgressionProgressOutOfOrderException, and the agent stops.The same thing happens when the last replay page is exactly
BatchSizeand the next page is empty: the row is left at the last event's sequence while memory is at the ceiling.Root cause
src/JasperFx.Events/Projections/Composite/CompositeReplayExecutor.cs: thepage.Count == 0branch advanced progression withMarkSuccessAsynconly. Every other page goes through_execution.ProcessRangeAsync, which starts a batch (where the store records the composite's progress: insert from floor 0, update otherwise), records each member's progress inExecutionStage, executes, and only then callsMarkSuccessAsync. The empty page skipped all of that, so the stored and in-memory positions diverged.Fix
For the empty page, commit an empty
EventRange(page.Floor, ceiling)through the composite execution and stop, instead of only callingMarkSuccessAsync. Floor 0 now inserts the composite and member rows, and a later floor updates the row from where the previous page left it. Termination is unchanged: the pass still ends at the ceiling on the first empty page.The
ceiling <= floorbranch at the top stays as it is; with nothing in the store the next continuous page still starts at floor 0 and inserts.Tests
New
CompositeReplayExecutorProgressionTestsdrives a realCompositeExecutionwith a small fake progression table that behaves like Marten's (insert from floor 0, otherwise update only where the row is at the floor, elseProgressionProgressOutOfOrderException):Both fail on main (row missing / row at 2 instead of 10) and pass with the fix.
CompositeReplayExecutorTests.advances_to_ceiling_when_no_events_match_below_high_waterpinned the old behaviour (noProcessRangeAsync); it now asserts the empty range(0, 25]goes through the execution, renamed accordingly.EventTests: 1384/1384 passing on net9.0 and net10.0.I did not add a Marten-level test; the progression semantics are Marten's, and I modelled them in the fake rather than pulling a store into JasperFx.
Downstream we have mitigated this for now by turning on
GateSideEffectsBehindPriorVersion, which keeps that composite off the single-pass replay path.https://claude.ai/code/session_01C5ySTvFt8fb8TmQakcKRx9