Fix checkpoint failure when serializing a concurrently-mutated object (#2101) - #2136
Conversation
…rializationPhase in ClearSerializedObjectData
Do not mutate a live object from the read path HashTimeToLive ran DeleteExpiredItems(), mutating hash, expirationTimes and expirationQueue while holding only a shared lock, so a concurrent snapshot could enumerate the dictionary as it changed. The purge is dropped; expired fields are already filtered by ContainsKey, so the response is unchanged. Do not dispose a source record that a checkpoint has frozen Disposal clears the record's heap fields, which returns the value's ObjectIdMap slot to the page free list for reuse by another record. The snapshot flush reads object ids from its page copy but resolves them against the live map, so disposing a frozen record let the flush serialize a freed, or recycled and therefore unrelated, object. OnDisposeSupersededSource() skips disposal while frozen; the value is released at page eviction instead. Applied to the four RMW expiration paths and the Delete path. Keep reused allocations above the checkpoint's fuzzy-region start The fuzzy-start clamp on minRevivAddress only covered the free record pool, so an allocation saved before the version shift and retried afterwards could place a (v+1) record below the fuzzy start, where the snapshot includes it regardless of its version bit. The clamp now covers both reuse paths. Elide the source record on Delete CreateNewRecordDelete already passed elideSourceRecord to the allocator, which relaxes minRevivAddress on the premise that the source leaves the tag chain, but it never actually elided it; the relaxed bound was therefore unsound. The tombstone now carries the source's PreviousAddress and the source is invalidated and freelisted, as CreateNewRecordUpsert and CreateNewRecordRMW do. Unlike those, Delete fires OnDispose(Deleted) before eliding, because Garnet keys range-index file cleanup and Vector Set deletion off that reason; the subsequent elide or freelist disposal only accounts for the key, so nothing is counted twice. PageEvictionFiresOnEvictForEveryLiveRecord assumed delete sources stay in the tag chain and are visited by eviction. It now derives the expected eviction count from the number of sources actually elided.
CleanupOfCachedDataLeavesObjectTerminal covers #2101: it drives the CopyUpdate-during- checkpoint path that caches a superseded object's (v) bytes, runs the post-checkpoint cleanup that releases them, and asserts the object stays terminal. Returning it to REST let the next checkpoint take the direct-serialize path and enumerate the collections the superseding (v+1) record still shares and mutates. FailedSerializationRestoresRestPhase covers the exception-safety half: a DoSerialize that throws must leave the object in REST, not stranded in SERIALIZING where every later Serialize() and CacheSerializedObjectData() would spin on it. Both fail against the pre-fix code with exactly these diagnostics.
There was a problem hiding this comment.
🟡 Changes recommended
Unresolved correctness concerns and missing regression coverage remain.
Get a fresh assessment by requesting another Copilot review.
Pull request overview
This pull request fixes checkpoint failures caused by concurrent object mutation and hardens related Tsavorite record lifecycle behavior.
Changes:
- Makes serialization exception-safe and terminal.
- Protects frozen records, allocation bounds, and delete elision.
- Prevents Hash TTL reads from mutating shared state.
- Adds regression tests and state-machine documentation.
File summaries
| File | Summary |
|---|---|
website/sidebars.js |
Registers the state-machine documentation. |
website/docs/dev/tsavorite/state-machine.md |
Adds state-machine documentation. |
website/docs/dev/tsavorite/intro.md |
Links to the new documentation. |
libs/storage/Tsavorite/cs/test/test.recovery/ObjectLogScanTests.cs |
Adds serialization regression coverage. |
libs/storage/Tsavorite/cs/test/test.recordops/RecordLifecycleTests.cs |
Updates eviction lifecycle expectations. |
libs/storage/Tsavorite/cs/src/core/Index/Tsavorite/Implementation/InternalRMW.cs |
Protects frozen records during expiration RMWs. |
libs/storage/Tsavorite/cs/src/core/Index/Tsavorite/Implementation/InternalDelete.cs |
Implements safe delete-source elision. |
libs/storage/Tsavorite/cs/src/core/Index/Tsavorite/Implementation/Helpers.cs |
Handles frozen-source disposal safely. |
libs/storage/Tsavorite/cs/src/core/Index/Tsavorite/Implementation/BlockAllocate.cs |
Enforces checkpoint-safe allocation bounds. |
libs/storage/Tsavorite/cs/src/core/Index/Checkpointing/StateMachineDriver.cs |
Clarifies state-machine behavior in comments. |
libs/storage/Tsavorite/cs/src/core/Allocator/HeapObjectBase.cs |
Hardens serialization state transitions and cleanup. |
libs/server/Objects/Hash/HashObjectImpl.cs |
Prevents mutation during Hash TTL reads. |
Review details
Suppressed comments (4)
libs/server/Objects/Hash/HashObjectImpl.cs:461
- Removing
DeleteExpiredItems()fixes the shared-lock mutation, but it also removes the only automatic cleanup for a read-only hash. WithExpiredObjectCollectionFrequencySecsdisabled by default and no later mutating operation orHCOLLECT, expired entries remain inhash,expirationTimes, andexpirationQueueindefinitely; HTTL merely hides them in responses, so repeated field TTLs can grow the object's memory without bound. Please provide a write-safe way to schedule/perform equivalent reclamation rather than dropping it entirely.
// This is a read operation, so it must not mutate the object: it runs under a shared lock and may execute
// while the flush path is serializing this same instance. Expired fields are reported as absent by
// ContainsKey (via GetExpiration) without being removed; the mutating paths purge them from the live object.
libs/storage/Tsavorite/cs/src/core/Index/Tsavorite/Implementation/BlockAllocate.cs:71
- The new fuzzy-start clamp has no regression test for the failure mode it addresses. Existing revivification tests cover ordinary free-record reuse and snapshots after deletes, but not an allocation saved before the version shift and retried after
CPR_SHIFT_DETECTED; without that interleaving, a reused v+1 record can still land belowstartLogicalAddressand leak into the snapshot. Add a deterministic retry/free-list test that asserts the snapshot excludes the v+1 value.
// A (v+1) record must never land below the checkpoint's fuzzy-region start: the snapshot includes every record
// below that address regardless of its version bit, so reusing a lower address would leak (v+1) data into the
// checkpoint image. This applies to both reuse paths below, including an allocation that was saved before the
// version shift and is only now being retried as (v+1).
if (sessionFunctions.Ctx.IsInV1)
{
var fuzzyStartAddress = _hybridLogCheckpoint.info.startLogicalAddress;
if (fuzzyStartAddress > minRevivAddress)
minRevivAddress = fuzzyStartAddress;
libs/storage/Tsavorite/cs/src/core/Index/Tsavorite/Implementation/Helpers.cs:126
- The new frozen-source branch is not exercised by the current lifecycle tests: those tests only call
ShiftReadOnlyAddresswhile the session remains inREST, soCtx.IsInV1is false; none overlaps an RMW/delete expiration with an active checkpoint. This is the safety property that prevents a snapshot from resolving a freed or recycledObjectIdMapslot, so add a deterministic checkpoint-overlap regression test and verify the recovered object data.
if (IsFrozen<TInput, TOutput, TContext, TSessionFunctionsWrapper>(sessionFunctions, ref stackCtx, logRecord.Info))
return;
libs/storage/Tsavorite/cs/test/test.recovery/ObjectLogScanTests.cs:327
- This does not exercise the regression path described by the test: it manually marks a wrapper record as new and calls
CacheSerializedObjectDatadirectly, without running an RMW/CopyUpdate or a checkpoint flush while the cloned value is being mutated. It therefore cannot catch failures in the actual CAS/phase transitions or the concurrent serialization that caused #2101. Please add an end-to-end test that performs a real CopyUpdate during an active checkpoint and mutates the resulting shared value while the snapshot is flushed.
var logRecord = store.hlogBase._wrapper.CreateLogRecord(beginAddress);
logRecord.InfoRef.SetIsInNewVersion();
RMWInfo rmwInfo = default;
value.CacheSerializedObjectData(ref logRecord, ref rmwInfo, srcIsOnMemoryLog: true);
- Files reviewed: 12/12 changed files
- Comments generated: 1
- Review effort level: Lite (auto)
Note
Copilot is running an experiment and ran this review at Lite.
💡 Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
FailedSerializationRestoresRestPhase only calls Serialize, so it exercises the direct path's finally. The catch in CacheSerializedObjectData, which covers the other DoSerialize caller, had no test: a regression there could strand the object in SERIALIZING, hanging every later serialization attempt on it. FailedCachedSerializationRestoresRestPhase drives the CopyUpdate-during-checkpoint capture with a throwing DoSerialize and asserts the exception is rethrown, REST is restored, and no partial capture is published. It fails with 'stranded the object outside REST' if the catch is removed. The store setup shared with CleanupOfCachedDataLeavesObjectTerminal moves into a helper.
The existing ObjectLogScanTests coverage asserts the serialization-phase invariant by calling CacheSerializedObjectData directly on an object that holds no collection. That is a valid unit test of the SAD protocol and it does fail without the fix, but it never runs InternalRMW, never has a checkpoint active, and can never produce 'Collection was modified'. It therefore does not reproduce the reported bug. CheckpointSharedObjectMutationTests drives the real path: a snapshot checkpoint is paused on entry to WAIT_FLUSH, an RMW in that window takes the CPR copy-update route through CreateNewRecordRMW into CacheSerializedObjectData, and the clone shares the source's List exactly as Garnet's shallow Clone() does. Post-checkpoint cleanup then runs, the surviving record mutates the shared list, and the superseded object is serialized again while a second mutation lands mid-enumeration. Against the pre-fix allocator that fails with InvalidOperationException: Collection was modified; enumeration operation may not execute. which is the exception from the issue, raised from the same direct serialize path. It passes with the fix. HashTimeToLive gains a comment recording why dropping DeleteExpiredItems does not leak without bound: a field only becomes expirable through HEXPIRE/HPEXPIRE, which is an RMW that purges first, so the expired-but-unpurged set cannot exceed the fields carrying a TTL at the last mutating operation and a read-only workload cannot grow it.
Blocker analysisAddressing the two CI failures and the four review comments. Summary: one review comment was correct and has been fixed, one rests on a premise that does not hold, and both CI failures are demonstrably not regressions from this diff. 1.
|
| Method | Line | Dispatch |
|---|---|---|
HashSet |
187 | RMW |
HashCollect |
245 | RMW |
HashIncrement |
303 | RMW |
HashIncrementFloat |
378 | RMW |
HashExpire |
438 | RMW |
HashPersist |
506 | RMW |
So the expired-but-unpurged set can never exceed the fields that were carrying a TTL at the last mutating operation, and a read-only workload cannot grow it: no writes, no new TTLs, no new expirations beyond that fixed set. It is reclaimed by the next mutating operation, by HCOLLECT, or by the background collector.
The accurate residual statement is: a hash whose fields expire after its last write retains that bounded memory until one of those three events. That is precisely the behavior SortedSetTimeToLive (ZTTL) has always had — it filters with IsExpired and never purges — so this change makes HTTL consistent with its sibling rather than introducing a new class of retention.
Keeping the fix. Mutating hash, expirationTimes and expirationQueue under a shared lock while the flush path enumerates the same instance is a data-corruption bug; trading it for bounded, reclaimable retention that matches the sorted-set command is the right call. The analysis is now recorded as a comment at the call site so the tradeoff is explicit rather than implied.
3. CI failures on 598838c7 (run 35154807588) — neither is a regression
RespTests.MultipleClientsUnblockAndAddTest(20) (ubuntu, net8.0, Debug)
Fails at RespTests.cs:5502, ClassicAssert.IsTrue(blockingResult.StartsWith("-UNBLOCKED")).
The decisive detail is which assertion failed. Line 5498 — AreEqual(numberOfItems - numberOfItemsReturned, listLength) — passed. Data integrity was correct. The failure is on the following line, about which of three concurrent CLIENT UNBLOCK calls raced 20 concurrent ListLeftPush calls against one BLMPOP, i.e. purely the blocking-manager outcome.
Reachability against this diff: the test uses BLMPOP, LPUSH, LLEN, CLIENT UNBLOCK. No DELETE, no TTL, no checkpoint.
HeapObjectBase— flush/checkpoint only; neither occurs.HashObjectImpl.HashTimeToLive— HTTL only; no hashes.InternalRMW— all four edits are insideRMWAction.ExpireAndStop/ExpireAndResume; no TTLs are set, so no expiration action is ever returned.BlockAllocate— the clamp is guarded bysessionFunctions.Ctx.IsInV1; no checkpoint.Helpers.OnDisposeSupersededSource— returns early only whenIsFrozen, which requiresCtx.IsInV1; with no checkpoint it callsOnDispose(Deleted), identical to the previous code.InternalDelete— the only edit reachable in principle, and the passing length assertion shows the chain stayed correct.
Locally: 5/5 passes on this branch (net8.0 Debug). #2110 ("Fix root causes behind recurring Garnet .NET CI flakes"), merged after this branch's last merge, reworks GarnetServerTcp.cs and GarnetClientSession.cs, which is where this test's client/connection behavior lives.
ClusterVectorSetTests.MultipleReplicasWithVectorSetsAndDeletesAsync (windows, net8.0, Release)
Fails in WaitForReplicaAofSync: [127.0.0.1:7600]: 1,588088 != [127.0.0.1:7603]: 1,574228 — the replica was ~14 KB of AOF behind the primary when the backoff window expired. That is a sync-throughput timeout, not a correctness assertion; nothing in this diff touches AOF, replication or gossip.
Locally: 3/3 passes on this branch (net8.0 Release). #2110 rewrote exactly this path — VectorManager.Replication.cs (+171), VectorManager.cs (+143), AofSyncTask.cs, AofSyncDriver.cs, Gossip.cs — and adjusted the replication sync tests themselves.
I treated this one as a real suspect rather than assuming flakiness, because the test name contains "Deletes" and this PR changes the Delete path. The Delete change elides the source record from the tag chain; it does not alter what is written to the AOF, which records the DELETE command. The failure is offset lag, not divergence.
This branch is deliberately still BEHIND main and has not picked up #2110. It is also staying behind while main is red for the Tsavorite build (#2161), to avoid importing four inherited Build Tsavorite failures that would obscure this PR's own signal.
4. Remaining coverage gaps — acknowledged, not closed
Two review comments are accurate and I am not claiming otherwise:
BlockAllocate.cs:71— the fuzzy-start clamp has no test for its specific failure mode (an allocation saved before the version shift, retried afterCPR_SHIFT_DETECTED, reused belowstartLogicalAddress). Reaching it requires forcing a retry across a version boundary, which the new state-machine pause harness now makes feasible.Helpers.cs:126— the frozen-source branch is still unexercised. The new end-to-end test performs an RMW during an active checkpoint, butOnDisposeSupersededSourceis only called from the expiration paths, so a non-expiring RMW does not reach it. A test needs an RMW returningExpireAndStop/ExpireAndResumeagainst a frozen source while a checkpoint is in the fuzzy region.
Both are guarded by Ctx.IsInV1 and are therefore inert outside a checkpoint; neither is exercised by existing lifecycle tests, which shift read-only while in REST.
Validation
Tsavorite.test.recovery 212 passed (incl. the new test), Tsavorite.test.recordops 220, Garnet.test.collections 780. dotnet format --verify-no-changes clean on both solutions. Every run checked for error CS first, since a compile failure makes dotnet test silently run a stale assembly.
Fixes #2101.
The failure
A snapshot checkpoint throws
InvalidOperationException: Collection was modified after the enumerator was instantiatedout ofListObject.DoSerialize. The exception escapes the flush, so the checkpoint never completes and the pipeline wedges permanently: every laterBGSAVEreturnsERR checkpoint already in progress, a core stays pinned, and the AOF grows without bound.Root cause
Clone()is a shallow copy, so a CopyUpdate leaves the (v) and (v+1) records sharing oneLinkedList<byte[]>.A(v) → recordB(v+1).A.CacheSerializedObjectDatacaptures A's bytes and sets its phase toSERIALIZED; C1 serializes A from those cached bytes, which is correct.Bis (v+1), so the snapshot excludes it.Log.ClearSerializedObjectData(...), which freed the cached bytes and reset the phase toREST.Ais still Sealed-but-Valid in the log, and still points at the listBkeeps mutating.Ais below the fuzzy-region start, so the snapshot includes it. Its phase isREST, soSerialize()takes the direct path and enumerates the live shared list while a client mutates it.Changes
Change 1 is the fix for #2101 itself. Auditing the surrounding code for the same class of problem — an object being serialized by the flush while something else mutates or frees it — turned up four further issues, each independently reachable, which are fixed here as well.
1. Keep a superseded object's serialization phase terminal (
HeapObjectBase)ClearSerializedObjectDatanow releases the cached bytes but leaves the phaseSERIALIZED.SERIALIZEDwith null bytes is exactly the stateSerialize()already documents as "superseded after checkpoint completion": it writes the null indicator. That is safe because bytes are only ever cached for a source a CopyUpdate has just superseded, so a higher-address record always carries the live data.Serialization is also made exception-safe. A
DoSerializethat throws used to strand the object inSERIALIZING, where every laterSerialize()andCacheSerializedObjectData()spins on it forever — a second, independent way to wedge the checkpoint pipeline. The direct path now restoresRESTin afinally, and the caching path discards partial bytes and restoresRESTbefore rethrowing.2. Stop a read-path command from mutating a live object (
HashTimeToLive)HashObjectImpl.HashTimeToLivecalledDeleteExpiredItems(), mutatinghash,expirationTimesandexpirationQueue. ButHashOps.HashTimeToLivedispatches throughReadObjectStoreOperation, so it runs under a shared lock and could restructure the dictionary while the flush path enumerated it.The purge is removed. Responses are unchanged:
ContainsKey/GetExpirationalready report expired fields as absent (-2). This also makes HTTL consistent withSortedSetTimeToLive, the sibling command on the same read dispatch that never purged, and with Hash's own read accessors. Reclamation is unchanged in kind and now symmetric with sorted sets — mutating ops,HCOLLECT/ZCOLLECT, or the background collector.3. Do not dispose a source record a checkpoint has frozen
OnDispose(..., Deleted)clears the record's heap fields, which returns the value'sObjectIdMapslot to that page's free list for reuse. The snapshot flush reads object ids from its page copy but resolves them against the live map, so disposing a frozen record lets the flush serialize a freed — or recycled and therefore unrelated — object.A new
OnDisposeSupersededSourceskips disposal while the record is frozen; the value is released at page eviction instead, which accounts for it correctly. Applied to the four RMW expiration paths and the Delete path.4. Keep reused allocations above the checkpoint's fuzzy-region start
The
IsInV1clamp onminRevivAddressonly covered the free-record pool. An allocation saved before the version shift and retried afterCPR_SHIFT_DETECTEDcould place a (v+1) record below the fuzzy start, where the snapshot includes it regardless of its version bit, leaking (v+1) data into the checkpoint image. The clamp now covers both reuse paths.5. Elide the source record on Delete
CreateNewRecordDeletealready passedelideSourceRecordto the allocator, which relaxesminRevivAddresson the premise that the source leaves the tag chain — but it never actually elided it, so that relaxed bound was unsound. The tombstone now carries the source'sPreviousAddress, and the source is invalidated and freelisted, asCreateNewRecordUpsertandCreateNewRecordRMWdo.Unlike those two, Delete fires
OnDispose(Deleted)before eliding. Garnet keys range-index data-file cleanup and Vector Set deletion off that reason, so a literal copy of the Upsert path would have silently skipped both. The subsequent elide/freelist disposal only accounts for the key, so nothing is counted twice.6. State machine documentation
Adds
website/docs/dev/tsavorite/state-machine.md, describing the checkpoint state machine top-down: defining and launching a machine, each transition, the transition-in/transition-out handles, and the waiting list, including two current limitations. Wired into the sidebar and linked from the Tsavorite intro. The accompanyingStateMachineDriver.cschanges are comments only — no behavior change.Tests
#2101 reproduction —
CheckpointSharedObjectMutationTests.SupersededObjectIsNotSerializedFromLiveStateAfterCleanup.This is the test that demonstrates the reported failure. It drives the production path end to end: a Snapshot
checkpoint is paused on entry to
WAIT_FLUSHviaIStateMachineCallback, a realRMWissued in that window isforced by CPR through
InternalRMW→CreateNewRecordRMW→CacheSerializedObjectData, and the value'sClone()is a shallow copy sharing the source's
List, exactly as Garnet's collection objects do. Post-checkpointcleanup then runs, the surviving record mutates the shared list, and the superseded object is serialized again
with a second mutation landing mid-enumeration. Against the pre-fix allocator it fails with the issue's own
exception, raised from the same direct-serialize path:
Serialization-phase unit tests —
ObjectLogScanTests.CleanupOfCachedDataLeavesObjectTerminal— asserts the phase invariant directly: after cleanup releases thecached bytes, the object must stay terminal and
Serialize()must write the null indicator. It callsCacheSerializedObjectDatadirectly and its test object holds no collection, so it pins the invariant cheaplybut does not reproduce Checkpoint fails with
InvalidOperationExceptioninListObject.DoSerializeunder concurrent LIST mutation, then never completes ("checkpoint already in progress") #2101; that is what the test above is for.FailedSerializationRestoresRestPhase— a throwingDoSerializeon the direct path must leave the object inREST.FailedCachedSerializationRestoresRestPhase— the same guarantee for the CopyUpdate capture path, which has itsown
catch: the exception is rethrown,RESTis restored, and no partial capture is published.All four fail against the exact pre-fix code with their intended diagnostics.
RecordLifecycleTests.PageEvictionFiresOnEvictForEveryLiveRecordwas updated: it assumed delete sources stay inthe tag chain and are visited by eviction, and now derives the expected eviction count from the number of sources
actually elided.
Status
HeapObjectBasephase lifecycle)main, conflict-freeRemaining work items
Two review comments are accurate and are not closed by this PR. Both paths are guarded by
Ctx.IsInV1, sothey are inert outside a checkpoint, and neither is reached by existing lifecycle tests (which shift read-only
while in
REST):BlockAllocate.csfuzzy-start clamp has no targeted test. Its failure mode is an allocation saved beforethe version shift, retried after
CPR_SHIFT_DETECTED, and reused belowstartLogicalAddress. Reaching itrequires forcing a retry across a version boundary; the
PauseAtPhaseharness added here makes that feasible.Helpers.OnDisposeSupersededSourcefrozen branch is unexercised. Note that the new end-to-end test doesnot cover it: that helper is called only from the expiration paths, so a non-expiring RMW during a
checkpoint never reaches it. A test needs an RMW returning
ExpireAndStop/ExpireAndResumeagainst a frozensource while a checkpoint is in the fuzzy region.
Neither is a correctness regression; both are coverage gaps on code this PR adds. Flagging rather than silently
assuming the new checkpoint test covers them.
A third review comment — that removing
DeleteExpiredItems()from HTTL leaks memory without bound — wasinvestigated and rebutted; see the analysis comment.
In short, a field only becomes expirable through
HEXPIRE/HPEXPIRE, which is an RMW that purges first, so theunpurged set cannot exceed the fields carrying a TTL at the last write and a read-only workload cannot grow it.
The bounded residual matches
SortedSetTimeToLive, which has never purged.Validation
Tsavorite: recovery 240, root 342, recordops 220, hlog 593, session 155, session.context 133, epoch 15.
Garnet: collections 780, rangeindex 62, vectorset 392.
dotnet format --verify-no-changesclean on both solutions.End-to-end with the reporter's repro (revivification plus delete/recreate churn): 88/88
BGSAVEs completed, noexceptions in the server log, no stall, over 2.5M mutations. The same workload reproduced the failure in roughly
55 seconds before the fix.
Checkpoint-only recovery with AOF disabled: 10 lists / 176,212 entries byte-identical before and after recovery.