Skip to content

Fix #5502 -- check the row count of every progression write, not just the first - #5507

Merged
jeremydmiller merged 1 commit into
masterfrom
fix/5502-per-statement-progression-check
Sep 24, 2026
Merged

jeremydmiller merged 1 commit into
masterfrom
fix/5502-per-statement-progression-check

Conversation

@jeremydmiller

Copy link
Copy Markdown
Member

Fixes #5502. Reported with a complete reproduction — and the cumulative-RecordsAffected diagnosis — by @arnelirobles.

The bug is bigger than "index 0"

UpdateProjectionProgress is both AssertsOnCallback and NoDataReturnedCall, so from index 1 on the loop hits continue before anything looks at the marker. A stale-floor progression update is a silent no-op.

That is not only reachable through QueueOperation. ExecutionStage.ExecuteDownstreamAsync records every composite member's progress into the parent's shared batch:

// Need to record the individual progress even though it's locked together
await cloned.ActiveBatch!.RecordProgress(cloned);

…on top of the parent's own, queued by StartProjectionBatchAsync. So a composite with N members puts N+1 progression operations on one page — the shipped three-member fixture produces four rows. The parent's is deterministically index 0 (queued before any stage runs), so the parent was checked and every member was not.

Why the obvious fix doesn't work

DbDataReader.RecordsAffected is cumulative across the batch, so a second progression update inherits the first one's count and never sees its own zero. I confirmed the naive fix (mirroring the index-0 branch) still doesn't throw.

It also means the index-0 check was never reliable either: when a later statement contributes a row, index 0's own zero is masked. That's the second test here, and it was red before this change.

The per-statement count lives on NpgsqlBatchCommand.RecordsAffected — but Npgsql populates it lazily as the reader advances. I measured this: parked on statement 0, even after draining 5000 rows, statements 1 and 2 both read -1; they only become correct after NextResultAsync() steps past them. So there is no point inside the callback loop at which an operation could read its own count, in any design.

Hence a separate pass after the reader is drained. Compile() keeps the NpgsqlBatch for it. BatchBuilder.StartNewCommand() appends exactly one command per operation and no Marten operation starts one of its own, so _operations[i] is _compiled.BatchCommands[i].

It logs, it does not throw

Deliberate, and the main thing to push back on if you disagree. Making the check effective converts progression writes that have been silently no-opping into hard shard failures, and ShardFailure categorizes ProgressionProgressOutOfOrderException as a split-brain double-daemon — an actively misleading diagnosis for what is usually a loader bug (#5501 was exactly one). Promoting to a throw is a breaking change and belongs in a major; the exception the operation hands back is the one that will be thrown then. The long-standing index-0 throw is untouched.

The logged exception uses the three-argument overload, so it carries the floor and ceiling: "expected floor 5 but it had already moved, attempted ceiling 50" is what tells an operator which of the two they're looking at.

Scope

One additive interface, IAssertsRecordsAffected, implemented only by UpdateProjectionProgress. InsertProjectionProgress doesn't need it (a failed insert raises a PK violation). DeleteProjectionProgress isn't AssertsOnCallback, so projection teardown never enters the pass — and a page with no opted-in operation returns before it even drains.

Entirely inside Marten. PostprocessAsync lives in Weasel.Core and is untouched — changing its signature to carry a count would have been a cross-repo binary-compat break across Marten and Polecat, which is why this is a separate interface instead.

Per-tenant partitioning turned out not to be a second instance of this. Each {name}:All:{tenant} shard gets its own agent, execution and ProjectionUpdateBatch with exactly one progression op; SessionForTenant only ever adds document operations. The test added there pins that invariant so a future change that starts sharing batches across tenants doesn't quietly reintroduce the hazard.

Tests

Three of the six unit cases are red without the pass. The rest are guards against exactly the risk this change carries:

  • a_lone_stale_update_still_throws_exactly_as_it_always_has — no behavior change at index 0;
  • a_healthy_batch_of_several_progression_updates_reports_nothing — no false positives;
  • an_ordinary_document_batch_is_untouched;
  • a_document_write_mixed_in_with_a_stale_progression_update_still_commits — the extra pass doesn't disturb a real operation's result-set handling.

Plus two end-to-end runs covering the shapes that actually batch: a composite daemon run (four progression rows, all advancing, zero warnings) and a per-tenant daemon run across two tenants.

suite Newtonsoft System.Text.Json
DocumentDbTests 1113 / 1 skipped / 0 failed 1109 / 5 skipped / 0 failed
DaemonTests 332 / 332 332 / 332
EventSourcingTests 2300 / 20 skipped / 0 failed 2300 / 20 skipped / 0 failed
TenantPartitionedEventsTests 253 / 2 skipped / 0 failed —
CoreTests 686 / 7 skipped / 0 failed —

net9.0 against local PostgreSQL 17. DocumentDbTests is identical to the master baseline.

Related: #5506 (#5501), which is one concrete source of the stale floors this now reports.

🤖 Generated with Claude Code

https://claude.ai/code/session_01481eiEZg1Bv6pu4DrgPRg3

… the first

OperationPage.ApplyCallbacksAsync only ever ran the AssertsOnCallback check for
the operation at index 0. UpdateProjectionProgress is both AssertsOnCallback and
NoDataReturnedCall, so from index 1 on the loop hit `continue` before anything
looked at the marker: PostprocessAsync never ran and a stale-floor progression
update was a silent no-op.

That is not a corner case reachable only through QueueOperation.
ExecutionStage.ExecuteDownstreamAsync records every composite MEMBER's progress
into the parent's shared batch, on top of the parent's own from
StartProjectionBatchAsync. A composite with N members puts N+1 progression
operations on one page -- the shipped three-member fixture produces four. The
parent's is deterministically index 0, because it is queued before any stage
runs. So the parent was checked and every member was not.

The obvious fix does not work, and the reporter said so. DbDataReader.
RecordsAffected is CUMULATIVE across the batch, so a second progression update
inherits the first one's row count and never sees its own zero. That is what the
old "weird quirks of the combined statements" comment was describing. It also
means the index-0 check was never reliable either: when a LATER statement
contributes a row, index 0's own zero is masked, which the second test here pins.

The per-statement count is on NpgsqlBatchCommand.RecordsAffected, but Npgsql
populates it lazily as the reader advances -- an operation's own count still
reads -1 while the reader sits on an earlier statement, so there is no point
inside the callback loop at which it could be read. Hence a separate pass after
the reader is drained. Compile() keeps the NpgsqlBatch for it; BatchBuilder
appends exactly one command per operation and no Marten operation starts one of
its own, so _operations[i] is _compiled.BatchCommands[i].

It LOGS rather than throws, on purpose. Making the check effective converts
progression writes that have been silently no-opping into hard shard failures,
and ShardFailure categorizes ProgressionProgressOutOfOrderException as a
split-brain double-daemon -- an actively misleading diagnosis for what is usually
a loader bug (#5501 was one). Promoting it to a throw is a breaking change and
belongs in a major; the exception the operation hands back is the one that will
be thrown then. The long-standing index-0 throw is untouched.

New surface is one additive interface, IAssertsRecordsAffected, implemented only
by UpdateProjectionProgress. InsertProjectionProgress does not need it -- a
failed insert raises a PK violation. DeleteProjectionProgress is not
AssertsOnCallback, so projection teardown never enters the pass at all; a page
with no opted-in operation returns before it even drains.

The logged exception uses the three-argument ProgressionProgressOutOfOrderException
overload, so it carries the expected floor and attempted ceiling. "Expected floor
5 but it had already moved, attempted ceiling 50" is what tells an operator
whether they are looking at a second daemon or a bad floor.

Per-tenant partitioning turned out NOT to be a second instance of this: each
{name}:All:{tenant} shard gets its own agent, execution and ProjectionUpdateBatch
with exactly one progression operation, and SessionForTenant only ever adds
document operations. The test added there pins that invariant so a future change
that starts sharing batches across tenants does not quietly reintroduce this.

Tests: three of the six unit cases are red without the pass. The others are
guards -- a lone stale update still throws as it always has, a healthy batch of
three reports nothing, an ordinary document batch is untouched, and a document
write mixed in with a stale progression update still commits. Plus an end-to-end
composite run (four progression rows, all advancing, zero warnings) and a
per-tenant daemon run across two tenants, both covering the real risk here, which
is false positives.

DocumentDbTests 1113/1114, DaemonTests 332/332, EventSourcingTests 2300/2320,
TenantPartitionedEventsTests 253/255 and CoreTests 686/693 on net9.0; the first
three also green under DEFAULT_SERIALIZER=SystemTextJson. Local PostgreSQL 17.

Reported with a complete reproduction and the cumulative-RecordsAffected
diagnosis by @arnelirobles.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01481eiEZg1Bv6pu4DrgPRg3
@jeremydmiller
jeremydmiller merged commit dff854c into master Sep 24, 2026
32 checks passed
@jeremydmiller
jeremydmiller deleted the fix/5502-per-statement-progression-check branch September 24, 2026 21:04
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.

OperationPage only runs the AssertsOnCallback check at index 0

1 participant