Repository navigation
fix: a snapshot written before #26 no longer re-folds events — marked snapshots; an unmarked one used only when the event after it is newer (#28, 1.3.7) - #46
Merged
Conversation
…server's wall-clock Created (rev-reach a3f8d0c32 m1) — a clock step back could make a short legacy snapshot look valid and re-fold Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…read path (SnapshotOrderRecords), both records carry server $all positions, non-zero, in commit order (lead + rev-reach 2883377b9) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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 #28. A 1.3.7 change, not 1.3.6 (lead's ruling): it changes what a persisted snapshot carries.
The defect
Before #26 an aggregate's version was a count of the events the build could map. A snapshot taken by such a build recorded that count as its
SnapshotVersion. Over a stream that held events the build did not know, the count is lower than the stream revision the snapshot covers. After upgrading,Getreads on fromSnapshotVersion + 1, so it re-folds events already in the snapshot's state (a double apply).Why the true revision can't be recovered from the number
The build that wrote the snapshot mapped its set of types. This build may map more: the classic case is a type that was unknown before the upgrade and known after it. So "count this build's mapped events up to
SnapshotVersion + 1" lands in the wrong place. That's the plausible wrong fix, and it's a mutant below.The fix
AppendSnapshotwritesSnapshotVersionIsRevision: truebesideSnapshotVersion. Since Aggregate version counts the stream revision, not delivered events (#25) #26 the version it writes is the stream revision.GetSnapshotexposes the marker asSnapshot.VersionIsRevision.SnapshotVersion + 1was written before the snapshot, the snapshot may cover it, so it may be low. It is not used, andGetrebuilds the aggregate from the stream, which is always right.Get, until the policy writes a new, marked snapshot.version + 1and the snapshot event. The comparison isPlumberEngine.WrittenAfter,later.Position > earlier.Position. No timestamp is used (rev-reach a3f8d0c32, m1).EventRecord.Created. That's the server's wall clock: a clock step back between the two writes would make a SHORT legacy snapshot look valid, the event would be re-folded, and the result would be the very state this PR fixes.SnapshotOrderRecords, exactly whatGet's snapshot branch uses. The positions come from the server on stream reads.SnapshotVersionIsRevisionsits un-namespaced besideSnapshotVersion(same family; an old reader keeps findingSnapshotVersionwhere it is). Only JSONtrueis trusted; anything else counts as unmarked, so it gets checked.Compatibility both ways
GetSnapshotat master 875ab10, lines 1305–1315) reads onlyCreatedandSnapshotVersion. Pinned by a row that reads a marked snapshot exactly that way.Rows (
Integration/SnapshotRevisionTests)Test aggregate:
TallyAggregate, a snapshotting aggregate whose state shows a re-fold: a count plus the marks seen, so a secondbreads as"abb". It has a second event type,TallyNoted, for "a type a later release added".Legacy snapshots: written through the real
AppendSnapshot, then re-appended with only the marker removed (same type, data and metadata otherwise). Each row assertsVersionIsRevision == falsebefore loading. Loading goes throughGet, via DI (AddPlumberd): a snapshot policy is what makesGetread snapshots at all.ALegacySnapshotOverAnUnknownEvent_DoesNotReFold. The stream is[a, (unknown), b], with a legacy snapshot{ab, 2}at version 1. Expected:"ab", Count 2, Version 2.ALegacySnapshotOverATypeThisBuildNowMaps_IsRebuilt_NotPositionedByThisBuildsCount. The stream is[a, x:TallyNoted, b], with a legacy snapshot{ab, 2}at version 1 (the old build didn't mapTallyNoted). Expected:"axb", Count 3, Version 2.ALegacySnapshotWhoseNextEventCameAfterIt_IsUsed. A legacy snapshot that is RIGHT is still used, so not every library user pays a replay. The snapshot holds a sentinel{S, 100}; used →"Sb"/101, rebuilt →"ab".ASnapshotThisBuildWrote_IsUsed_EvenWhenTheNextEventPrecedesIt. The marker is trusted where the order check alone would doubt: the concurrent-writer shape, with the sentinel state.WrittenAfter_IsDecidedByCommitPosition_NotByCreated. A real store can't plant a clock step, so the seam is fed two records whoseCreatedorder contradicts their commit order: v+1 at position 100, stamped +1 h; the snapshot at 200. The row asserts the inverted-clock precondition first, then that v+1 is NOT "written after".TheRecordsTheDecisionReads_CarryServerPositions_InCommitOrder. The seam's INPUT, through the real read path, on a real store: both records haveCommitPosition > 0, neither isPosition.Start, and v+1's position is below the snapshot's. Were a server to send 0, every legacy snapshot would be rebuilt silently; this row makes that red.AMarkedSnapshot_ReadsThroughThePreMarkerReader_Unchanged. Old build, new snapshot: the event type, the data through the serializer, thenCreatedandSnapshotVersion(an Int64), with the marker an extra key.Mutants (each RED, message read)
"abb"for"ab", and"abb"for"axb".Expected tally.State.Seen to be "axb", but "abb". Row 1 is red too (Version 1 for 2)."Sb"expected,"ab"found."Sb"expected,"ab"found.Createdagain: row 6,Expected PlumberEngine.WrittenAfter(next, snapshot) to be False because the event at v+1 committed BEFORE the snapshot … but found True.Position.Start: row 7,Expected next.Position.CommitPosition to be greater than 0UL, but found 0UL, plus row 3 (the correct legacy snapshot rebuilt:"Sb"expected,"ab"found). That's the silent all-rebuild direction.SnapshotVersion(the alternative shape that breaks older readers): row 5,"requires an element of type 'Number', but the target element has type 'Object'". The legacy rows also red on their marker precondition.Suite
MicroPlumberd.Testsat the tip (5abbd6d, which merges origin/master 9bb51ed; no rebase): 208/214, 18 m 30 s, exit 1, no FATAL or crash, and no refused/409 line. It was the only MP process, under--blame-hang-timeout 4m, and no hang fired. The 6 failures are exactly the long-standing set:GrpcApiTests.ApiInvocationWithProcessorCorrelation,ApiHandingExceptionInvocation,ApiSuccessfulInvocation;ProcessManagerSourceGenerationTests.CommandTypes;SubscriptionRunnerStateTests.ThrowsWrongExpectedVersionExceptionWhenOnDoubleChange,…WhenStreamAlreadyExists.Earlier runs, for the record:
IdentitySeedTestsAT06 and AT07, failed in 1 ms with the store-port 409,removal of container d67dadda… is already in progress. That base predates Testing: a store's port is held machine-wide; its container is acted on by id, never by name; another live process's container is refused (cross-process store race) #45, which fixed that race. Merging master brought Testing: a store's port is held machine-wide; its container is acted on by id, never by name; another live process's container is refused (cross-process store race) #45 in, and the re-run above is clean.IndexBackedMergeReviewFollowupsTestsleft its store up; the cause is inTestAppHost.StartAsync's unbounded wait, fixed separately (bounded host start).🤖 Generated with Claude Code