Skip to content

Commit 285bf22

Browse files
badrishcCopilot
andcommitted
fix: apply sizeChange delta before HasRemoveKey early return in ObjectStore IPU
ObjectStore InPlaceUpdaterWorker's HasRemoveKey path (e.g. SPOP/LPOP/HDEL/ZREM removing the last element) returned false with ExpireAndStop without applying the sizeChange from Operate. The mutation had already happened in-place (HeapMemorySize updated on the object), but the tracker never saw the negative delta. OnDispose(Deleted) then subtracted only the post-removal empty-collection size, leaving the pre-removal payload permanently over-counted. Fix: apply sizeChange to cacheSizeTracker before the early return so the tracker reflects the in-place mutation before ExpireAndStop disposes the record. Found by Opus 4.7 review. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 4301c90 commit 285bf22

5 files changed

Lines changed: 71 additions & 23 deletions

File tree

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

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -130,8 +130,12 @@ bool InPlaceUpdaterWorker(ref LogRecord logRecord, ref ObjectInput input, ref Ob
130130
functionsState.watchVersionMap.IncrementVersion(rmwInfo.KeyHash);
131131
if (functionsState.appendOnlyFile != null)
132132
rmwInfo.UserData |= NeedAofLog;
133-
// Heap disposal and cache size tracking are handled by
134-
// OnDispose(Deleted) in the ExpireAndStop path of InternalRMW.
133+
// Apply the mutation delta before the ExpireAndStop path disposes the record.
134+
// Operate already mutated the object (e.g. removed the last element), so
135+
// HeapMemorySize is now the post-removal size. Without this, the delta from
136+
// the removal is lost and the tracker permanently over-counts.
137+
if (sizeChange != 0)
138+
functionsState.cacheSizeTracker?.AddHeapSize(sizeChange);
135139
rmwInfo.Action = RMWAction.ExpireAndStop;
136140
return false;
137141
}

libs/storage/Tsavorite/cs/src/core/Allocator/AllocatorBase.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1426,7 +1426,7 @@ internal void EvictPageForRecovery(long page)
14261426
{
14271427
_wrapper.EvictRecordsInRange(start, end, source);
14281428
}
1429-
if (onEvictionObserver is not null && !ReferenceEquals(onEvictionObserver, logSizeTracker))
1429+
if (onEvictionObserver is not null)
14301430
{
14311431
MemoryPageScan(start, end, onEvictionObserver);
14321432
}
@@ -1504,7 +1504,7 @@ private void OnPagesClosedWorker()
15041504

15051505
// Legacy observer path — skip if the observer IS the logSizeTracker, since
15061506
// EvictRecordsInRange below already handles heap accounting via logSizeTracker.
1507-
if (onEvictionObserver is not null && !ReferenceEquals(onEvictionObserver, logSizeTracker))
1507+
if (onEvictionObserver is not null)
15081508
MemoryPageScan(start, end, onEvictionObserver);
15091509

15101510
// Per-record eviction walk: handles internal heap-size accounting (key overflow

libs/storage/Tsavorite/cs/src/core/Index/Common/LogSizeTracker.cs

Lines changed: 1 addition & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ public class LogSizeTracker
3333
/// <summary>Tracks and controls size of log</summary>
3434
/// <typeparam name="TStoreFunctions"></typeparam>
3535
/// <typeparam name="TAllocator"></typeparam>
36-
public sealed class LogSizeTracker<TStoreFunctions, TAllocator> : LogSizeTracker, IObserver<ITsavoriteScanIterator>
36+
public sealed class LogSizeTracker<TStoreFunctions, TAllocator> : LogSizeTracker
3737
where TStoreFunctions : IStoreFunctions
3838
where TAllocator : IAllocator<TStoreFunctions>
3939
{
@@ -190,22 +190,6 @@ public void UpdateTargetSize(long newTargetSize, long highDelta, long lowDelta)
190190
resizeTaskEvent.Set();
191191
}
192192

193-
/// <summary>Callback on allocator completion</summary>
194-
public void OnCompleted() { }
195-
196-
/// <summary>Callback on allocator error</summary>
197-
public void OnError(Exception error) { }
198-
199-
/// <summary>Callback on allocator evicting a page to disk</summary>
200-
public void OnNext(ITsavoriteScanIterator recordIter)
201-
{
202-
long size = 0;
203-
while (recordIter.GetNext())
204-
size += MemoryUtils.CalculateHeapMemorySize(in recordIter);
205-
if (size != 0)
206-
heapSize.Increment(-size); // Reduce size as records are being evicted
207-
}
208-
209193
/// <summary>Adds size to the tracked total count</summary>
210194
public void IncrementSize(long size)
211195
{

libs/storage/Tsavorite/cs/src/core/Index/Tsavorite/LogAccessor.cs

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -176,8 +176,6 @@ public IDisposable Subscribe(IObserver<ITsavoriteScanIterator> readOnlyObserver)
176176
public IDisposable SubscribeEvictions(IObserver<ITsavoriteScanIterator> evictionObserver)
177177
{
178178
allocatorBase.onEvictionObserver = evictionObserver;
179-
if (evictionObserver is LogSizeTracker<TStoreFunctions, TAllocator> tracker)
180-
allocatorBase.logSizeTracker = tracker;
181179
return new LogSubscribeDisposable(allocatorBase, isReadOnly: false);
182180
}
183181

test/Garnet.test/CacheSizeTrackerTests.cs

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -259,5 +259,67 @@ public void ReadCacheIncreaseEmptyPageCountTest()
259259
// The first page of the read cache has been evicted => 11 records removed, 9 remain.
260260
ClassicAssert.AreEqual(9 * MemorySizePerEntry, cacheSizeTracker.readCacheTracker.LogHeapSizeBytes);
261261
}
262+
263+
/// <summary>
264+
/// Verifies that removing the last element from an object collection (triggering HasRemoveKey →
265+
/// ExpireAndStop) correctly returns the heap tracker to zero. This exercises the IPU path where
266+
/// Operate mutates the object in-place, the sizeChange delta is applied before the ExpireAndStop
267+
/// early return, and OnDispose(Deleted) subtracts the remaining empty-collection overhead.
268+
/// </summary>
269+
[Test]
270+
public void RemoveLastElementReturnsTrackerToZero_IPU()
271+
{
272+
ClassicAssert.AreEqual(0, cacheSizeTracker.mainLogTracker.LogHeapSizeBytes);
273+
274+
using var redis = ConnectionMultiplexer.Connect(TestUtils.GetConfig());
275+
var db = redis.GetDatabase(0);
276+
277+
// Add a single element to a set — creates the object record with heap tracking.
278+
db.SetAdd("myset", "value1");
279+
var heapAfterAdd = cacheSizeTracker.mainLogTracker.LogHeapSizeBytes;
280+
ClassicAssert.Greater(heapAfterAdd, 0, "Heap should be positive after SADD");
281+
282+
// Remove the only element — triggers HasRemoveKey → ExpireAndStop → tombstone.
283+
var removed = db.SetRemove("myset", "value1");
284+
ClassicAssert.IsTrue(removed, "SREM should return true");
285+
286+
// The tracker should return to zero: the sizeChange delta from SREM (negative) plus
287+
// OnDispose(Deleted) subtracting the empty-collection overhead should exactly cancel
288+
// the original SADD increment.
289+
ClassicAssert.AreEqual(0, cacheSizeTracker.mainLogTracker.LogHeapSizeBytes,
290+
"Heap tracker must return to zero after removing the last element (IPU path)");
291+
}
292+
293+
/// <summary>
294+
/// Same scenario but with the record in the readonly region, forcing the remove-last-element
295+
/// through CopyUpdate → PostCopyUpdater → HasRemoveKey → ExpireAndStop. The PCU path's
296+
/// tombstoned new record must not leak value heap because +value is only added on pcuSuccess=true.
297+
/// </summary>
298+
[Test]
299+
public void RemoveLastElementReturnsTrackerToZero_PCU()
300+
{
301+
ClassicAssert.AreEqual(0, cacheSizeTracker.mainLogTracker.LogHeapSizeBytes);
302+
303+
using var redis = ConnectionMultiplexer.Connect(TestUtils.GetConfig());
304+
var db = redis.GetDatabase(0);
305+
306+
// Add a single element to a list.
307+
db.ListRightPush("mylist", "value1");
308+
var heapAfterAdd = cacheSizeTracker.mainLogTracker.LogHeapSizeBytes;
309+
ClassicAssert.Greater(heapAfterAdd, 0, "Heap should be positive after RPUSH");
310+
311+
// Shift the record to the readonly region so the next mutation goes through CopyUpdate.
312+
store.Log.ShiftReadOnlyAddress(store.Log.TailAddress, wait: true);
313+
314+
// Remove the only element — forces CopyUpdate → PCU → HasRemoveKey → ExpireAndStop.
315+
var popped = db.ListLeftPop("mylist");
316+
ClassicAssert.AreEqual("value1", (string)popped, "LPOP should return the value");
317+
318+
// Force eviction to flush any remaining sealed source records.
319+
store.Log.FlushAndEvict(wait: true);
320+
321+
ClassicAssert.AreEqual(0, cacheSizeTracker.mainLogTracker.LogHeapSizeBytes,
322+
"Heap tracker must return to zero after removing the last element (PCU path)");
323+
}
262324
}
263325
}

0 commit comments

Comments
 (0)