Skip to content

Commit 61089a1

Browse files
badrishcCopilot
andcommitted
refactor: move tombstone after OnDispose so triggers see pre-tombstone state
Session functions (ISessionFunctions) should not be responsible for heap-size tracking on destruction paths — that's IRecordTriggers' job. Two places were setting Tombstone BEFORE OnDispose(Deleted) fired, causing CalculateHeapMemorySize to return 0 and preventing the trigger from decrementing the tracked heap: 1. SessionFunctionsWrapper.InPlaceDeleter: set Tombstone+Dirty before returning to InternalDelete, which then called OnDispose(Deleted). Fixed by moving SetTombstone+SetDirtyAndModified to InternalDelete after OnDispose. 2. SessionFunctionsWrapper.InPlaceUpdater (ExpireAndStop): set Tombstone before returning to InternalRMW, which then called OnDispose(Deleted). Fixed by moving SetTombstone+SetDirtyAndModified to InternalRMW after OnDispose. This removes the manual cacheSizeTracker decrement from MainStore.InPlaceDeleter (it was a workaround for the wrong ordering) and makes GarnetRecordTriggers.OnDispose the single destruction-side decrement path for all record types. Also tightened PageEvictionFiresOnEvictForEveryLiveRecord test to use precise counts by observing TailAddress movement to distinguish mutable vs immutable deletes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 0358eea commit 61089a1

5 files changed

Lines changed: 40 additions & 43 deletions

File tree

libs/server/Storage/Functions/MainStore/DeleteMethods.cs

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -28,14 +28,6 @@ public void PostInitialDeleter(ref LogRecord logRecord, ref DeleteInfo deleteInf
2828
/// <inheritdoc />
2929
public bool InPlaceDeleter(ref LogRecord logRecord, ref DeleteInfo deleteInfo)
3030
{
31-
// Decrement here — before the wrapper sets Tombstone. After Tombstone is set,
32-
// CalculateHeapMemorySize short-circuits to zero and EvictRecordsInRange skips
33-
// the record, so any matching decrement would be lost and the creation-side
34-
// increments in PostInitialUpdater/PostInitialWriter/InPlaceUpdater would leak.
35-
var heap = logRecord.CalculateHeapMemorySize();
36-
if (heap != 0)
37-
functionsState.cacheSizeTracker?.AddHeapSize(-heap);
38-
3931
logRecord.ClearOptionals();
4032
if (!logRecord.Info.Modified)
4133
functionsState.watchVersionMap.IncrementVersion(deleteInfo.KeyHash);

libs/storage/Tsavorite/cs/src/core/ClientSession/SessionFunctionsWrapper.cs

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -193,12 +193,14 @@ public bool InPlaceUpdater(ref LogRecord logRecord, in RecordSizeInfo sizeInfo,
193193
}
194194
else if (rmwInfo.Action == RMWAction.ExpireAndStop)
195195
{
196-
logRecord.InfoRef.SetTombstone();
196+
// Tombstone is set by the caller (InternalRMW) AFTER OnDispose(Deleted),
197+
// so that CalculateHeapMemorySize sees the pre-tombstone state.
197198
status = OperationStatusUtils.AdvancedOpCode(OperationStatus.SUCCESS, StatusCode.InPlaceUpdatedRecord | StatusCode.Expired);
198199
}
199200
else if (rmwInfo.Action == RMWAction.WrongType)
200201
{
201-
logRecord.InfoRef.SetTombstone();
202+
// WrongType means the operation was rejected — the record must NOT be modified.
203+
// Do not set Tombstone; the key should remain intact for correct-type operations.
202204
status = OperationStatusUtils.AdvancedOpCode(OperationStatus.NOTFOUND, StatusCode.WrongType);
203205
}
204206
else
@@ -238,8 +240,9 @@ public bool InPlaceDeleter(ref LogRecord logRecord, ref DeleteInfo deleteInfo)
238240
{
239241
if (!_clientSession.functions.InPlaceDeleter(ref logRecord, ref deleteInfo))
240242
return false;
241-
logRecord.InfoRef.SetTombstone();
242-
logRecord.InfoRef.SetDirtyAndModified();
243+
// Tombstone and Dirty/Modified are set by the caller (InternalDelete) AFTER
244+
// OnDispose(Deleted), so that CalculateHeapMemorySize sees the pre-tombstone
245+
// state and IRecordTriggers can correctly account for the full heap contribution.
243246
return true;
244247
}
245248

libs/storage/Tsavorite/cs/src/core/Index/Tsavorite/Implementation/InternalDelete.cs

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -124,10 +124,13 @@ internal OperationStatus InternalDelete<TKey, TInput, TOutput, TContext, TSessio
124124
// DeleteInfo's lengths are filled in and GetRecordLengths and SetDeletedValueLength are called inside InPlaceDeleter.
125125
if (sessionFunctions.InPlaceDeleter(ref srcLogRecord, ref deleteInfo))
126126
{
127-
// Immediately dispose all resources: app callback (storeFunctions.OnDispose)
128-
// + ClearHeapFields + ClearOptionals — all via hlog.OnDispose.
127+
// Dispose all resources BEFORE setting Tombstone, so that
128+
// IRecordTriggers.OnDispose sees the pre-tombstone state and
129+
// CalculateHeapMemorySize returns the full heap contribution.
129130
OnDispose(ref srcLogRecord, DisposeReason.Deleted);
130131

132+
srcLogRecord.InfoRef.SetTombstone();
133+
srcLogRecord.InfoRef.SetDirtyAndModified();
131134
MarkPage(stackCtx.recSrc.LogicalAddress, sessionFunctions.Ctx);
132135

133136
// Try to transfer the record from the tag chain to the free record pool iff previous address points to invalid address.

libs/storage/Tsavorite/cs/src/core/Index/Tsavorite/Implementation/InternalRMW.cs

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -156,18 +156,18 @@ internal OperationStatus InternalRMW<TKey, TInput, TOutput, TContext, TSessionFu
156156
if (rmwInfo.Action == RMWAction.ExpireAndStop)
157157
{
158158
MarkPage(stackCtx.recSrc.LogicalAddress, sessionFunctions.Ctx);
159-
// Tombstone has been set by SessionFunctionsWrapper.InPlaceUpdater
160-
srcLogRecord.InfoRef.SetDirtyAndModified();
161159

162160
pendingContext.logicalAddress = stackCtx.recSrc.LogicalAddress;
163161
pendingContext.eTag = srcLogRecord.ETag;
164162

165-
// ExpireAndStop means to override default Delete handling (which is to go to InitialUpdater) by leaving the tombstoned record as current.
166-
// Our SessionFunctionsWrapper.InPlaceUpdater implementation has already reinitialized-in-place or set Tombstone as appropriate and marked the record.
167-
168-
// Immediately dispose all resources at delete site.
163+
// Dispose all resources BEFORE setting Tombstone, so that
164+
// IRecordTriggers.OnDispose sees the pre-tombstone state and
165+
// CalculateHeapMemorySize returns the full heap contribution.
169166
OnDispose(ref srcLogRecord, DisposeReason.Deleted);
170167

168+
srcLogRecord.InfoRef.SetTombstone();
169+
srcLogRecord.InfoRef.SetDirtyAndModified();
170+
171171
// Try to transfer the record from the tag chain to the free record pool iff previous address points to invalid address.
172172
// Otherwise an earlier record for this key could be reachable again.
173173
if (CanElide<TInput, TOutput, TContext, TSessionFunctionsWrapper>(sessionFunctions, ref stackCtx, srcLogRecord.Info))

libs/storage/Tsavorite/cs/test/RecordLifecycleTests.cs

Lines changed: 22 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -459,53 +459,52 @@ public void PendingReadFromDiskFiresOnDisposeDiskRecordOnce()
459459
/// <summary>
460460
/// Filling the log well beyond its mutable window forces page eviction. OnEvict must fire for
461461
/// every non-tombstoned, non-invalid record evicted past HeadAddress — including sealed source
462-
/// records from immutable-region deletes or CopyUpdates. Tombstoned records are skipped (heap
463-
/// was decremented at the delete site). Invalid/elided records are skipped (heap was already
464-
/// cleaned up). Sealed records are visited because they may still own overflow key/value bytes
465-
/// or value objects (checkpoint path).
462+
/// records from immutable-region deletes. Tombstoned records are skipped (heap was decremented
463+
/// at the delete site). Invalid/elided records are skipped (already cleaned up).
466464
/// </summary>
467465
[Test, Category("TsavoriteKV")]
468466
public void PageEvictionFiresOnEvictForEveryLiveRecord()
469467
{
470468
const int n = 500; // enough to push pages past HeadAddress given 32KB memory / 1KB pages
471469
for (int i = 0; i < n; i++) Upsert(i, i);
472470

473-
// Delete ~10% of records to create tombstones. OnDispose(Deleted) fires for each.
471+
// Delete ~10% of records. Track which go through the immutable path (TailAddress moves)
472+
// vs in-place mutable path (TailAddress stays).
474473
tracker.Reset();
475474
const int deleted = 50;
475+
int mutableDeletes = 0;
476476
using (var s = NewSession())
477477
{
478478
for (int i = 0; i < deleted; i++)
479+
{
480+
var tailBefore = store.Log.TailAddress;
479481
_ = s.BasicContext.Delete(new TestObjectKey { key = i });
482+
if (store.Log.TailAddress == tailBefore)
483+
mutableDeletes++;
484+
}
480485
}
486+
var immutableDeletes = deleted - mutableDeletes;
487+
481488
ClassicAssert.AreEqual(deleted, tracker.DisposeCount(DisposeReason.Deleted),
482489
"Each Delete should fire OnDispose(Deleted) exactly once");
483490
var deletedDisposeCountBeforeEvict = tracker.DisposeCount(DisposeReason.Deleted);
484491

485-
// Force all records out to disk. This triggers OnEvict for every non-tombstoned,
486-
// non-invalid record on each evicted page.
492+
// Force all records out to disk.
487493
store.Log.FlushAndEvict(wait: true);
488494

489-
// OnEvict fires for: (1) the n live records that were never deleted, plus (2) sealed
490-
// source records from immutable-region deletes (these are not tombstoned — the tombstone
491-
// is on a NEW record at the tail). In-place (mutable) deletes set tombstone directly on
492-
// the source and are skipped. The exact split depends on the mutable-vs-readonly boundary
493-
// at delete time, so we assert bounds rather than an exact count:
494-
// - Lower bound: at least n - deleted (the live records)
495-
// - Upper bound: at most n (if all deletes hit the readonly region, producing sealed sources)
496-
var mainLogEvicts = tracker.EvictCount(EvictionSource.MainLog);
497-
ClassicAssert.GreaterOrEqual(mainLogEvicts, n - deleted,
498-
$"OnEvict(MainLog) must fire for at least the {n - deleted} non-deleted records");
499-
ClassicAssert.LessOrEqual(mainLogEvicts, n,
500-
$"OnEvict(MainLog) must not fire more than {n} times (n original + sealed sources, minus tombstones)");
501-
495+
// Precise count:
496+
// - (n - deleted) live records: visited by OnEvict.
497+
// - mutableDeletes records: tombstoned in-place, skipped by OnEvict.
498+
// - immutableDeletes sealed source records: NOT tombstoned, visited by OnEvict.
499+
// - immutableDeletes new tombstone records at tail: tombstoned, skipped by OnEvict.
500+
// Total = (n - deleted) + immutableDeletes = n - mutableDeletes.
501+
ClassicAssert.AreEqual(n - mutableDeletes, tracker.EvictCount(EvictionSource.MainLog),
502+
$"OnEvict(MainLog) must fire exactly {n - mutableDeletes} times: " +
503+
$"{n - deleted} live + {immutableDeletes} sealed sources, skipping {mutableDeletes} in-place tombstones");
502504
ClassicAssert.AreEqual(0, tracker.EvictCount(EvictionSource.ReadCache),
503505
"No read cache is configured, OnEvict(ReadCache) must never fire");
504-
// Tombstoned records must NOT produce a paired OnDispose(Deleted) during eviction; the
505-
// OnDispose(Deleted) count should be unchanged from the pre-eviction snapshot.
506506
ClassicAssert.AreEqual(deletedDisposeCountBeforeEvict, tracker.DisposeCount(DisposeReason.Deleted),
507507
"Page eviction must not re-fire OnDispose(Deleted) for tombstoned records");
508-
// And OnDisposeDiskRecord must not fire during eviction — eviction uses OnEvict only.
509508
ClassicAssert.AreEqual(0, tracker.TotalDisposeDisk(),
510509
"Page eviction must not route through OnDisposeDiskRecord");
511510
}

0 commit comments

Comments
 (0)