Repository navigation
Fix #5502 -- check the row count of every progression write, not just the first - #5507
Merged
Merged
Conversation
… 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
This was referenced Sep 24, 2026
This was referenced Oct 2, 2026
This was referenced Oct 9, 2026
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.
Fixes #5502. Reported with a complete reproduction — and the cumulative-
RecordsAffecteddiagnosis — by @arnelirobles.The bug is bigger than "index 0"
UpdateProjectionProgressis bothAssertsOnCallbackandNoDataReturnedCall, so from index 1 on the loop hitscontinuebefore anything looks at the marker. A stale-floor progression update is a silent no-op.That is not only reachable through
QueueOperation.ExecutionStage.ExecuteDownstreamAsyncrecords every composite member's progress into the parent's shared batch:…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.RecordsAffectedis 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 afterNextResultAsync()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 theNpgsqlBatchfor 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
ShardFailurecategorizesProgressionProgressOutOfOrderExceptionas 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 byUpdateProjectionProgress.InsertProjectionProgressdoesn't need it (a failed insert raises a PK violation).DeleteProjectionProgressisn'tAssertsOnCallback, so projection teardown never enters the pass — and a page with no opted-in operation returns before it even drains.Entirely inside Marten.
PostprocessAsynclives 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 andProjectionUpdateBatchwith exactly one progression op;SessionForTenantonly 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.
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