Repository navigation
Stabilization: AOF recovery hang over the heap budget, full-log ScanCursor read-ahead, and size-tracker fixes - #2181
Merged
Ted Hart (TedHartMS) merged 20 commits intoSep 30, 2026
Conversation
NeedToShiftAddress and IssueShiftAddress guard the same MaxAllocatedPageCount-based head shift, but only IssueShiftAddress skipped deferring to the resizer when it is not running. With NeedToWaitForClose no longer waiting on a stopped tracker, an over-budget replay left neither path requesting the shift, so AllocatedPageCount could exceed MaxAllocatedPageCount up to BufferSize. Align all three conditions. Also restore the bounded wait on the recovering server's Start() in the regression test: the standalone CI job sets no blame-hang timeout, so an unbounded hang would consume the whole 45-minute job and report no test results.
…cker budget (#2174) The allocation page-turn path applied LogSizeTracker backpressure even when the tracker's background resizer was not running, so nothing could relieve it. With the heap over budget and the page count still below MaxAllocatedPageCount, HeadAddress never advanced and TryAllocateRetryNow spun on RETRY_NOW forever. AOF replay hit this because the resizer was started only after recovery finished. - AllocatorBase.NeedToWaitForClose: do not wait for size-tracker eviction when the resizer is not running; fall through to synchronous MaxAllocatedPageCount eviction. - AllocatorBase.NeedToShiftAddress: add the same !IsRunning guard as IssueShiftAddress so the page cap stays enforced. - LogSizeTracker.ToString: call IsBeyondSizeLimitAndCanEvict() instead of formatting the method group. - StoreWrapper.ReplayAOF: start the size trackers first, so replay is memory-budget-managed like normal operation. - Tests: Tsavorite livelock regression (resizer never started, and started-then-stopped) and a Garnet end-to-end AOF recovery test over the heap budget.
Covers both page-turn guards in one fixture, in the state recovery and AOF replay run in: a LogSizeTracker attached via SetLogSizeTracker whose resizer was never started, with the log over budget. The page cap is only observable when MaxAllocatedPageCount is not a power of two, since BufferSize rounds up and the wrap check in NeedToWaitForClose otherwise caps the log on its own; 5 and 9 pages leave that gap and 16 is the power-of-two control. Deferring the cap to the stopped resizer grows the resident span to BufferSize (9 and 17 pages observed, against caps of 5 and 9), and waiting on the resizer livelocks the retry loop, which the bounded wait reports as a named failure rather than hanging the run.
NeedToWaitForClose already ordered the checks this way, and there it is load-bearing: IsBeyondSizeLimitAndCanEvict reads HeadAddress and TailAddress, which Recovery has not set up when it runs, so the IsRunning short-circuit is what keeps that call off the recovery path. Record that in a comment so the order is not rearranged later. IsOverBudget carries no such precondition, so the order in IssueShiftAddress and NeedToShiftAddress was incidental -- IsRunning was appended to an existing condition. Match the other guard: IsRunning is a volatile int read on an object already in hand, while IsOverBudget walks logAccessor to allocatorBase and reads the ConcurrentCounter partition line that writers contend with Interlocked.Add, so testing liveness first skips the contended read entirely once the resizer has stopped.
…very-heap-budget-hang # Conflicts: # libs/storage/Tsavorite/cs/src/core/Allocator/AllocatorBase.cs
ScanCursor always used SinglePageBuffering, so a whole-log lookup scan (DBSIZE, INFO KEYSPACE, KEYS, slot deletion) waited for each on-disk page to load before processing it, then waited again for the next one. Use DoublePageBuffering when the caller asks for the whole log (count == long.MaxValue) so the next page is read while the current page's records are liveness-checked. Bounded-count calls such as SCAN keep SinglePageBuffering, since they create a new iterator per call and would waste the read-ahead. DBSIZE with --storage-tier, 64 MB memory, 4 MB pages, log almost all on disk (Windows, NVMe), alternating A/B runs: - 60K keys x 128 KB (7.8 GB log): ~9.8 s -> ~6.8 s median - 4M keys x 64 B (316 MB log): ~2.0 s -> ~1.8 s median Refs #1436 Co-Authored-By: Claude Opus 5.5 <[email protected]>
Enabling read-ahead for whole-log ScanCursor iterations (PR #2177) made frameSize > 1 reachable on this path for the first time, exposing three latent defects in ScanIteratorBase: - Frame reuse race: a read-ahead issued into nextFrame could still be in flight when the scan reached a page mapping back to that frame without having consumed the prefetched one, so two device reads wrote the same buffer and the completion event this load replaced was orphaned. A Debug.Assert documented the invariant but did not enforce it; replace it with WaitForPriorFrameLoad, which drains until the prior claim's deferred read is issued and then waits off-epoch. With frameSize 1 nextFrame is always currentFrame, so it is a no-op. - CountdownEvent churn: AsyncReadPageFromDeviceToFrame allocated a fresh CountdownEvent per page read (out parameter). Take it by ref and reuse the frame's event via PrepareFrameLoadCompletionEvent, keeping at most one lazily-created kernel wait handle per frame for the iterator's lifetime. - Failure bookkeeping: WaitForFrameLoad cleared loadedPages but left nextLoadedPages at the failed claim, a state the BufferAndLoad CAS loop could neither satisfy nor re-claim, so a later pass over a page ending at or below that claim would spin. Reset both vectors together. Also route the expired-key-deletion scan through the unbounded ScanCursor overload so it gets the same read-ahead. Tests: ScanCursorReadAheadTests (bounded-count scans still use SinglePageBuffering; frame reclaim waits for an in-flight read-ahead) and read-ahead failure coverage in ScanIteratorEpochFailureTests. Co-authored-by: Shivam Sharma <[email protected]>
The comments inherited from PR #2175 described AOF replay as running with the size tracker attached but its resizer not started. That stopped being true when StoreWrapper.ReplayAOF was changed to start the size trackers, so replay is budget-managed; the resizer is running there and the IsRunning guards are inert. The states in which the resizer is genuinely not running are checkpoint recovery, which owns the log and does its own budget-aware eviction, and the window after CacheSizeTracker.Stop() at shutdown. Update UnstartedSizeTrackerAllocationTests, SeAofRecoverObjectsOverHeapBudgetTest, and the LogSizeTracker.IsRunning doc accordingly. Verified each fix in isolation against SeAofRecoverObjectsOverHeapBudgetTest: the allocator guard alone passes, StartSizeTrackers alone passes, and the original hang reproduces only when both are reverted. Comments only; no behavior change.
Recovery in SeAofRecoverObjectsOverHeapBudgetTest completes in ~0.1s, but while it is over budget it defers eviction to the size tracker's resizer, so it cannot finish before the thread pool dispatches that task. Injecting a 20s dispatch delay stalls recovery by 20.098s; without the delay it is 0.108s. The bound therefore has to tolerate dispatch latency, and its only real job is to separate 'completes' from 'never completes' -- the regression it guards is a livelock, which never finishes at any bound. Widen it from 60s to 5 minutes and say so, so a starved pool reports a slow recovery rather than a spurious failure. Comments and the bound only; no product change.
LogSizeTracker.Start publishes RunState.Running and then queues the resizer body with Task.Run, so IsRunning reported true while nothing existed yet that could act on Signal(). The allocation path deferred eviction to that resizer and stalled for as long as dispatch took, which thread-pool starvation makes unbounded. This is not specific to recovery: with a 15s dispatch delay injected, AOF recovery took 15.11s (vs ~110ms) and 2,000 HSETs plus COMMITAOF against a running server took 15.72s (vs ~1.08s). With IsRunning also requiring the body to have started, both return to ~110ms and ~1.08s, and there is no measurable cost when the pool is healthy. RunState.Running is deliberately still published synchronously in Start rather than moved into the task body. Stop hands off by CAS-ing Running to StopRequested; were the state still NotStarted at that point the CAS would fail and Stop would return without signalling or waiting, leaving the body to start against an allocator whose epoch and buffer pool had already been torn down. Tested: with the publish moved into the body, Stop(wait: true) returned in 24ms rather than waiting for the resizer. With recovery no longer dependent on resizer dispatch, restore the SeAofRecoverObjectsOverHeapBudgetTest bound to 60s; at ~110ms of actual work that is headroom, not a timing assumption.
Both fixtures enumerate the states in which the resizer is not running, so they need the third one: started but not yet dispatched by the thread pool. Comments only.
…llers do CompletionEvent is not inherently lossy. Set() retires the current semaphore generation and installs a fresh, unsignaled one, so a signal is observed by any waiter holding a copy of the generation that was current when it was raised. Copying the struct before the work that may race with Set is the contract -- it is why CompletionEvent is a struct -- and every flushEvent consumer follows it: BlockAllocate captures into operationState.flushEvent before TryAllocateRetryNow, TryAllocateRetryNow and WaitToRetryNow keep a localFlushEvent and refresh it each pass, and TsavoriteLog captures before each wait. ResizerTask was the one caller that did not: it awaited the field directly, re-reading it after ResizeIfNeeded, so a signal raised while resizing retired the generation it was about to wait on and it slept out the full ResizeTaskDelaySeconds having missed it. Capture before resizing and wait on the capture, refreshed each pass. Resize failures are now contained in their own catch so they cannot skip the wait; the previous ordering relied on the wait preceding the resize to rate-limit a persistently failing resize. Adds CompletionEventTests pinning the contract: a signal is lost without a capture, preserved with one (same thread and cross-thread), and a consumed capture returns immediately forever rather than missing later signals -- Set() releases int.MaxValue permits on the generation it retires, which is why the capture must be refreshed rather than reused.
Ted Hart (TedHartMS)
requested review from
Badrish Chandramouli (badrishc)
and
a balanced review from Copilot
September 29, 2026 09:10
Contributor
There was a problem hiding this comment.
Copilot review overview
🟡 Changes recommended
Failed read-ahead reuse retains a canceled token, potentially skipping data while I/O remains active.
Review effort: Balanced
Findings: 1
Open (3)
What changed in this PR
Stabilizes Tsavorite recovery, memory budgeting, and full-log disk scans.
Changes:
- Prevents allocation hangs when size-tracker resizers are unavailable.
- Adds double-page read-ahead and safer frame reuse.
- Starts memory tracking during AOF replay and expands regression coverage.
| File | Description |
|---|---|
test/standalone/Garnet.test/RespAdminCommandsTests.cs |
Tests object-heavy AOF recovery. |
libs/storage/Tsavorite/cs/test/test.recovery/UnstartedSizeTrackerAllocationTests.cs |
Tests page caps without a running tracker. |
libs/storage/Tsavorite/cs/test/test.recordops/LogSizeTrackerAllocationTests.cs |
Tests allocation with stopped trackers. |
libs/storage/Tsavorite/cs/test/test.hlog/ScanIteratorEpochFailureTests.cs |
Tests frame reuse and load failures. |
libs/storage/Tsavorite/cs/test/test.hlog/ScanCursorReadAheadTests.cs |
Tests bounded and full-log buffering policies. |
libs/storage/Tsavorite/cs/test/CompletionEventTests.cs |
Documents and tests generation capture semantics. |
libs/storage/Tsavorite/cs/src/core/TsavoriteLog/TsavoriteLogScanIterator.cs |
Reuses frame completion events. |
libs/storage/Tsavorite/cs/src/core/Index/Common/LogSizeTracker.cs |
Hardens dispatch, signaling, and diagnostics. |
libs/storage/Tsavorite/cs/src/core/Allocator/TsavoriteLogAllocatorImpl.cs |
Reuses read completion events. |
libs/storage/Tsavorite/cs/src/core/Allocator/SpanByteScanIterator.cs |
Passes reusable completion events. |
libs/storage/Tsavorite/cs/src/core/Allocator/SpanByteAllocatorImpl.cs |
Enables full-log read-ahead. |
libs/storage/Tsavorite/cs/src/core/Allocator/ScanIteratorBase.cs |
Hardens frame loading and failure bookkeeping. |
libs/storage/Tsavorite/cs/src/core/Allocator/ObjectScanIterator.cs |
Passes reusable completion events. |
libs/storage/Tsavorite/cs/src/core/Allocator/ObjectAllocatorImpl.cs |
Enables object-log read-ahead. |
libs/storage/Tsavorite/cs/src/core/Allocator/AllocatorScan.cs |
Selects buffering mode from scan count. |
libs/storage/Tsavorite/cs/src/core/Allocator/AllocatorBase.cs |
Avoids waiting on inactive resizers. |
libs/server/StoreWrapper.cs |
Starts trackers before AOF replay. |
libs/server/Storage/Session/Common/ArrayKeyIterationFunctions.cs |
Enables read-ahead for expiration scans. |
💡 Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
…ay budget management Three review findings, all valid. 1. ScanIteratorBase: a failed load cancels its frame's token without replacing it, and only WaitForFrameLoad's failure path installs a fresh one -- which a frame re-claimed without first being awaited as currentFrame never reaches. The replacement read then ran under an already-cancelled token, so the next wait on that frame threw immediately: it skipped a page that was genuinely being read, and signalled the frame reusable while that read was still writing into its buffer, which is the concurrent-reads-into-one-buffer hazard WaitForPriorFrameLoad exists to prevent. Renew a cancelled token where the frame is prepared for reuse. Reachable only with read-ahead, which this PR enables on the ScanCursor path. Covered by ReclaimAfterFailedReadAheadRenewsTheFrameToken, which fails without the fix. 2. StoreWrapper.ReplayAOF's StartSizeTrackers call was untested: the allocator's page-cap fallback lets replay complete either way. Post-replay heap is ~120 KB with the call and ~600 KB without, so record the heap replay held and assert on it. Sampling after startup cannot show this, because the resizer keeps trimming once the server is up and both converge to the same floor. SeAofRecoverObjectsOverHeapBudgetTest now fails without the call. 3. The IsRunning dispatch-window change remains covered by measurement rather than an automated test. Holding that window open deterministically needs a test seam in LogSizeTracker; the gated test built on one passed in isolation but failed intermittently in full-suite runs, flipping on the presence of a diagnostic write. An intermittently failing test is worse than none, so the seam and test were removed and the gap is called out in the PR description.
… code loadCTSs entries are non-null from InitializeForReads/Reset until Dispose, which is the only place that nulls them, so within an active scan the element cannot be null. WaitForPriorFrameLoad already dereferences loadCTSs[frame].Token directly twenty lines above, as do WaitForFrameLoadCompletion and Dispose; the null-conditional uses are confined to FailFrameLoad, which runs inside a drain action where nothing may escape, and to Dispose, which creates the nulls. The property pattern therefore guarded a state that cannot occur, implied to a reader that it can, and would have masked a use-after-dispose by silently skipping the renewal where a NullReferenceException would surface it.
Badrish Chandramouli (badrishc)
approved these changes
Sep 30, 2026
Ted Hart (TedHartMS)
deleted the
tedhar-aof-recovery-heap-budget-hang
branch
September 30, 2026 23:19
Contributor
|
Thanks for carrying this through, and for the credit in the description. |
Contributor
Author
|
Thanks for the reports |
Ted Hart (TedHartMS)
added a commit
that referenced
this pull request
Oct 3, 2026
Conflicts were in the scan-iterator page-read path and the test harness. main #2181 widened the page-read API: int readPage -> long, int devicePageOffset -> long, and out CountdownEvent -> ref (the frame's event is now reused rather than replaced). This branch had added a CircularDiskReadBuffer parameter to the same signatures, so every declaration and override conflicted; resolved by keeping the added parameter and taking main's widening and ref semantics. That widening also reverted a narrowing this branch had made: PageAsyncReadResult.page, GetPageIndexForPage, GetLogicalAddressOfStartOfPage, GetFileOffsetOfPage and BufferAndLoad were int-based here but long at the merge base and in main. Restored to long -- main widened them deliberately, and a page index must not be bounded by int for large logs. main #2193 replaced this branch's GARNET_TEST_PORT_SLOT scheme with TestPortAllocator, which reserves ports dynamically from a non-ephemeral range. Took main's mechanism wholesale and kept this branch's V7CheckpointFixture compile item alongside main's new TestPortAllocator one. Remaining conflicts were additive: CreateGarnetServer parameters and a configuration.md section, where both sides' additions were kept.
x@01 (x-at-01)
added a commit
to webc-fork/garnet
that referenced
this pull request
Oct 4, 2026
…2187,microsoft#2188,microsoft#2189), Vector Sets expiration after migration (microsoft#2179), Vector Set RESP2/RESP3 contracts (microsoft#2184), AOF recovery hang + ScanCursor read-ahead + size-tracker fixes (microsoft#2181)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.


Issues and PRs
This PR consolidates the following issues that were filed, as well as additional stabilization work:
Issue #2174 — With the storage tier and AOF enabled,
--recovernever completes after an unclean shutdown when hash objects push the log heap over theLogSizeTrackerbudget. The server never logs "Ready to accept connections" and sits at low CPU.NeedToWaitForClosesignalled the size tracker and returned RETRY_NOW, but the tracker's background resizer is not running during recovery, andIssueShiftAddressonly advancesHeadAddressonce the page count reachesMaxAllocatedPageCount— so with only the heap over budget nothing moved andTryAllocateRetryNowspun forever. The same load withSETinstead ofHSETrecovered in about 2 seconds.PR #2175 — Fixes #2174 in the allocator:
NeedToWaitForCloseno longer waits on a size tracker whose resizer is not running, and the same guard is applied toNeedToShiftAddressso theMaxAllocatedPageCountcap is still enforced when eviction cannot be deferred. All three page-turn guards testIsRunningbefore querying the budget, becauseIsBeyondSizeLimitAndCanEvictreadsHeadAddress/TailAddress, which Recovery has not yet set up. AddsUnstartedSizeTrackerAllocationTests(page cap holds with an attached-but-unstarted tracker, at 5/9/16 pages) andRespAdminCommandsTests.SeAofRecoverObjectsOverHeapBudgetTest(end-to-end AOF recovery over the heap budget).PR #2177 — Refs #1436.
ScanCursoralways built its iterator withSinglePageBuffering, so a whole-log lookup scan (DBSIZE,INFO KEYSPACE,KEYS, slot deletion) read an on-disk page, processed it, and only then issued the next read; disk IO and the per-record liveness checks never overlapped. Full-log iterations (count == long.MaxValue) now useDoublePageBufferingso the next page is read while the current one is processed. Bounded-count callers such asSCANkeepSinglePageBuffering, since they create a new iterator per call and would waste the read-ahead. Measured onDBSIZEwith--storage-tier, 64 MB memory, 4 MB pages, log almost entirely on disk: 60K × 128 KB (7.8 GB log) ~9.8 s → ~6.8 s; 4M × 64 B (316 MB log) ~2.0 s → ~1.8 s.This does not make
DBSIZEO(1), so #1436 is referenced rather than closed: with the storage tier enabled it remains a full scan whose cost is proportional to log size.Fixes #2174
Original work by Federico Laurianti (@Laurianti) (#2175) and Shivam Sharma (@ShivamSharma43) (#2177); authorship is preserved in the commit history.
Additional fixes
Work beyond what the two PRs proposed. The
ScanCursorhardening below started as follow-up work in #2177's own branch that had not been pushed; the rest was found while consolidating, or in review.Full-log
ScanCursorread-ahead hardening. EnablingDoublePageBufferingmadeframeSize > 1reachable on theScanCursorpath for the first time, which exposed four latent defects inScanIteratorBase:nextFramecould still be in flight when the scan reached a page that mapped back to that frame without having consumed the prefetched one (BeginAddressadvancing past the prefetched page is one way in). Reusing the buffer left two device reads writing the same memory and orphaned the completion event the new load replaced. ADebug.Assertdocumented the invariant but did not enforce it; it is replaced byWaitForPriorFrameLoad, which drains until the prior claim's deferred read has been issued, then waits off-epoch so the drain list can make progress. WithframeSize == 1,nextFrameis alwayscurrentFrame, which is awaited before every return, so this is a no-op.WaitForFrameLoad's failure path installs a fresh one — which a frame re-claimed without first being awaited ascurrentFramenever reaches. The replacement read then ran under an already-cancelled token, so the next wait on that frame threw immediately: it skipped a page that was genuinely being read, and signalled the frame reusable while that read was still writing into its buffer — the very hazard the previous item exists to prevent.WaitForPriorFrameLoadnow renews a cancelled token where the frame is prepared for reuse. (Found in review by GitHub Copilot.)CountdownEventallocation.AsyncReadPageFromDeviceToFrameallocated a freshCountdownEventfor every page read via anoutparameter. It now takes the event byrefand reuses the frame's existing one throughPrepareFrameLoadCompletionEvent, so at most one lazily-created kernel wait handle exists per frame for the iterator's lifetime. Reuse is safe because a frame is only re-read once its previous load has completed.WaitForFrameLoadclearedloadedPagesbut leftnextLoadedPagesat the failed claim — a state theBufferAndLoadCAS loop could neither satisfy nor re-claim, so a later pass over a page ending at or below that claim would spin. Both vectors are now reset together.Expired-key-deletion scan. Routed through the unbounded
ScanCursoroverload so it benefits from the same read-ahead.AOF replay is now memory-budget-managed.
StoreWrapper.ReplayAOFstarts the size trackers before replaying. Replay is ordinary store traffic, but the resizer was previously started only after recovery completed, so replay ran unmanaged. With the allocator guard alone, replay no longer hangs but holds roughly five times the object heap — measured ~600 KB versus ~120 KB post-replay in the regression test — because eviction falls back to the page cap. Checkpoint recovery still runs without the resizer, since it owns the log and resets log addresses directly;ReplayAOFis the earliest safe point. Starting is idempotent, so the existing call inStoreWrapper.Start()is unaffected, and the cluster replication and replica disk-based sync replay paths are covered too.The
IsRunningguards are mostly inert during replay, since the resizer is running by then; they protect checkpoint recovery, which precedes replay, the post-CacheSizeTracker.Stop()shutdown window, and the dispatch window described below. Each fix was verified in isolation againstSeAofRecoverObjectsOverHeapBudgetTest: the original hang reproduces only when both are reverted.Size-tracker resizer dispatch window.
LogSizeTracker.StartpublishesRunState.Runningand then queues the resizer body withTask.Run, soIsRunningreported true while the thread pool had not yet dispatched anything that could act onSignal(). The allocation path would defer eviction to that resizer and stall for as long as dispatch took — unbounded under thread-pool starvation, and not limited to recovery.IsRunningnow also requires the body to have started; until then the allocator falls back to the same synchronous page-cap eviction it uses when there is no size tracker at all. Measured by injecting a 15 s dispatch delay:HSET+COMMITAOFRunState.Runningis deliberately still published synchronously inStartrather than moved into the task body:Stophands off by CAS-ing Running → StopRequested, so if the state were still NotStarted at that point the CAS would fail andStopwould return without signalling or waiting — leaving the body to start against an allocator whose epoch and buffer pool had already been torn down. That variant was tested andStop(wait: true)returned in 24 ms instead of waiting, confirming the leak.This one is verified by the measurement above rather than by an automated test. Holding the dispatch window open deterministically needs a test seam in
LogSizeTracker, and the gated test built on one passed in isolation but failed intermittently in full-suite runs, flipping on the mere presence of a diagnostic write. Rather than land an intermittently failing test or an unexplained production seam, the seam and test were removed and the coverage gap is called out here.Lost resize signals.
CompletionEvent.Setretires the current semaphore generation and installs a fresh, unsignaled one, so a signal is observed only by a waiter holding a copy of the generation that was current when it was raised. Copying the struct before the work that may race withSetis the contract — it is whyCompletionEventis a struct — and everyflushEventconsumer follows it:BlockAllocatecaptures intooperationState.flushEventbeforeTryAllocateRetryNow,TryAllocateRetryNow/WaitToRetryNowkeep alocalFlushEventand refresh it each pass, andTsavoriteLogcaptures before each wait.LogSizeTracker.ResizerTaskwas the one caller that did not: it awaited the field directly, re-read afterResizeIfNeeded, so a signal raised while resizing retired the generation it was about to wait on and it slept out the fullResizeTaskDelaySecondshaving missed it. It now captures before resizing and waits on the capture. Resize failures are contained in their owncatchso they cannot skip the wait, since the previous ordering relied on the wait preceding the resize to rate-limit a persistently failing resize.CompletionEventTestspins the contract down: a signal is lost without a capture, preserved with one (same-thread and cross-thread), and a consumed capture returns immediately forever rather than missing later signals —Setreleasesint.MaxValuepermits on the generation it retires, which is why the capture must be refreshed rather than reused.LogSizeTracker.ToStringformatted theIsBeyondSizeLimitAndCanEvictmethod group instead of calling it, so the diagnostic printed a delegate type name (<>f__AnonymousDelegate0...) where the eviction state should have been.Shutdown window. The same page-turn guards also close a latent hang after
CacheSizeTracker.Stop(): any allocation that went over budget once the resizer had stopped would have hit the identical livelock.Testing
ScanCursorReadAheadTests(bounded-count scans still useSinglePageBuffering; frame reclaim waits for an in-flight read-ahead), read-ahead failure coverage inScanIteratorEpochFailureTestsincludingReclaimAfterFailedReadAheadRenewsTheFrameToken,CompletionEventTests(signalling contract),LogSizeTrackerAllocationTests(allocation completes when the heap is over budget and the resizer is not running — never started, and started-then-stopped),UnstartedSizeTrackerAllocationTests, andSeAofRecoverObjectsOverHeapBudgetTest, which now also asserts the heap replay actually held.SeAofRecoverObjectsOverHeapBudgetTestreports ~600 KB of replay heap instead of ~120 KB.Garnet.test(1290),Garnet.test.scripting(638),Garnet.test.extensions(536),Tsavorite.test.hlog(606),Tsavorite.test(346),Tsavorite.test.recovery(249),Tsavorite.test.recordops(222).dotnet format --verify-no-changesclean on bothGarnet.slnxandTsavorite.slnx.Note on the AOF recovery test's timeout
SeAofRecoverObjectsOverHeapBudgetTestbounds the recoveringStart()so the livelock it guards fails as a named assertion rather than hanging the run. Recovery measures ~110 ms and, with the dispatch-window fix above, no longer depends on the thread pool scheduling the resizer, so the 60 s bound is three orders of magnitude of headroom rather than a timing assumption.