Repository navigation
Startup Query Store repair logs its progress lines before it returns (#5693) - #5694
Conversation
| try | ||
| { | ||
| await service.RepairOnStartupAsync(TestContext.Current.CancellationToken); | ||
| } | ||
| finally | ||
| { | ||
| SynchronizationContext.SetSynchronizationContext(previous); | ||
| } |
There was a problem hiding this comment.
Non-blocking, and it does not bite today — but the finally restores the context on whichever thread resumes the await, not the thread the try set it on. If anything inside RepairOnStartupAsync ever yields, the continuation goes through ProgressPostCountingContext.Post onto a pool thread, the restore lands there, and the counting context stays installed on the xunit worker thread for the rest of the run. The Post handler leaks the same way in the other direction: it calls SetSynchronizationContext(this) at line 178 and never clears it, so every pool thread it touches keeps the context after d(state) returns.
Why it is inert as written: nothing in the repair path yields. There is no Task.Delay/Task.Run/Task.Yield anywhere in QueryStoreSliceRepairService, AcquireReadLock is a blocking call, and DuckDB.NET's async completes synchronously — which is exactly the premise DuckDbInitializer.cs:342 records and DuckDbLockModelTests.DuckDbAsyncStillCompletesOnTheCallingThread guards. So with the fixed code the context receives zero posts and the restore happens on the setting thread.
That ties the hygiene of this test to that premise: the day the tripwire goes red, this test starts leaving a SynchronizationContext on threads it does not own (and, separately, resumes a thread-affine ReaderWriterLockSlim hold on another thread). A try { d(state); } finally { SetSynchronizationContext(null); } in the work item, and restoring the test's context from a known thread, would make it independent of it.
| var owner = d.Target?.GetType(); | ||
| if (owner is { IsGenericType: true } && owner.GetGenericTypeDefinition() == typeof(Progress<>)) | ||
| { | ||
| Interlocked.Increment(ref _progressPosts); | ||
| } |
There was a problem hiding this comment.
Non-blocking observation about what this pin will fail on in two years' time. The discriminator is that Progress<T> posts an instance-bound SendOrPostCallback (_invokeHandlers = new SendOrPostCallback(InvokeHandlers)), so d.Target is the Progress<T>. That is an implementation detail of the BCL, not a contract: if it ever becomes a static callback carrying the instance in state, owner is a closure/null, the counter stays 0, and the pin passes for a reason that has nothing to do with the fix — and because the fixed code posts nothing at all, vacuous reads exactly like green here.
The sentence in the doc comment ("the old code posts at least one, the inline reporter posts none") is the stronger property and it holds for any post: against the fixed code the context receives no posts whatsoever on this path. Counting every post and asserting 0 would survive a BCL change, at the cost of coupling the test to DuckDB.NET's synchronous completion (see the other comment). Either way it is worth a line here saying which detail the 0 depends on, so a future reader who sees it go vacuous knows where to look.
There was a problem hiding this comment.
Verdict: LGTM
Claims
- VERIFIED —
RepairOnStartupAsyncpassednew Progress<string>(...)toRepairAsync— the removed line in theLite/Services/QueryStoreSliceRepairService.cs:413hunk. - VERIFIED — the repair runs from a background task —
Lite/MainWindow.xaml.cs:266,_ = Task.Run(() => sliceRepair.RepairOnStartupAsync(_backgroundCts.Token)), un-awaited. - VERIFIED — a private
InlineLogProgress : IProgress<string>logs each line on the calling thread beforeReportreturns, and the startup repair uses it instead ofProgress<string>—QueryStoreSliceRepairService.cs:1251-1253, constructed at:414. - VERIFIED — the new test asserts zero
Progress<T>posts, the archive and views lines present and in that order, and an unchanged log count after a 250 ms wait —Lite.Tests/SliceRepairStartupTests.cs:147,:152-154,:156-158; the two lines it looks for are theReportsites atQueryStoreSliceRepairService.cs:1051and:771. - VERIFIED —
CapturingLoggernow owns its list, adds under a lock, exposesEntriesas a snapshot taken under the same lock, gainsClear(), and every existing reader was moved tolog.Entries—SliceRepairStartupTests.cs:538-556, readers at:203,:263,:269,:299,:383-384. No other file references this nested type: every otherCapturingLoggerinLite.Tests/Darling.Testsis its own private nested class, so the constructor change cannot break a caller. - VERIFIED — the only
new Progress<...>is the one fixed, there is noIProgress<>implementation to reuse, and no other service logs throughProgress<T>— a repo-wide grep forIProgress</Progress<returns onlydeprecated/Installer.Core/{DependencyInstaller,InstallationService}.cs(deprecated tree,IProgress<InstallationProgress>, not a logging adapter) and this file. - VERIFIED — "it does not depend on timing", for the assertion that carries the red:
:147counts insidePost, synchronously, and everyReporthappens beforeRepairAsyncreturns; the 250 ms wait at:157can only false-green, never false-red, because nothing in the fixed path can log after the awaited call returns. - UNVERIFIED —
Progress<T>posts each report to the captured synchronization context, or to the thread pool when there is none — documented BCL behaviour, not a fact in this tree; it is the mechanism the new context's counter exploits and the reason the old code's two reports were posts. - UNVERIFIED —
Rewrite_WithTooLittleFreeDisk_SkipsTheFile_AndSaysWhyfailed once in CI with "Collection was modified" — needs that run's log. The mechanism is consistent with the pre-change code: the sink was a bareList(new CapturingLogger(log)) appended from a pool thread whileAssert.Contains(log, …)enumerated it. - UNVERIFIED — RED line "old code, new test: Expected: 0 / Actual: 2" — needs a run against the pre-change service. The number corroborates: exactly two
Reportcalls are reachable for this fixture (:1051per-file,:771views), while the hot-store line at:724cannot fire because the fixture's hot table carries no split groups. - UNVERIFIED — test plan (0 Warning(s)/0 Error(s);
*SliceRepairStartupTests*14/0/0,*Census*67/0/0,Stage=Guard493/0/0) — needs a build and a Windows test host.
Riskiest lines
Lite/Services/QueryStoreSliceRepairService.cs:414— the reporter swap itself: every#1912progress line in the startup repair now goes through it, and it is the only production caller that passes a progress (RepairAsync's other callers are tests, which pass none) — pinned byStartupRepair_LogsItsProgressLines_BeforeItReturns.Lite/Services/QueryStoreSliceRepairService.cs:724— the oneReportsite that sits inside the DuckDB write lock (:653-:725), so this line is the one the change moves onto the lock-holding thread. Checked and benign: Lite'sILoggerisAppLoggerAdapter(MainWindow.xaml.cs:252), whoseLogends atAppLogger.Log→s_buffer.Enqueue(Lite/Services/AppLogger.cs:318-321) with the file write on a 5 s timer, so there is no file I/O and no re-entry intos_dbLockunder the write lock — UNPINNED (no test asserts the logger stays non-blocking).Lite.Tests/SliceRepairStartupTests.cs:137-145and:176-181— aSynchronizationContextinstalled on an xunit worker thread, restored in afinallythat runs on whichever thread resumes the await, with aPosthandler that installs itself on pool threads and never clears it — UNPINNED; inert only while no await in the repair path yields (see the inline comment).
Traps near this diff
- "Lite and Darling ship in parity (CONTRIBUTING's two-store rule): a fix to one SKU's reader, tool, alert or description without the counterpart's is a finding, not a nit" — does not apply:
QueryStoreSliceRepairServiceis Lite-only (DuckDB hot table plus parquet archive,Lite/Services/QueryStoreSliceRepairService.cs:43), and the repo-wideIProgress<grep above shows Darling has noProgress<T>logging adapter to fix in step. - "A test that reads the clock twice measures the CI scheduler: the delta-seeder tests failed at 123 and 243 on unrelated PRs (#3676). One anchor owns both ends of an interval." — does not apply: the new test reads no clock. Its only wall-clock element is the 250 ms wait at
SliceRepairStartupTests.cs:157, which can only false-green; the red comes from the synchronous post count at:147. - "A pin that encodes the lie:
ACappedResult_WithholdsTheUnsatisfiedSummary_…handed the builder two facets atlimit = 2and assertedtruncated— the>= limitinference as its own proof. Ask what a pin would fail ON." — does not apply: the pin fails on aProgress<T>post (:147) and on a missing or out-of-order line (:152-154), neither of which is the inference the production code makes. The one way it could go quietly vacuous is a BCL change toProgress<T>'s callback shape — flagged inline, non-blocking. - "An expression-bodied member holding a
{ }property pattern is read SHORT by the T-SQL convention guard's member walk (#3667; the #3607 precedent) — a member the walk cannot read whole is one no census can see into." — does not apply: the guard scans all ofLite/(Darling/Darling.Tests/TsqlConventionGuardTests.cs:74-93), and this diff adds one expression-bodied member there (InlineLogProgress.Report,:1253) — but it carries no string literal at all, so nothing can escapeEveryTsqlLiteralInTheCorpus_IsAttributedToADeclaredMember. The{ IsGenericType: true }property pattern is inLite.Tests/SliceRepairStartupTests.cs:171, outside the scanned trees (TsqlConventionGuardTests.cs:1434).
Divergence (blind read vs PR body)
One omission, not a defect. The body's account is "the lines are logged inline instead of posted", and it does not mention that one of the four Report sites — QueryStoreSliceRepairService.cs:724, the hot-store line — sits inside the DuckDB write lock, so that line's logging moves from a pool thread onto the lock-holding thread. I chased it because a sink that blocked or re-entered s_dbLock there would be a LockRecursionException on a NoRecursion ReaderWriterLockSlim: it does neither (AppLogger.Log only enqueues, AppLogger.cs:318-321), so the change is safe, but it is a behaviour the body leaves out. Everything else matches: the mechanism, the single new Progress<...> census, the CapturingLogger refactor and its call-site sweep, and the shape of the new test all read as described. The "Actual: 2" in the RED line matches the two reachable Report sites for this fixture, which is corroboration rather than confirmation.
Reviewer checklist
- Run the full
Lite.Testssuite on Windows (not just-class "*SliceRepairStartupTests*"), and ideally twice: the new test installs aSynchronizationContexton an xunit worker thread, and whether any residue escapes onto pool threads is not observable from a read-only tree. - Confirm the pin red-first against the pre-change service and that the failure is the post count (
Expected: 0 / Actual: 2), not the ordering assertion — the ordering half passed on the old code in one run by the author's own account. - On a real pre-#1907 store, read
lite_YYYYMMDD.logafter a startup repair and confirm the#1912progress lines are interleaved in order with the repair's warnings and precede the "repair complete" line.
Fixes #5693.
Why
QueryStoreSliceRepairService.RepairOnStartupAsyncpassednew Progress<string>(...)toRepairAsync.Progress<T>does not run its handler inline: it posts each report to the captured synchronization context, or to the thread pool when there is none. The repair runs from a background task, so the "#1912" progress lines were logged late, on pool threads, and could land after the repair returned and out of order with the warnings logged inline. With a test logger that writes to a plainList, a late line can arrive while a test reads the list; that is howRewrite_WithTooLittleFreeDisk_SkipsTheFile_AndSaysWhyfailed once in CI with "Collection was modified".What changes
Lite/Services/QueryStoreSliceRepairService.cs: a small privateInlineLogProgress : IProgress<string>logs each line on the calling thread, beforeReportreturns. The startup repair uses it instead ofProgress<string>.Lite.Tests/SliceRepairStartupTests.cs:StartupRepair_LogsItsProgressLines_BeforeItReturns. It runs the repair under a synchronization context that forwards to the thread pool and counts posts made by aProgress<T>. It asserts zero such posts, that the archive and views progress lines are in the log when the call returns and in order, and that the log count does not change after a 250 ms wait. It does not depend on timing.CapturingLoggernow owns its list, adds under a lock, and exposesEntriesas a snapshot taken under the same lock (plusClear()). Existing tests readlog.Entries.Other
Progress<T>usersSearched
Lite/forProgress<,new Progress(and: IProgress<: the onlynew Progress<...>is the one fixed here. No existingIProgress<>implementation to reuse. No other service logs throughProgress<T>from a background task, so there is nothing else to change.RED line
Old code, new test:
Assert.Equal() Failure: Values differ / Expected: 0 / Actual: 2(twoProgress<T>posts). An earlier version of the test that only checked for the lines in the log passed on the old code in one run, because the pool delivered them in time, so the test counts the posts instead.Test plan
-class "*SliceRepairStartupTests*":Total: 14, Errors: 0, Failed: 0-class "*Census*":Total: 67, Errors: 0, Failed: 0-trait Stage=Guard:Total: 493, Errors: 0, Failed: 0Changelog
SECTION: Fixed
ENTRY: