Repository navigation
Make the read-lock cancellation flake deterministic under a loaded runner - #4655
Merged
Merged
Conversation
…nner TheReadLockWaitIsAbandonableWhileAWriterHoldsIt armed a CancellationTokenSource(250ms) and trusted that the delay was long enough for the read-lock poll to already be running before it fired. That delay cancels through a Timer callback, which the CLR ThreadPool has to schedule; under a loaded runner (a parallel run of the rest of the suite queues real work onto the same pool, and DuckDbInitializer's lock is one static field every test in the process contends for) that scheduling can lag the 250ms mark by seconds, past the test's 10s backstop. The lock's own poll was never the slow part - it only checks its token between 50ms TryEnterReadLock attempts. The reader now cancels explicitly instead of on a timer: the writer signals once it holds the lock, the reader signals once it is about to wait, and only once both are observed does the test call Cancel() itself. The writer's own wait is bumped from 5s to 30s for the same reason - the lock is static and process-wide, so a parallel run can legitimately queue it behind another test's hold.
… cancelling readerIsWaiting only proved the reader Task had started, not that it had reached AcquireReadLock's poll loop yet. Cancelling right on that signal usually raced ahead of the call and threw out of the loop's very first ThrowIfCancellationRequested() check - which a "check once, then block uncancellably" regression in AcquireReadLock would pass too, since the token is still uncancelled at that first check. Spin on s_dbLock's own WaitingReadCount instead, which only turns positive once a thread is genuinely blocked entering the lock, then cancel. Also corrects the class doc comment, which claimed the two ManualResetEventSlim signals alone already guaranteed the reader was genuinely waiting when Cancel() ran; that guarantee didn't exist until this change.
Lite.Tests runs its classes in parallel with no [Collection] to serialize them, and roughly twenty analysis test files besides this one call AcquireReadLock on the same static s_dbLock. That let another class's reader trip WaitingReadCount >= 1 while this test's own reader had not yet reached AcquireReadLock, so Cancel() could land before this reader's first token check - a false green for a "check once, then block" regression in that interleaving, despite the class doc comment's claim that this could not happen. Capture this reader's own Thread from inside its Task.Run and spin on WaitingReadCount >= 1 together with that thread's own ThreadState.WaitSleepJoin. That state is only reachable after this reader has made its own check (real or regressed) and is genuinely blocked inside TryEnterReadLock or EnterReadLock, so Cancel() can no longer land early. Drop the now-redundant 100ms settle delay, and correct the class doc comment, the inline comments, and the PR body's "cannot turn a real failure into a false pass" claim to describe the actual guarantee. Proof: planted "check once, then block" and "token ignored entirely" regressions in AcquireReadLock; the hardened test goes red (TimeoutException at the 10s backstop) 3/3 for each, reverting cleanly with no product file touched. 30/30 green for the class under CPU load, and the full Lite suite passes except one pre-existing failure in a different lane's file.
erikdarlingdata
marked this pull request as ready for review
September 28, 2026 22:24
This was referenced Sep 28, 2026
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.
Test-only flake fix. No issue: this addresses the flake in
Lite.Tests/AnalysisPassTokenThreadingTests.cs:301,TheReadLockWaitIsAbandonableWhileAWriterHoldsIt, seen failing alone in a slow CI build on 2026-09-12 and again in a loaded local full Lite run on 2026-09-28.Why
The test guards a real guarantee: a store read waiting on
DuckDbInitializer's read lock can be abandoned through itsCancellationTokenwhile a writer holds the lock, instead of blocking for the writer's full hold. That guarantee is real and unchanged by this PR.What flaked was how the test triggered the cancellation. It armed
new CancellationTokenSource(TimeSpan.FromMilliseconds(250))and trusted that 250ms was long enough for the read-lock attempt to already be polling by the time the token fired. A delay-basedCancellationTokenSourcecancels through aSystem.Threading.Timercallback, and that callback has to be scheduled by the CLR ThreadPool. Under a loaded runner two things push that scheduling past the 250ms mark, and past the test's 10-second backstop:DuckDbInitializer'ss_dbLockis onestaticfield, shared by everyDuckDbInitializerin the process. A parallel test class that also touches it (there are well over a hundred inLite.Tests) creates real, legitimate queuing on the same lock, which also affects how long the writer itself takes to acquire it (bumped from a 5s to a 30s wait below, for the same reason).The lock's own poll was never the slow part.
AcquireReadLock(CancellationToken)(Lite/Database/DuckDbInitializer.cs) only checks its token between 50msTryEnterReadLockattempts, so it reacts within about one interval of the token actually firing. The problem was getting the token to fire promptly at all under contention - confirmed by reading the implementation, not by directly capturing a failure message. I could not reproduce the failure live (see Test plan): 30 solo runs under 28 CPU-bound background processes on a 32-core box passed clean, and a further ~8-9 runs of a 9-class combination chosen forAcquireReadLock/AcquireWriteLock/LocalDataServicecontention, on a machine already busy with other builds and test runs, also passed clean. A CancellationTokenSource-Timer-vs-ThreadPool race is a genuine race, not a guaranteed reproduction on demand, so the fix is grounded in readingSystem.Threading.CancellationTokenSource's andDuckDbInitializer's actual code paths.I did not find a product bug. The lock implementation's cancellation handling is correct as written; this is entirely about how the test manufactured its cancellation.
What changes
TheReadLockWaitIsAbandonableWhileAWriterHoldsItinLite.Tests/AnalysisPassTokenThreadingTests.cs:CancellationTokenSource(250ms)with an untimed one, cancelled explicitly by the test thread viacts.Cancel()- no Timer, no ThreadPool callback in the critical path.Task.Run(no token passed toTask.Runitself, only toAcquireReadLock, so a thrownOperationCanceledExceptionfaults the task rather than putting it in theCanceledstate - awaiting it rethrows the same exception type instead of a wrappedTaskCanceledException).readerIsWaitingsignal is set immediately before that task callsAcquireReadLock, and the test waits for it before proceeding towardCancel(). A second commit on this same PR (below) found that this signal alone was not sufficient and tightened it further.s_dbLockbeing static means a parallel suite run can legitimately queue the writer behind another test's hold of the same lock.reader.WaitAsync(...), used only as a hang backstop (documented as such in the test's own comment) - a working cancellation returns in about one 50ms poll interval, so this only matters if the lock genuinely stopped observing its token.Stopwatch/System.Diagnosticsimport.Checking for the same timing shape elsewhere: no other test in this file has a
CancellationTokenSource/Task.Run/Wait(TimeSpan)shape - the rest of the class is static source-regex scanning or pure in-memory logic. The Darling twin (Darling/Darling.Tests/AnalysisPassTokenThreadingTests.cs) has no read-lock timing test at all; its own doc comment already states this guard is "the Lite-only half of #2443" because Darling has no equivalent static file lock.git grep -l AnalysisPassTokenThreadingturns up two more files (Lite.Tests/CrossAppGuardCiGateTests.cs,Darling/Darling.Tests/CommentFilterAdoptionTests.cs) but both only reference this file's path in a bounded reachability/doc-comment-scanning list, not a copy of the timing shape - confirmed by reading both entries, nothing to fix there.No product code changed - only the test file.
Second pass: the reader-waiting signal alone was a false green
A second look at this test found a gap:
readerIsWaiting.Set()runs as the very first statement inside the reader'sTask.Run, before it callsAcquireReadLockat all. The test then doesreaderIsWaiting.Wait(...)and callscts.Cancel()immediately afterward. That proves the reader task has started - it does not prove the reader has reached, let alone blocked in,AcquireReadLock's poll loop. In practicects.Cancel()almost always wins that race and lands before the reader's firstThrowIfCancellationRequested()check even runs (see Proof below - it was 10/10 in testing, not an occasional race).That matters because the test's own name promises the wait is abandonable while a writer holds the lock - cancellation reaching a reader that is genuinely blocked, not a reader that sees an already-cancelled token before it ever tries to enter the lock. The real
AcquireReadLock(Lite/Database/DuckDbInitializer.cs:194-208) checks the token before each poll, so it happens to pass either way. But the most natural regression - "check the token once, then block uncancellably" (cancellationToken.ThrowIfCancellationRequested(); s_dbLock.EnterReadLock();in place of the polling loop) - would ALSO pass the test as it stood after the first round, purely because the cancel lands before that single check. The old 250ms-timer version of this test caught that same regression shape, essentially by accident, via the timer's own scheduling delay giving the reader time to actually block; tightening the timer away without closing this gap would have quietly dropped that coverage.Fix: after
readerIsWaiting.Wait(...), the test now also spins (SpinWait.SpinUntil, 30s backstop) onDuckDbInitializer.s_dbLock's ownWaitingReadCount(read via reflection, since the field isprivate static) until it shows a thread genuinely blocked trying to enter the lock, then waits a 100ms settle margin (two poll intervals; nothing is asserted on it), and only then callscts.Cancel().s_dbLockis one static field shared by everyDuckDbInitializerin the process, so a parallel run of the rest of the suite could in principle tickWaitingReadCountover 1 via another test's own blocked reader - this paragraph originally claimed that was harmless, since this test's own writer still holds the lock regardless of what tripped the spin. That claim was wrong, and "Third pass" below describes the actual gap and its fix. The class's XML doc comment previously asserted a guarantee ("the reader is guaranteed to be genuinely waiting... when the cancellation arrives") that the two signals alone did not actually provide; it was corrected in this commit to credit theWaitingReadCountspin instead, and is corrected again, more precisely, by the third pass below.Proof
Lite/Database/DuckDbInitializer.cs'sAcquireReadLockelsebranch:cancellationToken.ThrowIfCancellationRequested(); s_dbLock.EnterReadLock();in place of thewhile (!TryEnterReadLock(...))polling loop - i.e., check once, then block uncancellably. Checked out the PR's then-current test (pre-tightening,git checkout origin/feature/analysispass-readlock-flake -- Lite.Tests/AnalysisPassTokenThreadingTests.cs) against that plant and ran it 10 times: 10/10 passed, each in ~0.14-0.18s (Total: 1, Errors: 0, Failed: 0) - the false green, and a near-certain one in practice rather than a rare race:cts.Cancel()on the test thread reliably outran the reader task reaching its first (and, under the plant, only) token check.Assert.ThrowsAny() Failure: Expected: typeof(System.OperationCanceledException); Actual: typeof(System.TimeoutException). The failure landed at theAssert.ThrowsAnyAsyncline, not at the earlierSpinWait.SpinUntilassertion ("no reader ever blocked on the lock"), which confirms the spin itself resolved correctly - the reader really was blocked in the uncancellableEnterReadLock()whenCancel()ran, it just couldn't observe the cancellation, and the 10s hang backstop caught that.s_dbLock.EnterReadLock(), no token check at all, regardless ofCanBeCanceled). Tightened test, run once: still red, sameTimeoutExceptionshape - confirming the tightening didn't narrow coverage of the regression the first round was already built to catch.git restore Lite/Database/DuckDbInitializer.cs;git diff origin/dev -- Lite/Database/DuckDbInitializer.csis empty andgit status --shortis clean - no product code in this PR.Total: 6, Errors: 0, Failed: 0every run, 0.79-1.05s each (vs. ~0.4-0.5s unloaded).origin/dev(already up to date, no conflicts). FullLite.Testssuite, once:Total: 5525, Errors: 0, Failed: 0, Skipped: 0, Not Run: 0, Time: 209.425s.Third pass: WaitingReadCount alone still let a different test class stand in for this one
The same fix had a second gap. Lite.Tests runs its test classes in parallel - there is no
CollectionBehavior, no runner JSON serializing classes, and no[Collection]attribute onAnalysisPassTokenThreadingTests- and roughly twenty other analysis test files in the suite callAcquireReadLockon the same process-wide statics_dbLock. The second pass's spin only checkeddbLock.WaitingReadCount >= 1, with no way to tell whether the thread that tripped it was this test's own reader or some other class's reader blocking on the same lock at the same moment. If another class's reader ticks the count first, while this test's own reader task is still sitting on a ThreadPool queue and has not yet reached its ownAcquireReadLockcall,cts.Cancel()fires before this reader's own token check ever runs. A "check once, then block uncancellably" regression passes anyway in that interleaving, sinceThrowIfCancellationRequested()sees an already-cancelled token on its one and only look - a false green, in the same shape the second pass had already found and thought it had closed. The 100ms settle after the spin narrowed the window but could not close it: it is a fixed margin against a scheduling delay of unknown size, not a check tied to this reader's own state.Fix: the test now captures this reader's own
Threadobject from inside itsTask.Rundelegate (published safely across threads by thereaderIsWaiting.Set()/.Wait()signal that already separates the two threads) and spins ondbLock.WaitingReadCount >= 1 && (readerThread.ThreadState & ThreadState.WaitSleepJoin) != 0instead of the count alone.WaitSleepJoinon THIS reader's own thread can only be true after it has made its own check - real or regressed - and is now genuinely blocked insideTryEnterReadLockorEnterReadLock; nothing else on that thread waits betweenSet()and the lock. That closes the interleaving above: by the timeCancel()runs, this reader is either past its check and correctly still polling (real implementation, still observes the cancellation within oneReadLockPollInterval) or past its one check and blocked uncancellably (regressed implementation, now provably fails instead of coincidentally passing). The now-redundant 100ms settle delay is dropped. The class doc comment, the inline comment above the spin, and the "cannot turn a real failure into a false pass" claim in "Second pass" above are corrected to describe this actual guarantee instead of the one that turned out not to hold.Proof
cancellationToken.ThrowIfCancellationRequested(); s_dbLock.EnterReadLock();inAcquireReadLock'selsebranch), rebuilt, ran the third-pass test 3 times: 3/3 failed, each at ~10.5-10.6s,Assert.ThrowsAny() Failure: Expected: typeof(System.OperationCanceledException); Actual: typeof(System.TimeoutException)- the fix still catches this regression.s_dbLock.EnterReadLock(), no check at all, regardless ofCanBeCanceled) from the first round's proof, ran 3 times: 3/3 failed, sameTimeoutExceptionshape.git checkout -- Lite/Database/DuckDbInitializer.cs;git status --porcelainclean except this test file.git diff origin/dev -- Lite/shows onlyLite/Themes/CoolBreezeTheme.xamldiffering, which isdev's own forward drift and not this branch's:git diff <merge-base> HEAD -- Lite/(this branch's own commits since the merge base withorigin/dev) is empty, confirming no product file is in this PR.yes > /dev/null, independent of this shell, PIDs confirmed live viaps -W), ran the class 30 times in a row: 30/30 passed,Total: 6, Errors: 0, Failed: 0every run, 0.47-0.67s each. Killed all 4 by PID afterward and confirmed none were left running before continuing.Lite.Testssuite once (not under the synthetic load - the suite's own ~5500 tests across many parallel classes already provide it):Total: 5525, Errors: 0, Failed: 1, Skipped: 0, Not Run: 0, Time: 313.290s. The one failure,StatusBarSizeReadLockTests.GetUsedDataSizeMb_WhenTheWriteLockIsHeld_GivesUpInsteadOfBlocking, is a separate timing flake in a file this PR does not touch, and is not evidence of a regression from this change.Test plan
dotnet build Lite.Tests/Lite.Tests.csproj -c Debug- Build succeeded, 0 Warning(s), 0 Error(s) (confirmed across all three rounds and every plant/revert cycle).Lite/Database/DuckDbInitializer.cs(AcquireReadLockcalling bares_dbLock.EnterReadLock(), ignoring the token entirely - the pre-The analysis token is armed but 167 Darling and ~138 Lite store calls never receive it #2443 shape), rebuilt, ran the single test. It failed deterministically on the first run:Assert.ThrowsAny() Failure: Expected: OperationCanceledException, Actual: TimeoutException, at ~10.3s (the hang backstop doing its job). Reverted the plant immediately after;git diff --statagainstLite/Database/DuckDbInitializer.csis empty, confirming no product code is in this PR.Lite.Testsclass that touchesAcquireWriteLock/AcquireReadLock/LocalDataServicedirectly) on an already busy machine - clean, stopped early because each run took ~30s.origin/devbefore the final run (round 1: clean, no conflicts; round 2: already up to date).Lite.Testssuite, once, after the round-2 merge:Total: 5525, Errors: 0, Failed: 0, Skipped: 0, Not Run: 0, Time: 209.425s.TimeoutExceptionat ~10.5-10.6s). Planted "token ignored entirely" regression, ran 3x - 3/3 failed, same shape. Reverted both;git diff <merge-base> HEAD -- Lite/empty, confirming no product file in this PR (see "Third pass" Proof above, items 1-3).Lite.Testssuite, once, after the round-3 fix:Total: 5525, Errors: 0, Failed: 1, Skipped: 0, Not Run: 0, Time: 313.290s- the one failure is inStatusBarSizeReadLockTests.cs, a file this PR does not touch (see "Third pass" Proof above, item 5).Installer.Testsnot run (out of scope).AvailabilityGroupsTabRefreshTests.cs,StatusBarSizeReadLockTests.cs, and the theme tests are not touched by this PR.CHANGELOG
SECTION: None - test-only change; no user-visible behavior changes.