Repository navigation
Decide, per site, whether the repair pass can be abandoned at the lock (#2465) - #2467
Merged
Merged
Conversation
|
Reviewed. This is a tightly-scoped, well-documented change and I didn't find correctness bugs. Verified:
No security, injection, or performance concerns — this only changes which token reaches an in-process |
#2465) Twenty CA2016 warnings, five call sites, one file. The analyzer only started saying this once #2454 gave AcquireReadLock a token overload, and what it is pointing at is real rather than stylistic: every method in this service takes the DATABASE LOCK BEFORE it opens its connection, so a token that reaches the reads and not the lock is a token that stops at the door. That is exactly the hole #2454 found on the analysis pass -- abandonable everywhere except where the pass is actually stuck -- and a startup repair queued behind a long archival has the same shape. So the five are decided one at a time, and the split is by what abandoning COSTS rather than by whether the site reads or writes. THREE FORWARD IT. The marker read asks a question and writes nothing. The survey is the longest read here -- a full GROUP BY over query_store_stats and then a read_parquet of every monthly archive file -- and is precisely the thing that should yield to an archival rather than queue behind it. The archive rewrite-to-temp is the expensive one, and abandoning it lands in a state the design already handles rather than a new one: the original is untouched by construction, the marker is withheld, and the next launch retries a repair the pre-fix signature makes idempotent. Each of the three abandons into a state the next launch reproduces for free. TWO DECLINE IT, in CancellationToken.None with the reason at the site, per the convention #2454 set for its fourteen off-pass callers. Both are the marker write, and the marker is the one thing here that records work already done. The post-repair one is the sharper case: the hot table is collapsed and committed and every affected archive file has been swapped, and abandoning the record does not undo any of it -- it only makes the next launch pay the whole survey to rediscover there is nothing left to repair. And reaching either line proves the token was unfired a moment earlier, since the survey (or the entire repair) just returned under it, so forwarding would buy an abandonment window microseconds wide at that price. The declines go all the way through -- the lock, the OpenAsync and the two statements -- rather than stopping at the lock. #2454's InsertFindingsAsync declines only past the lock and the open, and it is right to, because what it must not cut is a multi-row batch that would read as a complete analysis. The marker write has no such shape; what it must not lose is the record itself. A lock that will not be abandoned in front of a write that will be is worse than either choice made whole. Not blanket-suppressed, and the write locks are not swept in with the rest. CA2016 cannot see AcquireWriteLock because there is no token-taking overload, but the better reason to leave those two alone is that they are the genuinely maintenance-shaped acquisitions -- the hot-table mutation with its CHECKPOINT, and the instant a live path's bytes are replaced -- which is the one category #2463 does not put in question. The pin is a source guard because what is being pinned is a DECISION and not a behavior: MainWindow fires this un-awaited with no token, so all five spellings run identically today and nothing observable separates a forward from a decline. That is the reason they need pinning rather than a reason they do not -- an edit that tidied the two declines into forwards, or suppressed the rule for the file, would cost nothing at runtime and quietly turn five stated decisions back into an unknown. The declines are pinned WITH their reason, since a bare CancellationToken.None is the oversight the analyzer complained about with one more token typed in. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
erikdarlingdata
force-pushed
the
fix/2465-ca2016-repair-service
branch
from
August 21, 2026 18:32
6d2c912 to
40bebce
Compare
|
Reviewed. No correctness, parity, security, or performance issues found. What I checked:
Nice work tracing the cost/abandonability distinction per call site rather than blanket-suppressing or blanket-forwarding. |
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.
Closes #2465. Lite only — Darling has no
QueryStoreSliceRepairServiceand no lock of this shape, so there is no twin to keep in step.The twenty are five
20warning lines,5distinct call sites. Each is reported four times: MSBuild builds the WPF project twice (the realPerformanceMonitorLite.csprojand its generated_wpftmppass) and each emits the warning in the log and again in the summary block. All five areAcquireReadLock(); the twoAcquireWriteLock()acquisitions are invisible to CA2016 because that method has no token-taking overload.Why the analyzer is right rather than pedantic
Every method in this service takes the database lock before it opens its connection. So a token that reaches the reads and not the lock is a token that stops at the door — the exact hole #2454 found on the analysis pass, where threading the reads alone would have produced a pass abandonable everywhere except where it was actually stuck. A startup repair queued behind a long archival has the same shape.
It is also worth saying plainly that nothing observable separates the two spellings today:
MainWindow.xaml.cs:193fires this un-awaited with no token, socancellationTokenisdefaultat all five sites andCanBeCanceledis false, which takesAcquireReadLock's original uninterruptible path either way. That is the reason these need deciding and pinning rather than a reason they do not matter — the decision is entirely in what the code says, and a silent no-arg call is indistinguishable from an oversight.The five, decided one at a time
The split is by what abandoning costs, not by whether the site reads or writes.
AlreadyRepairedAsyncEnterReadLock()for an answer the caller has stopped wanting.SurveyAsyncGROUP BYoverquery_store_statsplus aread_parquetof every monthly archive file. This is the one that should yield to an archival rather than queue behind it.RewriteArchiveFileAsync(rewrite-to-temp)COPYstrands the same.repair-tmpsibling a failed one does, the retry'sCOPYoverwrites it, and the archive glob does not match that suffix so a stranded one is never read as an archive file.RepairOnStartupAsync(no-work marker)None, with the reasonRepairOnStartupAsync(post-repair marker)None, with the reason3 forwarded, 2
CancellationToken.Nonewith a comment saying why, matching the convention #2454 set for its fourteen off-pass callers.A detail that decided both declines: reaching either marker write proves the token was unfired a moment earlier, because
SurveyAsync(or the entireRepairAsync) just returned under it. Forwarding there would buy an abandonment window microseconds wide, and what it would risk in that window is the record of work that cannot be redone for free.The declines go all the way through
CancellationToken.Nonereaches the lock, theOpenAsyncand both statements — not just the lock. #2454'sInsertFindingsAsyncdeclines only past the lock and the open, and it is right to: what it must not cut is a multi-row batch that would read as a complete analysis that found fewer problems. The marker write has no such shape — it isCREATE TABLE IF NOT EXISTSplus one row, and a cut between them leaves an empty marker table thatAlreadyRepairedAsynccorrectly reads as "not repaired". What it must not lose is the record, so the decline covers the whole write. A lock that will not be abandoned in front of a write that will be is worse than either choice made whole.Not suppressed, and the write locks are not swept in
No
#pragma, no.editorconfigentry, no file-scoped suppression — per the issue, that converts a list of decidable items into a permanent unknown.The two
AcquireWriteLock()sites are left exactly as they are, and not merely because CA2016 cannot see them. They are the genuinely maintenance-shaped acquisitions — the hot-table mutation with its CHECKPOINT, and the instant a live path's bytes are replaced — which is the one category #2463 explicitly does not put in question. Their no-timeout behaviour is #2463's, and #2463 rules on it.While in the file: the two marker writes are writes taking a read lock, which is the same shape #2455/#2464 ruled correct. Nothing about their locking changed here.
Verification
The build is the check. Full-solution rebuild on macOS (
EnableWindowsTargeting,-t:Rebuildso the analyzer actually re-runs — an incremental build reports zero because it skips the compile):devWarning code sets are otherwise identical (
CA1720×8,xUnit1031×2,xUnit2031×30 on both). One new warning was introduced and fixed before commit: the first draft of the pin usedAssert.Equal(0, …Count)and drewxUnit2013.Lite.Testsisnet10.0-windowsand cannot run on macOS, so the realLite.Tests/QueryStoreSliceRepairTests.cswas compiled into anet10.0xUnit shim together with the real shipped sources —Lite/Database/*.cs,Lite/Services/QueryStoreSliceRepairService.cs,ArchiveService.cs,ParquetCompaction.cs, the realSharedDuckDbFixture, and aProjectReferencetoPerformanceMonitor.Collectors— with one harness-only stub for the single service-layer symbolDuckDbInitializerreaches for (RemoteCollectorService.GetDeterministicHashCode, a pure hash on a legacy re-key path nothing under test calls). That is the same single stub Hand Lite's analysis token to the store reads it is meant to abandon (#2443) #2454's Lite harness needed.10/10 pass on this branch. 2/10 fail on
dev, against a real DuckDB:The second is the pre-existing structural pin, whose anchor for the rewrite-to-temp lock moved with the token — updated deliberately, not incidentally, and it still asserts the same property.
Rebased onto
devat9d1337a5and every check above re-run after: still 0 CA2016, still 10/10 here and 2/10 ondev.CI is the arbiter for the rest of the suite.
The new test
EveryLockAcquisition_SaysWhetherItCanBeAbandonedsits beside the existingEveryMutatingPhase_SitsInsideAWriteLockScopein the same class, deliberately: one pins which lock each phase takes, the other pins whether the phase can be abandoned while it waits for it. Two properties, and splitting them across files would let either be reverted without the other noticing.It asserts zero silent acquisitions, three forwards, two declines, that each decline carries
#2465and the wordmarkerin the preceding source, that each decline is whole (theOpenAsyncand both statements decline too), and that the write-lock count is still two.Co-Authored-By: Claude Opus 5 noreply@anthropic.com
Related: #2469 answers #2463, and agrees with the call made here — the two
AcquireWriteLocksites in this file are maintenance-shaped (clause 1 of the rule it states), and the two marker writes taking a read lock are appends, which is clause-correct too. Neither PR touches the other's files; they can merge in either order.