Skip to content

Commit 89cbce5

Browse files
badrishcCopilot
andcommitted
refactor: internalize CopyUpdated value-object accounting into Tsavorite
The CopyUpdated path is a partial clear (value-object slot only, key stays on the record). Expecting IRecordTriggers implementers to know this nuance — that they should subtract only ValueObject.HeapMemorySize and not the full CalculateHeapMemorySize — is error-prone. Move the accounting entirely into Tsavorite's InternalRMW: - Decrement logSizeTracker by ValueObject.HeapMemorySize before clearing - Call IHeapObject.Dispose() on the freed value object - Then ClearValueIfHeap() nulls the ObjectIdMap slot Remove OnDispose(CopyUpdated) from the trigger call site — the trigger is no longer involved in CopyUpdate accounting. GarnetRecordTriggers.OnDispose now only handles DisposeReason.Deleted. The IRecordTriggers doc is updated to reflect that CopyUpdated is handled internally. The trigger contract is now simple: - OnDispose(Deleted): subtract CalculateHeapMemorySize (full record heap) - OnEvict: subtract CalculateHeapMemorySize (whatever remains at eviction) - Creation sites (PostInitialUpdater/Writer/CopyUpdater, InPlaceUpdater): add the new/changed heap Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 61089a1 commit 89cbce5

6 files changed

Lines changed: 53 additions & 48 deletions

File tree

libs/server/Storage/Functions/GarnetRecordTriggers.cs

Lines changed: 3 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -57,26 +57,13 @@ public void OnDispose(ref LogRecord logRecord, DisposeReason reason)
5757
if (reason == DisposeReason.Deleted)
5858
{
5959
// Decrement the full heap contribution of the dying record (overflow key/value bytes
60-
// and/or value-object heap). CalculateHeapMemorySize returns 0 for tombstoned records,
61-
// which naturally makes this a no-op on the mutable Delete() path where
62-
// MainStore.InPlaceDeleter already subtracted before tombstone was set. On the RMW
63-
// expire paths (ExpireAndResume, ExpireAndStop for objects) the tombstone is NOT yet
64-
// set when OnDispose fires, so CalculateHeapMemorySize returns the correct non-zero value.
60+
// and/or value-object heap). Tombstone is NOT yet set when this fires — Tsavorite sets
61+
// it AFTER OnDispose returns — so CalculateHeapMemorySize returns the correct non-zero
62+
// value. ClearHeapFields then physically frees the heap fields from the ObjectIdMap.
6563
var size = MemoryUtils.CalculateHeapMemorySize(in logRecord);
6664
if (size != 0)
6765
cacheSizeTracker.AddHeapSize(-size);
6866
}
69-
else if (reason == DisposeReason.CopyUpdated)
70-
{
71-
// Source record's value-object slot is being cleared in place after a successful
72-
// CopyUpdate CAS. The record itself stays alive on the sealed page until eviction,
73-
// but the (v) object is about to be released — this is the paired decrement for
74-
// PostCopyUpdater's unconditional +value.HeapMemorySize on the (v+1) value.
75-
// Checkpoint/disk paths that leave the source alive don't reach this site; their
76-
// decrement comes from OnEvict at page eviction.
77-
if (logRecord.Info.ValueIsObject)
78-
cacheSizeTracker.AddHeapSize(-logRecord.ValueObject.HeapMemorySize);
79-
}
8067
}
8168

8269
/// <inheritdoc/>

libs/server/Storage/Functions/ObjectStore/RMWMethods.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -242,8 +242,8 @@ public bool PostCopyUpdater<TSourceLogRecord>(in TSourceLogRecord srcLogRecord,
242242
sizeInfo.AssertOptionalsIfSet(dstLogRecord.Info);
243243

244244
// Track creation of the (v+1) value unconditionally; the matching decrement for the (v) value is
245-
// emitted at the removal site: OnDisposeValueObject(DisposeReason.CopyUpdated) when the source is
246-
// cleared eagerly, or OnEvict when the sealed source page later evicts (checkpoint/disk paths).
245+
// emitted internally by Tsavorite via logSizeTracker when the source value-object slot is cleared
246+
// eagerly (ClearSourceValueObject), or by OnEvict when the sealed source page later evicts.
247247
functionsState.cacheSizeTracker?.AddHeapSize(value.HeapMemorySize);
248248

249249
if (functionsState.appendOnlyFile != null)

libs/server/Storage/Functions/UnifiedStore/RMWMethods.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -175,8 +175,8 @@ public bool PostCopyUpdater<TSourceLogRecord>(in TSourceLogRecord srcLogRecord,
175175
sizeInfo.AssertOptionalsIfSet(dstLogRecord.Info);
176176

177177
// Track creation of the (v+1) value unconditionally; the matching decrement for the (v) value is
178-
// emitted at the removal site: OnDispose(DisposeReason.CopyUpdated) when the source is cleared
179-
// eagerly, or OnEvict when the sealed source page later evicts (checkpoint/disk paths).
178+
// emitted internally by Tsavorite via logSizeTracker when the source value-object slot is cleared
179+
// eagerly (ClearSourceValueObject), or by OnEvict when the sealed source page later evicts.
180180
functionsState.cacheSizeTracker?.AddHeapSize(value.HeapMemorySize);
181181
}
182182

libs/storage/Tsavorite/cs/src/core/Index/StoreFunctions/IRecordTriggers.cs

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -48,14 +48,19 @@ public interface IRecordTriggers
4848
bool CallOnDiskRead { get; }
4949

5050
/// <summary>
51-
/// Called when a record or one of its heap slots is disposed due to delete, CAS failure,
52-
/// copy-update source-slot clearing, or other store-internal reasons. Use <paramref name="reason"/>
53-
/// to distinguish record-level events (e.g. <see cref="DisposeReason.Deleted"/>) from
54-
/// slot-level events (e.g. <see cref="DisposeReason.CopyUpdated"/>, which signals that only
55-
/// the value-object slot is being cleared in place while the record itself remains on the
56-
/// sealed page until eviction). If the value object implements <see cref="IDisposable"/> and
57-
/// the application needs to release its resources, it should invoke <see cref="IDisposable.Dispose"/>
58-
/// from this callback on the appropriate reasons.
51+
/// Called when a record is disposed due to delete, expiration, CAS failure, elision,
52+
/// revivification, or other store-internal reasons. Use <paramref name="reason"/> to
53+
/// distinguish the event type (e.g. <see cref="DisposeReason.Deleted"/> for tombstoning).
54+
/// <para>
55+
/// For heap-size tracking, <see cref="DisposeReason.Deleted"/> is the primary reason to handle:
56+
/// the record's full heap contribution (overflow key/value + value object) can be obtained via
57+
/// <c>CalculateHeapMemorySize</c> and should be subtracted from the tracked total. The tombstone
58+
/// bit is NOT yet set when this callback fires for Deleted, so the size is correct.
59+
/// </para>
60+
/// <para>
61+
/// Copy-update source-slot clearing (<see cref="DisposeReason.CopyUpdated"/>) is handled
62+
/// internally by the store's <c>logSizeTracker</c> and does NOT fire this callback.
63+
/// </para>
5964
/// NOT called for page eviction (use <see cref="OnEvict"/> instead).
6065
/// NOT called for transient records materialized from disk (use <see cref="OnDisposeDiskRecord"/>).
6166
/// Default implementation is a no-op.

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

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -597,13 +597,26 @@ private OperationStatus CreateNewRecordRMW<TKey, TInput, TOutput, TContext, TSes
597597
{
598598
if (rmwInfo.ClearSourceValueObject && isMemoryLogRecord)
599599
{
600-
// Signal the source's value-object slot is being cleared in place (the record
601-
// itself stays alive on the sealed page until eviction). We pass the full
602-
// srcLogRecord + CopyUpdated reason so the application can distinguish this
603-
// slot-level dispose from a record-level Deleted dispose. The actual object
604-
// disposal is handled inside ClearValueIfHeap via ObjectIdMap.Free → IHeapObject.Dispose.
605600
ref var srcMemLogRecord = ref srcLogRecord.AsMemoryLogRecordRef();
606-
storeFunctions.OnDispose(ref srcMemLogRecord, DisposeReason.CopyUpdated);
601+
602+
// Decrement the value-object's heap contribution from the tracker and dispose
603+
// the object BEFORE clearing the slot. This is an internal accounting and
604+
// disposal step — the trigger is NOT involved because CopyUpdated is a
605+
// partial clear (value only, key stays) and expecting trigger implementers
606+
// to know that nuance is error-prone. The remaining key overflow (if any)
607+
// is decremented later by OnEvict when the sealed page is evicted.
608+
if (srcMemLogRecord.Info.ValueIsObject)
609+
{
610+
var valueObject = srcMemLogRecord.ValueObject;
611+
if (valueObject is not null)
612+
{
613+
var valueHeap = valueObject.HeapMemorySize;
614+
if (valueHeap != 0)
615+
hlogBase.logSizeTracker?.IncrementSize(-valueHeap);
616+
valueObject.Dispose();
617+
}
618+
}
619+
607620
srcMemLogRecord.ClearValueIfHeap();
608621
}
609622
}

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

Lines changed: 14 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -233,15 +233,17 @@ private void Upsert(int key, int value)
233233
_ = s.BasicContext.Upsert(new TestObjectKey { key = key }, new TrackedObjectValue { value = value }, 0);
234234
}
235235

236-
#region OnDispose(CopyUpdated)
236+
#region CopyUpdate — value-object slot clearing
237237

238238
/// <summary>
239239
/// RMW on a record in the immutable region forces CopyUpdate. After the CAS succeeds,
240-
/// Tsavorite fires <see cref="IRecordTriggers.OnDispose"/> with <see cref="DisposeReason.CopyUpdated"/>
241-
/// to let the handler clear the source value slot (paired with PostCopyUpdater's +size add on the destination).
240+
/// Tsavorite internally clears the source value-object slot and decrements the logSizeTracker
241+
/// for the value-object's heap. The trigger is NOT involved — <see cref="DisposeReason.CopyUpdated"/>
242+
/// does not fire via <see cref="IRecordTriggers.OnDispose"/>. The source record stays alive
243+
/// (sealed) until eviction, where OnEvict picks up any remaining key overflow.
242244
/// </summary>
243245
[Test, Category("TsavoriteKV")]
244-
public void CopyUpdateFiresOnDisposeCopyUpdatedExactlyOnce()
246+
public void CopyUpdateDoesNotFireOnDisposeCopyUpdated()
245247
{
246248
Upsert(1, 100);
247249
store.Log.ShiftReadOnlyAddress(store.Log.TailAddress, wait: true);
@@ -254,27 +256,25 @@ public void CopyUpdateFiresOnDisposeCopyUpdatedExactlyOnce()
254256
_ = s.BasicContext.RMW(new TestObjectKey { key = 1 }, ref input, ref output, 0);
255257
}
256258

257-
ClassicAssert.AreEqual(1, tracker.DisposeCount(DisposeReason.CopyUpdated),
258-
"OnDispose(CopyUpdated) should fire exactly once after a successful CopyUpdate CAS");
259+
ClassicAssert.AreEqual(0, tracker.DisposeCount(DisposeReason.CopyUpdated),
260+
"CopyUpdated is handled internally by logSizeTracker — OnDispose must not fire for it");
259261
ClassicAssert.AreEqual(0, tracker.DisposeCount(DisposeReason.Deleted),
260262
"Deleted must not fire on a CopyUpdate path");
261-
// The source record survives on the (sealed) page; no eviction yet, so no OnEvict.
262263
ClassicAssert.AreEqual(0, tracker.TotalEvict(),
263264
"No page eviction should have happened in this test window");
264265
}
265266

266267
/// <summary>
267-
/// With <see cref="LifecycleTracker.DisposeValuesOnDispose"/> set, the handler disposes the source
268-
/// value object once during CopyUpdated. This asserts that Tsavorite itself never invokes Dispose
269-
/// on the on-log value; only the handler does — and it does so at most once per record.
268+
/// After CopyUpdate, the source value-object is cleared from the ObjectIdMap (via ClearValueIfHeap).
269+
/// Tsavorite calls IHeapObject.Dispose on the freed object internally — the trigger is not involved.
270+
/// Verify that exactly one IHeapObject.Dispose fires for the source value object.
270271
/// </summary>
271272
[Test, Category("TsavoriteKV")]
272-
public void CopyUpdateDisposesSourceValueExactlyOnceWhenHandlerOptsIn()
273+
public void CopyUpdateDisposesSourceValueExactlyOnce()
273274
{
274275
Upsert(1, 100);
275276
store.Log.ShiftReadOnlyAddress(store.Log.TailAddress, wait: true);
276277
tracker.Reset();
277-
tracker.DisposeValuesOnDispose = true;
278278

279279
using (var s = NewSession())
280280
{
@@ -283,8 +283,8 @@ public void CopyUpdateDisposesSourceValueExactlyOnceWhenHandlerOptsIn()
283283
_ = s.BasicContext.RMW(new TestObjectKey { key = 1 }, ref input, ref output, 0);
284284
}
285285

286-
ClassicAssert.AreEqual(1, tracker.DisposeCount(DisposeReason.CopyUpdated),
287-
"OnDispose(CopyUpdated) should fire exactly once");
286+
ClassicAssert.AreEqual(0, tracker.DisposeCount(DisposeReason.CopyUpdated),
287+
"CopyUpdated must not fire — handled internally");
288288

289289
var (total, max) = TrackedObjectValue.Snapshot();
290290
ClassicAssert.AreEqual(1, total, "Exactly one IHeapObject.Dispose call expected (the CU source object)");

0 commit comments

Comments
 (0)