Skip to content

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 into
mainfrom
tedhar-aof-recovery-heap-budget-hang
Sep 30, 2026
Merged

Ted Hart (TedHartMS) merged 20 commits into
mainfrom
tedhar-aof-recovery-heap-budget-hang

Conversation

@TedHartMS

@TedHartMS Ted Hart (TedHartMS) commented Sep 29, 2026 •

Copy link
Copy Markdown
Contributor

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, --recover never completes after an unclean shutdown when hash objects push the log heap over the LogSizeTracker budget. The server never logs "Ready to accept connections" and sits at low CPU. NeedToWaitForClose signalled the size tracker and returned RETRY_NOW, but the tracker's background resizer is not running during recovery, and IssueShiftAddress only advances HeadAddress once the page count reaches MaxAllocatedPageCount — so with only the heap over budget nothing moved and TryAllocateRetryNow spun forever. The same load with SET instead of HSET recovered in about 2 seconds.

PR #2175 — Fixes #2174 in the allocator: NeedToWaitForClose no longer waits on a size tracker whose resizer is not running, and the same guard is applied to NeedToShiftAddress so the MaxAllocatedPageCount cap is still enforced when eviction cannot be deferred. All three page-turn guards test IsRunning before querying the budget, because IsBeyondSizeLimitAndCanEvict reads HeadAddress/TailAddress, which Recovery has not yet set up. Adds UnstartedSizeTrackerAllocationTests (page cap holds with an attached-but-unstarted tracker, at 5/9/16 pages) and RespAdminCommandsTests.SeAofRecoverObjectsOverHeapBudgetTest (end-to-end AOF recovery over the heap budget).

PR #2177 — Refs #1436. ScanCursor always built its iterator with SinglePageBuffering, 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 use DoublePageBuffering so the next page is read while the current one is processed. Bounded-count callers such as SCAN keep SinglePageBuffering, since they create a new iterator per call and would waste the read-ahead. Measured on DBSIZE with --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 DBSIZE O(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 ScanCursor hardening 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 ScanCursor read-ahead hardening. Enabling DoublePageBuffering made frameSize > 1 reachable on the ScanCursor path for the first time, which exposed four latent defects in ScanIteratorBase:

  • Frame reuse race. A read-ahead issued into nextFrame could still be in flight when the scan reached a page that mapped back to that frame without having consumed the prefetched one (BeginAddress advancing 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. A Debug.Assert documented the invariant but did not enforce it; it is replaced by WaitForPriorFrameLoad, which drains until the prior claim's deferred read has been issued, then waits off-epoch so the drain list can make progress. With frameSize == 1, nextFrame is always currentFrame, which is awaited before every return, so this is a no-op.
  • Cancelled token carried into the replacement read. 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 — the very hazard the previous item exists to prevent. WaitForPriorFrameLoad now renews a cancelled token where the frame is prepared for reuse. (Found in review by GitHub Copilot.)
  • Per-page CountdownEvent allocation. AsyncReadPageFromDeviceToFrame allocated a fresh CountdownEvent for every page read via an out parameter. It now takes the event by ref and reuses the frame's existing one through PrepareFrameLoadCompletionEvent, 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.
  • 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. Both vectors are now reset together.

Expired-key-deletion scan. Routed through the unbounded ScanCursor overload so it benefits from the same read-ahead.

AOF replay is now memory-budget-managed. StoreWrapper.ReplayAOF starts 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; ReplayAOF is the earliest safe point. Starting is idempotent, so the existing call in StoreWrapper.Start() is unaffected, and the cluster replication and replica disk-based sync replay paths are covered too.

The IsRunning guards 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 against SeAofRecoverObjectsOverHeapBudgetTest: the original hang reproduces only when both are reverted.

Size-tracker resizer dispatch window. LogSizeTracker.Start publishes RunState.Running and then queues the resizer body with Task.Run, so IsRunning reported true while the thread pool had not yet dispatched anything that could act on Signal(). 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. IsRunning now 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:

AOF recovery 2,000 HSET + COMMITAOF
No starvation (before or after) ~110 ms ~1.08 s
15 s starvation, before 15.11 s 15.72 s
15 s starvation, after 0.11 s 1.08 s

RunState.Running is deliberately still published synchronously in Start rather than moved into the task body: Stop hands off by CAS-ing Running → StopRequested, so if the state were 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. That variant was tested and Stop(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.Set retires 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 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/WaitToRetryNow keep a localFlushEvent and refresh it each pass, and TsavoriteLog captures before each wait. LogSizeTracker.ResizerTask was the one caller that did not: it awaited the field directly, re-read 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. It now captures before resizing and waits on the capture. Resize failures are contained in their own catch so they cannot skip the wait, since the previous ordering relied on the wait preceding the resize to rate-limit a persistently failing resize.

CompletionEventTests pins 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 — Set releases int.MaxValue permits on the generation it retires, which is why the capture must be refreshed rather than reused.

LogSizeTracker.ToString formatted the IsBeyondSizeLimitAndCanEvict method 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

  • New: ScanCursorReadAheadTests (bounded-count scans still use SinglePageBuffering; frame reclaim waits for an in-flight read-ahead), read-ahead failure coverage in ScanIteratorEpochFailureTests including ReclaimAfterFailedReadAheadRenewsTheFrameToken, 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, and SeAofRecoverObjectsOverHeapBudgetTest, which now also asserts the heap replay actually held.
  • Every fix above except the dispatch window was verified by reverting it and confirming the corresponding test fails: the allocator guards hang, the cancelled-token fix fails its assertion, and SeAofRecoverObjectsOverHeapBudgetTest reports ~600 KB of replay heap instead of ~120 KB.
  • Full suites on net10.0 Debug/Windows, all passing: 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-changes clean on both Garnet.slnx and Tsavorite.slnx.

Note on the AOF recovery test's timeout

SeAofRecoverObjectsOverHeapBudgetTest bounds the recovering Start() 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.

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.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 High severity · 2 Low severity

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.

Comment thread libs/storage/Tsavorite/cs/src/core/Allocator/ScanIteratorBase.cs Outdated
Comment thread libs/server/StoreWrapper.cs
Comment thread libs/storage/Tsavorite/cs/src/core/Index/Common/LogSizeTracker.cs
…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.
@TedHartMS
Ted Hart (TedHartMS) merged commit 4b0c184 into main Sep 30, 2026
448 of 449 checks passed
@TedHartMS
Ted Hart (TedHartMS) deleted the tedhar-aof-recovery-heap-budget-hang branch September 30, 2026 23:19
@Laurianti

Copy link
Copy Markdown
Contributor

Thanks for carrying this through, and for the credit in the description.

@TedHartMS

Copy link
Copy Markdown
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)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

AOF recovery hangs when objects put the log heap over the size tracker budget

5 participants