Skip to content

Commit fcdb5fc

Browse files
TedHartMSCopilotbadrishc
authored
Fix object-log write buffer leaks in ObjectAllocator read-only and recovery flushes (#2046)
* Fix object-log write buffer leaks in ObjectAllocator read-only and recovery flushes ObjectAllocator flushes rent pooled object-log write buffers via CircularDiskWriteBuffer, which are returned to the pool only on Dispose(). The read-only flush and snapshot-region recovery copy paths never disposed theirs, leaking a buffer set per flush range / per recovered page. - Dispose the shared buffers after the read-only flush loop. - Dispose each recovery page's buffer after its WriteAsync. - Skip renting the buffer for inline-only read-only pages (objectIdMap empty), routing them through WriteInlinePageAsync. - Harden CircularDiskWriteBuffer.Dispose() memory ordering, since both paths now dispose while device writes may still be in flight. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f45128a9-8d17-4a2e-b84d-6da1afd3cc73 * Make flush-buffer disposal exception-safe Wrap the read-only flush loop and the per-page recovery WriteAsync in try/finally so the rented CircularDiskWriteBuffer is returned to the pool even if a page write throws. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f45128a9-8d17-4a2e-b84d-6da1afd3cc73 --------- Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Co-authored-by: Badrish Chandramouli <badrishc@microsoft.com> Copilot-Session: f45128a9-8d17-4a2e-b84d-6da1afd3cc73
1 parent 85f5590 commit fcdb5fc

3 files changed

Lines changed: 58 additions & 20 deletions

File tree

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

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1948,6 +1948,10 @@ public void AsyncFlushPagesForRecovery<TContext>(long scanFromAddress, long flus
19481948
{
19491949
var pageStartAddress = GetLogicalAddressOfStartOfPage(flushPage);
19501950
var flushFromAddress = Math.Max(scanFromAddress, pageStartAddress);
1951+
1952+
// When copying snapshot object bytes into the main object-log, rent this page's object-log write buffers; the hybrid-log region
1953+
// (which reuses the stored lengths/positions without writing object bytes) needs none.
1954+
var flushBuffers = copyObjects ? CreateCircularFlushBuffers(objectLogDevice: null, logger) : null;
19511955
var asyncResult = new PageAsyncFlushResult<TContext>()
19521956
{
19531957
page = flushPage,
@@ -1963,12 +1967,22 @@ public void AsyncFlushPagesForRecovery<TContext>(long scanFromAddress, long flus
19631967
flushRequestState = FlushRequestState.Recovery,
19641968
recoverySnapshotObjectLogDevice = snapshotObjectLogDevice,
19651969
recoveryFormerFlushedUntilAddress = formerFlushedUntilAddress,
1966-
flushBuffers = copyObjects ? CreateCircularFlushBuffers(objectLogDevice: null, logger) : null
1970+
flushBuffers = flushBuffers
19671971
};
19681972

1969-
// For the snapshot region (records at/above formerFlushedUntilAddress) we copy object bytes from the snapshot object-log to the main
1970-
// object-log using flushBuffers; otherwise (hybrid-log region) we reuse the stored lengths/positions without writing object bytes.
1971-
WriteAsync(flushPage, callback, asyncResult);
1973+
try
1974+
{
1975+
// For the snapshot region (records at/above formerFlushedUntilAddress) we copy object bytes from the snapshot object-log to the main
1976+
// object-log using flushBuffers; otherwise (hybrid-log region) we reuse the stored lengths/positions without writing object bytes.
1977+
WriteAsync(flushPage, callback, asyncResult);
1978+
}
1979+
finally
1980+
{
1981+
// WriteAsync issues all of this page's object-log and main-log device writes synchronously before returning, and the recovery completion
1982+
// callback (AsyncFlushPageCallbackForRecovery) never reuses flushBuffers, so dispose it here, even if WriteAsync throws. Dispose defers the
1983+
// actual return-to-pool (ClearBuffers) until any still-in-flight writes complete.
1984+
flushBuffers?.Dispose();
1985+
}
19721986
}
19731987
}
19741988

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

Lines changed: 33 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -619,25 +619,37 @@ internal override void AsyncFlushPagesForReadOnly(long fromAddress, long untilAd
619619
// the current TailAddress is, but for normal flush operations we do set it to page alignment to eliminate concerns about rewriting partial sectors.
620620
GetFlushPageRange(fromAddress, untilAddress, out var startPage, out var numPages);
621621

622-
// Create the buffers we will use for all ranges of the flush. This calls our callback and disposes itself when the last write of a range completes.
622+
// Create the buffers we will use for all ranges of the flush. Each page that has out-of-line data rents pooled object-log write
623+
// buffers from this instance; pages that are entirely inline skip it (see WriteAsync). ObjectAllocator flushes are page-aligned and
624+
// never use the PendingFlush chaining path, so once this loop has issued every page's write, no further writes will reference these
625+
// buffers and it is safe to Dispose below.
623626
var flushBuffers = CreateCircularFlushBuffers(objectLogDevice: null, logger);
624627

625-
// Write each page (or partial page) in the range.
626-
for (var flushPage = startPage; flushPage < (startPage + numPages); flushPage++)
628+
try
627629
{
628-
// The result from PrepareFlushAsyncResult indicates whether we are to perform an actual flush--but asyncResult will be set anyway.
629-
if (PrepareFlushAsyncResult(fromAddress, untilAddress, noFlush, flushPage, out var asyncResult))
630+
// Write each page (or partial page) in the range.
631+
for (var flushPage = startPage; flushPage < (startPage + numPages); flushPage++)
630632
{
631-
asyncResult.flushBuffers = flushBuffers;
633+
// The result from PrepareFlushAsyncResult indicates whether we are to perform an actual flush--but asyncResult will be set anyway.
634+
if (PrepareFlushAsyncResult(fromAddress, untilAddress, noFlush, flushPage, out var asyncResult))
635+
{
636+
asyncResult.flushBuffers = flushBuffers;
632637

633-
// TsavoriteKV using ObjectAllocator always moves ReadOnlyAddress in page alignment, so if we have a partial first page, it can be written
634-
// in the same loop as full pages, because there are no adjacent fragments. Write the entire page up to asyncResult.untilAddress.
635-
Debug.Assert(PendingFlush[GetPageIndexForAddress(asyncResult.fromAddress)].list.Count == 0,
636-
$"Expected PendingFlush count {PendingFlush[GetPageIndexForAddress(asyncResult.fromAddress)].list.Count} to be 0 for ObjectAllocator");
638+
// TsavoriteKV using ObjectAllocator always moves ReadOnlyAddress in page alignment, so if we have a partial first page, it can be written
639+
// in the same loop as full pages, because there are no adjacent fragments. Write the entire page up to asyncResult.untilAddress.
640+
Debug.Assert(PendingFlush[GetPageIndexForAddress(asyncResult.fromAddress)].list.Count == 0,
641+
$"Expected PendingFlush count {PendingFlush[GetPageIndexForAddress(asyncResult.fromAddress)].list.Count} to be 0 for ObjectAllocator");
637642

638-
WriteAsync(flushPage, AsyncFlushPageCallback, asyncResult);
643+
WriteAsync(flushPage, AsyncFlushPageCallback, asyncResult);
644+
}
639645
}
640646
}
647+
finally
648+
{
649+
// Dispose the shared flush buffers so their pooled object-log write buffers are returned to the pool, even if a page write throws;
650+
// Dispose defers the actual return (ClearBuffers) until any still-in-flight device writes complete.
651+
flushBuffers?.Dispose();
652+
}
641653
}
642654

643655
protected override void WriteAsync<TContext>(long flushPage, DeviceIOCompletionCallback callback, PageAsyncFlushResult<TContext> asyncResult)
@@ -708,8 +720,16 @@ private void WriteAsync<TContext>(long flushPage, ulong alignedMainLogFlushPageA
708720
if (isFirstRecordOnPage)
709721
((PageHeader*)logPagePointer)->SetLowestObjectLogPosition(objectLogTail);
710722

711-
// Short circuit if we are not using flushBuffers and not in recovery (e.g. using ObjectAllocator for string-only purposes).
712-
if (asyncResult.flushBuffers is null)
723+
// A ReadOnly flush of a page whose records are entirely inline (no Overflow keys/values and no Object values, i.e. the page's
724+
// objectIdMap is empty) has nothing to serialize to the object log, so take the cheaper WriteInlinePageAsync path and skip renting
725+
// an object-log write buffer. This is restricted to ReadOnly flushes: a Recovery flush does not populate objectIdMap (it reuses
726+
// the on-disk lengths/positions), and a Snapshot flush may still need to invalidate v+1 records in the disk-image copy.
727+
var objectIdMap = objectPages[flushPage % BufferSize].objectIdMap;
728+
var pageHasNoObjectsToFlush = asyncResult.flushRequestState == FlushRequestState.ReadOnly && objectIdMap.Count == 0;
729+
730+
// Short circuit if we are not using flushBuffers and not in recovery (e.g. using ObjectAllocator for string-only purposes), or if a
731+
// ReadOnly flush of this page has no out-of-line data to write to the object log.
732+
if (asyncResult.flushBuffers is null || pageHasNoObjectsToFlush)
713733
{
714734
if (asyncResult.flushRequestState != FlushRequestState.Recovery)
715735
{
@@ -792,8 +812,6 @@ private void WriteAsync<TContext>(long flushPage, ulong alignedMainLogFlushPageA
792812
// not change record sizes, so the logicalAddress space is unchanged. Also, we will not advance HeadAddress until this flush is complete
793813
// and has updated FlushedUntilAddress, so we don't have to worry about the page being yanked out from underneath us (and Objects
794814
// won't be disposed before we're done). TODO: Loop on successive subsets of the page's records to make this initial copy buffer smaller.
795-
var objectIdMap = objectPages[flushPage % BufferSize].objectIdMap;
796-
797815
srcBuffer = bufferPool.Get(alignedBufferSize);
798816
asyncResult.freeBuffer1 = srcBuffer;
799817

libs/storage/Tsavorite/cs/src/core/Allocator/ObjectSerialization/CircularDiskWriteBuffer.cs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -254,7 +254,13 @@ public void Dispose()
254254
// any further writes. For this class, "disposed" means "we're done issuing writes". And filePosition must be preserved; checkpoints will retrieve it later,
255255
// and chained partial flushes will append to it.
256256
disposed = true;
257-
if (numInFlightWrites == 0)
257+
258+
// Full fence so the 'disposed' store above is globally visible before we read numInFlightWrites below (and, symmetrically, so this read is
259+
// not hoisted above the store). FlushToDeviceCallback decrements numInFlightWrites (a full-fence Interlocked op) and then reads 'disposed';
260+
// without this barrier the store and load could reorder and both sides could observe the pre-decrement / pre-dispose values, so neither would
261+
// call ClearBuffers and the pooled buffers would leak. This matters now that the ReadOnly flush path Disposes while writes may be in flight.
262+
Interlocked.MemoryBarrier();
263+
if (Interlocked.Read(ref numInFlightWrites) == 0)
258264
ClearBuffers();
259265
}
260266

0 commit comments

Comments
 (0)