Skip to content

Fix live-object mutation and scan liveness defects (#2092) - #2095

Open
Badrish Chandramouli (badrishc) wants to merge 5 commits into
mainfrom
badrishc/fix-live-object-mutation-and-scan-liveness
Open

Fix live-object mutation and scan liveness defects (#2092)#2095
Badrish Chandramouli (badrishc) wants to merge 5 commits into
mainfrom
badrishc/fix-live-object-mutation-and-scan-liveness

Conversation

@badrishc

@badrishc Badrish Chandramouli (badrishc) commented Aug 31, 2026

Copy link
Copy Markdown
Collaborator

Fixes #2092

The reported symptom

List keys intermittently and permanently became Strings holding a 4-byte 0xFF FF FF FF payload. Every subsequent write returned WRONGTYPE, and the condition survived restart.

Root cause

StorageSession.ListMove called GET to obtain the IGarnetObject and then mutated the returned heap object in place:

srcListObject.LnkList.RemoveLast();

That is only legal when the record is in the mutable region (>= ReadOnlyAddress). Otherwise it races the object log writer and throws InvalidOperationException: Collection was modified inside ObjectAllocatorImpl.WriteAsync. The page write is never issued, FlushedUntilAddress stalls, and the log wedges.

The WRONGTYPE is the aftermath, not the bug. On restart with recovery, the newest persisted record for the key is an older CopyUpdate source that was legitimately cleared to an inline ObjectIdMap.InvalidObjectId — four 0xFF bytes, which read back as a perfectly valid String.

Repro (standalone server + concurrent LMOVE stress):

ops completed net errors flush exceptions
before 13,141 285 yes
after 423,467 0 0

Audit for the same anti-pattern

Per review feedback the audit covered every object operation. The pattern — GET an object, then mutate its collection directly — appeared in exactly three places:

Site Commands Fix
StorageSession.ListMove LMOVE, RLMOVE route pop/push through the existing ListPop/ListPush RMW helpers
StorageSession.SetMove SMOVE route through RMW
CollectionItemBroker all blocking collection commands replaced 3 live-mutating helpers with TryGetNextListResult / TryGetNextSortedSetResult

Four further defects surfaced while fixing those:

  • SMOVE destroyed the member when the destination held a wrong type. Now returns WRONGTYPE.
  • Same-key singleton LMOVE/BLMOVE discarded the key TTL. The no-op fast path now also covers Count == 1.
  • BLMOVE src dst never woke a client blocked on dst. Reproduced on unmodified main, so this is pre-existing. The broker event is now enqueued after txnManager.Commit(true), which also avoids re-entering keysToObserversLock.
  • A collection update served only one blocked waiter. TryAssignItemFromKey returned immediately after the first HandleSetResult, so one event woke one observer regardless of how many items were available. Pre-existing on main. With three clients blocked, LPUSH k a b c woke 1 of 3 and stranded two items, and a no-op BLMOVE k k LEFT LEFT woke 1 of 3 with the item still present; Redis serves all three in both cases. The observer queue is now drained until the collection can no longer serve the observer at the head. That observer is left queued rather than skipped, which keeps the queue FIFO and removes a latent infinite loop in the old if (currCount > 0) continue; path, which re-peeked the same undequeued observer forever.
  • SortedSetObject / HashObject mutated expirationTimes during DoSerialize. Serialization must be a pure read: the CAS state machine in HeapObjectBase.Serialize coordinates serializer-vs-serializer and serializer-vs-CopyUpdate only — plain readers never touch SerializationPhase, and ObjectLogWriter.DoSerialize takes no record lock. Confirmed empirically by instrumenting DoSerialize under ZEXPIRE load on a tiered-storage server. DoSerialize is now two-pass and non-mutating. Both passes share one now and one snapshot of the expirationTimes reference, because the element count is written before the entries and the writer is forward-only, so it cannot be back-patched. Emitted bytes are byte-identical.

Separate defect: KEYS listed deleted keys

ScanLookup's dedup step (ConditionalScanPush) is a recency test — "is a newer version of this key reachable from the tag chain below maxAddress?" — not a liveness test.

Deletion normally hides a key via two mechanisms, and elision defeats both:

SET key v0     -> A                             chain: HB -> A
EXPIRE key ttl -> RCU writes B, elides A        chain: HB -> B
                  A = SealAndInvalidate  (Invalid, NOT a tombstone)
DEL key        -> B.SetTombstone(); B elided    chain: HB -> (empty)

The iterator walks raw log bytes, not the chain, so both records are still visited. B is caught by the existing tombstone filter. A is not a tombstone, so it reaches ConditionalScanPush, FindTag hits an empty chain, nothing newer is found — and the stale record is pushed. The tombstone that was supposed to suppress A is exactly what elision removed.

The fix, and why this specific predicate

CanElide requires the record to be the sole member of its tag chain:

stackCtx.hei.Address == stackCtx.recSrc.LogicalAddress
    && srcRecordInfo.PreviousAddress < hlogBase.BeginAddress

and TryElide then CASes the bucket slot to kInvalidAddress. So an elided chain is exactly an empty slot, and FindTag failing is an exact test for "this key is unreachable." OperationStatus.NOTFOUND is set only on that path (TryFindRecordInMainLogForPendingOperation only ever sets SUCCESS or RETRY_LATER), so it is an unambiguous discriminator.

The whole change is 3 lines in one file:

if (internalStatus == OperationStatus.NOTFOUND)
    return Status.CreateFound();

This keys off reachability of the key, not of the record — which is what makes it safe for point-in-time consumers. If the key was re-inserted above maxAddress, as during slot migration, the chain is non-empty and the record is still pushed.

Rejected alternative — filtering on RecordInfo.Invalid. That is a per-record test. Invalid marks the elided record while the key may remain live at a higher address, so it silently drops keys during slot migration. ClusterMigrateCustomProcDelRMW catches this: DELRMW elides record A and writes B above the captured scan tail, the key never enters the migration sketch, and it is lost. Because it keys off the key instead, the predicate here needs no opt-in flag and fixes the same defect for the other bounded-maxAddress callers (DeleteSlotKeys, HasKeysInSlots, VectorManager.Cleanup, streaming snapshot).

Tests

Every test below was verified to fail without its corresponding fix.

  • RespListTests / RespSetTest — object-mutation and TTL regressions
  • RespBlockingCollectionTests — broker correctness and the BLMOVE destination-notification gap
  • GarnetObjectTests — non-mutating serialization
  • RespScanCommandsTests — 3 KEYS regressions, asserting KEYS + SCAN + DBSIZE + EXISTS + GET under Assert.Multiple so no single view masks another
  • SpanByteIterationTests.SpanByteIterateLookupSnapshotEmitsKeyRcudAboveSnapshotTail — locks the guarantee that an RCU landing above the captured tail does not lose the key

Four test assertions that had encoded the old EXPIRE(key, TimeSpan.Zero)-on-empty behavior (which left a phantom record) were updated.

Validation

Suite Result
Garnet.test 1049 / 0
Garnet.test.collections 775 / 0
Garnet.test.cluster 156 / 0
Garnet.test.cluster.migrate 56 / 0
Tsavorite: base / hlog / recovery / recordops / session / session.context / epoch 290 / 560 / 204 / 220 / 155 / 124 / 15 — all 0 failures
dotnet build Garnet.slnx 0 errors, 0 warnings
dotnet format (Garnet + Tsavorite) clean

Run on net10.0 Debug.

Notes for reviewers

  • The Tsavorite change is 12 lines in AllocatorScan.cs (9 of them comment) with no public API change.
  • ConditionalCopyToTail (compaction) is deliberately untouched; only ConditionalScanPush is affected.
  • Latent issues found but not fixed here, to keep the diff scoped — happy to file follow-ups: DEL on an already-expired key returns 1 where Redis returns 0; SMOVE key key member returns 0 where Redis returns 1 (currently encoded in our own tests); SortedSetIntersection returns the live Dictionary via out for the single-key/no-weights case; the public mutable collection properties (ListObject.LnkList, SetObject.Set, SortedSetObject.Dictionary) would be better as read-only views; and eight redundant EXPIRE(key, TimeSpan.Zero) delete-on-empty hacks remain.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Fixes live-object mutation races in collection commands, preserves serialization safety, and prevents scans from returning deleted keys.

Changes:

  • Routes list, set, and blocking collection mutations through RMW operations.
  • Makes hash and sorted-set serialization non-mutating.
  • Corrects scan liveness detection and adds regression coverage.

Reviewed changes

Copilot reviewed 12 out of 12 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
libs/server/Storage/Session/ObjectStore/ListOps.cs Uses RMW for list moves.
libs/server/Storage/Session/ObjectStore/SetOps.cs Uses RMW for set moves.
libs/server/Objects/ItemBroker/CollectionItemBroker.cs Uses RMW for blocking operations and adds destination notifications.
libs/server/Objects/Hash/HashObject.cs Serializes expired fields without mutation.
libs/server/Objects/SortedSet/SortedSetObject.cs Serializes expired members without mutation.
libs/storage/Tsavorite/cs/src/core/Allocator/AllocatorScan.cs Filters unreachable records during scans.
libs/storage/Tsavorite/cs/test/test.hlog/SpanByteIterationTests.cs Covers bounded-scan RCU behavior.
test/standalone/Garnet.test/RespScanCommandsTests.cs Covers deleted-key scan regressions.
test/standalone/Garnet.test.collections/RespListTests.cs Covers LMOVE persistence and TTL behavior.
test/standalone/Garnet.test.collections/RespSetTest.cs Covers SMOVE persistence and wrong types.
test/standalone/Garnet.test.collections/RespBlockingCollectionTests.cs Covers blocking-operation persistence and notifications.
test/standalone/Garnet.test.collections/GarnetObjectTests.cs Verifies serialization does not mutate objects.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread libs/server/Objects/ItemBroker/CollectionItemBroker.cs
@badrishc

Copy link
Copy Markdown
Collaborator Author

Addressed the review comment, plus a test-quality fix it surfaced.

1. CollectionItemBroker — one waiter served per update event (b380605)

Root-caused past the reported symptom. TryAssignItemFromKey is unchanged from main in this PR and return trues after the first HandleSetResult, so one collection-update event served one observer regardless of how many items were available. Measured on the pre-fix build with three clients blocked per key:

scenario trigger before after
BLPOP k ×3 LPUSH k a b c 1 / 3, two items stranded 3 / 3
BLMOVE k k LEFT LEFT ×3 LPUSH k v1 1 / 3, item still present 3 / 3

The first row does not involve the flagged fast path at all, so notifying from that path would have fixed only half the defect. The observer queue is now drained until the collection can no longer serve the observer at the head; that observer is left queued rather than skipped, keeping the queue FIFO and removing a latent infinite loop in the old if (currCount > 0) continue; path, which re-peeked the same undequeued observer forever.

New tests, all three verified failing on the pre-fix build: BlockingPopServesAllWaitersFromSingleMultiItemPush (BLPOP, BRPOP) and BlockingListMoveSameKeyServesAllWaiters.

2. Flaky serialization tests (2000817e)

SerializeDoesNotMutateSortedSetWithExpiredMembers failed on windows-latest/net8.0/Debug. My bug, and a real test defect rather than a bad flake: the member got a 200 ms TTL when its bytes were built, but the deserializing constructor drops members that are already expired (SortedSetObject.cs:185), so a stalled runner produced an object with nothing expirable. The object is now built inside a retry that widens the window until the member survives construction, then waits on the member's own expiration timestamp instead of a fixed sleep. Verified by forcing attempt 0 to miss, which reproduces the CI failure exactly and now recovers. HashObject.HasExpirableItems became internal so the hash test uses the same signal, which also lets it assert that serialization leaves the expiration structures intact.

Status: 226 checks passing, 0 failing.

One unrelated job, Garnet.test.cluster.replication.tls on ubuntu/net10.0/Release, timed out in ValidateNodeObjects -> BackOff waiting for replica convergence, with a different test each occurrence; it passed on re-run. Confirmed not caused by this PR: the full TLS suite passes locally in that exact configuration (103/0), and every change made since the last all-green run is unreachable from it — TryAssignItemFromKey requires a registered observer and cluster.replication.tls issues no blocking commands, and the HasExpirableItems visibility change is not behavioral. For the same reason the earlier DoSerialize change is not implicated: its extra pass is skipped when expirationTimes is null, which is the case for the objects these tests build, and ConditionalScanPush is reachable only from ScanCursor, not from checkpointing or replication.

Comment thread libs/server/Objects/Hash/HashObject.cs
Comment thread libs/storage/Tsavorite/cs/src/core/Allocator/AllocatorScan.cs
@badrishc
Badrish Chandramouli (badrishc) marked this pull request as ready for review September 8, 2026 15:12
Badrish Chandramouli (badrishc) added a commit that referenced this pull request Sep 8, 2026
Address review feedback on #2095.

Comment the expiration state on HashObject and SortedSetObject -- the two
object types that carry expirationTimes/expirationQueue -- describing what
each structure holds and how they interact: they are allocated and torn down
together, expirationTimes is the source of truth, and expirationQueue entries
are ordering hints that may go stale because PriorityQueue cannot update a
priority in place.

Assert in ConditionalScanPush that a record whose key has an empty HashBucket
is Invalid. Closed records only reach this path when the iterator was created
with includeClosedRecords, which ScanCursor does when maxAddress is bounded.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@badrishc

Copy link
Copy Markdown
Collaborator Author

CI status note for a7316af: the run shows red, but every Run tests step passes. The remaining failures are all the Upload test results step returning Failed to FinalizeArtifact: (403) Forbidden from GitHub's artifact service -- an infrastructure error, not a test result. It hit unrelated suites too (including Release-only Tsavorite epoch tests).

The first attempt did have one genuine test failure, ClusterManagementTests.PrimaryUnavailableRecoveryAsync(Aof_Sync_Task_Consume,True), which passed on rerun. It's a pre-existing flake, not related to this PR:

  • The same test fails identically on main without these changes -- run 33837610174, job Garnet Cluster (windows-latest, net10.0, Debug, Garnet.test.cluster), same Cancellation Requested error and same ClusterTestUtils.BackOffAsync:774 stack, differing only in the parameterization.
  • It times out in SimpleSetupCluster -> WaitForSyncAsync, i.e. during gossip convergence at cluster formation, before any data operation runs -- nothing this PR touches.
  • All 6 parameterizations pass locally in Debug.

Local Debug validation for this commit: Garnet.test 1049/0, Garnet.test.collections 778/0, Garnet.test.cluster 156/0, Garnet.test.cluster.migrate 56/0, Tsavorite.test.hlog 560/0, plus dotnet format --verify-no-changes clean on both solutions.

Badrish Chandramouli (badrishc) added a commit that referenced this pull request Sep 8, 2026
Address review feedback on #2095.

Comment the expiration state on HashObject and SortedSetObject -- the two
object types that carry expirationTimes/expirationQueue -- describing what
each structure holds and how they interact: they are allocated and torn down
together, expirationTimes is the source of truth, and expirationQueue entries
are ordering hints that may go stale because PriorityQueue cannot update a
priority in place.

Assert in ConditionalScanPush that a record whose key has an empty HashBucket
is Invalid. Closed records only reach this path when the iterator was created
with includeClosedRecords, which ScanCursor does when maxAddress is bounded.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@badrishc
Badrish Chandramouli (badrishc) force-pushed the badrishc/fix-live-object-mutation-and-scan-liveness branch from a7316af to 8bc6db7 Compare September 8, 2026 21:34
Badrish Chandramouli (badrishc) added a commit that referenced this pull request Sep 8, 2026
Address review feedback on #2095.

Comment the expiration state on HashObject and SortedSetObject -- the two
object types that carry expirationTimes/expirationQueue -- describing what
each structure holds and how they interact: they are allocated and torn down
together, expirationTimes is the source of truth, and expirationQueue entries
are ordering hints that may go stale because PriorityQueue cannot update a
priority in place.

Assert in ConditionalScanPush that a record whose key has an empty HashBucket
is Invalid. Closed records only reach this path when the iterator was created
with includeClosedRecords, which ScanCursor does when maxAddress is bounded.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@badrishc
Badrish Chandramouli (badrishc) force-pushed the badrishc/fix-live-object-mutation-and-scan-liveness branch from 8bc6db7 to 6fdd4e6 Compare September 8, 2026 21:52
@badrishc

Copy link
Copy Markdown
Collaborator Author

Followed up on the Tsavorite.test.hlog flake I earlier attributed to pre-existing noise, and root-caused it instead. Fixed in 113d7e3.

CommitRecordBoundedGrowthTest -> TsavoriteException: Error reading page 0 from device

The test builds its TsavoriteLog without setting TryRecoverLatest. That defaults to true, and TsavoriteLog(logSettings, logger) forwards it as syncRecover, so the constructor runs a synchronous recovery -- even though this test always starts from an empty log and never intends to recover.

TestUtils.MethodTestDir is keyed on TestContext.CurrentContext.Test.MethodName only, with no parameters, so all parameterizations of this test share one directory. A commit written late by the previous parameterization can land there after BaseSetup's DeleteDirectory has cleaned it. Recovery then finds real commit metadata (info.UntilAddress > 0, so it does not take the "Unable to recover using any available commit" early return) and replays it onto this run's fresh, empty LocalMemory device, which fails reading page 0.

The fix sets TryRecoverLatest = false, matching the fixture's other two tests, which already disable recovery. This is a deterministic elimination rather than a timing tweak: with syncRecover false the constructor never calls RecoverAsync, so the entire failing stack is unreachable.

Evidence: over 60 runs of the test's parameter set, 2 failures before the change and 0 after (control isolated to just this line), plus 5 consecutive clean full-suite runs (580 passed each).

Separately, while chasing this I found my earlier post-rebase hlog numbers were run against a stale binary -- test.hlog is not part of Garnet.slnx, so dotnet build Garnet.slnx doesn't rebuild it and my --no-build runs used pre-rebase test code. Re-validated with freshly built binaries: Tsavorite.test.hlog 580/0, Tsavorite.test.recovery 204/0, Tsavorite.test 290/0.

Keys intermittently and permanently became Strings holding a 4-byte
0xFF FF FF FF payload, after which every write returned WRONGTYPE.

Root cause: StorageSession.ListMove called GET to obtain the IGarnetObject
and then mutated the returned heap object in place. That is only legal when
the record is in the mutable region; otherwise it races the object log
writer, which throws "Collection was modified" inside WriteAsync. The page
write is then never issued, FlushedUntilAddress stalls, and the log wedges.
On restart the newest persisted record is an older CopyUpdate source that
was legitimately cleared to an inline ObjectIdMap.InvalidObjectId, which
reads back as a valid String -- hence the permanent WRONGTYPE.

Auditing every object operation found the same anti-pattern in exactly two
other places, plus four further defects:

- ListMove (LMOVE/RLMOVE), SetMove (SMOVE) and CollectionItemBroker (all
  blocking collection commands) mutated live heap objects. All three now
  route through RMW.
- SetMove destroyed the member when the destination held a wrong type;
  it now returns WRONGTYPE.
- Same-key singleton LMOVE/BLMOVE discarded the key TTL. The no-op fast
  path now also covers Count == 1.
- BLMOVE did not wake a client blocked on the destination key. The broker
  event is now enqueued after txnManager.Commit, keeping it lock-free.
- SortedSetObject and HashObject mutated their expirationTimes collection
  during DoSerialize, so a concurrent reader could observe a torn
  structure. DoSerialize is now a two-pass pure read. Both passes share
  one timestamp and one snapshot of the expirationTimes reference, since
  the element count is written before the entries and cannot be
  back-patched. Emitted bytes are unchanged.

Separately, KEYS could list a deleted key. ScanLookup's dedup step
(ConditionalScanPush) is a recency test -- "is a newer version of this key
reachable from the tag chain below maxAddress?" -- not a liveness test. A
key whose chain has been fully elided has no chain left to search, so the
test correctly finds nothing newer and pushes a record that is still
physically present in the log. Deletion normally hides a key via a
tombstone sitting above the old record, but elision removes precisely that
tombstone.

CanElide requires the record to be the sole member of its tag chain
(hei.Address == recSrc.LogicalAddress and PreviousAddress < BeginAddress),
and TryElide then CASes the bucket slot to kInvalidAddress. So an elided
chain is exactly an empty slot, and FindTag failing (OperationStatus.
NOTFOUND, which is set only on that path) is an exact test for "this key is
unreachable". ConditionalScanPush now skips those records.

This keys off the reachability of the key rather than of the record, so it
is safe for point-in-time consumers: if the key was re-inserted above
maxAddress, as during slot migration, the chain is non-empty and the record
is still pushed. Filtering on RecordInfo.Invalid instead would be a
per-record test and silently drops keys during slot migration, since
Invalid marks the elided record while the key remains live at a higher
address.

Tests added, each verified to fail without the corresponding fix.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
A collection-update event served at most one blocked observer, even when
the collection still held items for the waiters behind it. Pushing three
elements in one LPUSH woke a single BLPOP client and stranded the other
two along with two available items, and a no-op same-key BLMOVE woke one
client while leaving the item in place for the rest.

Continue draining the observer queue after a successful assignment,
stopping when the collection can no longer serve the observer at the
head. That observer is left queued rather than skipped, so the queue
stays FIFO and every iteration either dequeues an observer or breaks.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The expiring member was given a 200 ms TTL when its bytes were built, but
the deserializing constructor drops members that have already expired. A
stalled CI machine could take longer than that to reach the constructor,
leaving an object with no expirable items and failing the test. It failed
this way on windows-latest/net8.0/Debug.

Build the object inside a retry that widens the window until the expiring
member survives construction, then wait for the member's own expiration
timestamp instead of sleeping a fixed amount.

HashObject.HasExpirableItems becomes internal so the hash test can use the
same signal as the sorted set one, which also lets it assert that
serialization leaves the expiration structures intact.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Address review feedback on #2095.

Comment the expiration state on HashObject and SortedSetObject -- the two
object types that carry expirationTimes/expirationQueue -- describing what
each structure holds and how they interact: they are allocated and torn down
together, expirationTimes is the source of truth, and expirationQueue entries
are ordering hints that may go stale because PriorityQueue cannot update a
priority in place.

Assert in ConditionalScanPush that a record whose key has an empty HashBucket
is Invalid. Closed records only reach this path when the iterator was created
with includeClosedRecords, which ScanCursor does when maxAddress is bounded.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
CommitRecordBoundedGrowthTest built its TsavoriteLog without setting
TryRecoverLatest, which defaults to true, so the constructor ran a synchronous
recovery. The test always starts from an empty log and never intends to
recover.

MethodTestDir is keyed only on the test method name, so every parameterization
of this test shares one directory. A commit written late by the previous
parameterization can land there after BaseSetup has cleaned it, and recovering
that commit onto this run's fresh, empty device fails with "Error reading page
0 from device".

Setting TryRecoverLatest = false makes syncRecover false, so the constructor
never calls RecoverAsync and the failing path is unreachable. This matches the
other two tests in the fixture, which already disable recovery.

Measured on 60 runs of the test's parameter set: 2 failures before, 0 after,
plus 5 clean full-suite runs.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@badrishc
Badrish Chandramouli (badrishc) force-pushed the badrishc/fix-live-object-mutation-and-scan-liveness branch from 113d7e3 to 6e83d6e Compare September 9, 2026 17: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.

List key corrupts to a garbage String value (WRONGTYPE) after a connection drop

3 participants