Skip to content

Hand Lite's analysis token to the store reads it is meant to abandon (#2443) - #2454

Merged
erikdarlingdata merged 12 commits into
devfrom
fix/2443-lite-token-threading
Aug 21, 2026
Merged

erikdarlingdata merged 12 commits into
devfrom
fix/2443-lite-token-threading

Conversation

@erikdarlingdata

@erikdarlingdata erikdarlingdata commented Aug 21, 2026 •

Copy link
Copy Markdown
Owner

Closes the other half of #2443. Darling is #2449; this branch sits on top of it so it compiles, and the diff narrows to Lite's own commits the moment #2449 merges. Merge #2449 first.

Reviewers: the first four commits here belong to #2449 and are already reviewed there. Lite's work is bff0bea9 onward.

What was wrong

#2419 armed a per-pass budget and put a ThrowIfCancellationRequested ahead of every store-touching stage, which closed the user-visible defect in #2412 — a wedged server became loudly stuck instead of silently skipped forever. Its diff touched no store file. So abandonment happened between stages: a collector that had already started ran to completion, holding the read lock and the connection of a pass that had stopped waiting for it.

Re-derived count on dev: 203 untokened async store calls across 17 files in Lite/Analysis/ — ExecuteReaderAsync 65, ReadAsync 65, OpenAsync 67, ExecuteScalarAsync 3, ExecuteNonQueryAsync 3. (The issue estimated ~138. Two of the 203 are SqlPlanFetcher's SqlClient calls, which #2449 already threads; the DuckDB side is 201.) Plus 66 AcquireReadLock() calls in the same files, which turn out to matter more than any of them.

The lock was the real hole

AcquireWriteLock has taken a TimeSpan? since it was written; AcquireReadLock took nothing and blocked in EnterReadLock() until it got in. And the order of every store read on this pass is: take the lock, then open the connection. So a pass queued behind a long archival or a compaction was uninterruptible no matter how carefully its reads were threaded — the token would have reached the reads and stopped at the door. This is the item #2443 called out as "Lite-only, small, and the one place where the reporting genuinely cannot be replaced by cancellation", and threading the reads without it would have produced a pass abandonable everywhere except where it was actually stuck.

It takes a token, not a timeout, and that is the choice rather than a detail: a timeout needs a NUMBER, and the right budget for waiting on this lock is exactly the caller's remaining budget — which the caller already holds and nothing in DuckDbInitializer can know. ReaderWriterLockSlim has no token-taking overload, so a 50 ms poll is the only way to say it; the interval is only reached under contention, and CancellationToken.None takes the original EnterReadLock() path unchanged, so the fourteen callers outside the analysis pass are not quietly re-timed.

DuckDB cancellation, measured rather than assumed

#2419 recorded that a token cancelled at 2 s aborted a 15,233 ms query at 2,004 ms. What it did not record is what that throws, and the whole classifier depends on it — the worry was a DuckDBException needing its own pattern-match. Measured against DuckDB.NET 1.5.5 on net10.0:

result
pre-cancelled token, ExecuteScalarAsync(ct) TaskCanceledException, database never touched
token fired mid-query, ExecuteReaderAsync(ct) System.OperationCanceledException — 2,790 ms query aborted at 2,004 ms
ReadAsync(ct), token fired mid-loop observed, stops at the next row
the interrupted connection afterwards fully usable
a fresh connection to the same file afterwards fully usable

So duckdb_interrupt reaches .NET as a plain OperationCanceledException and there is nothing else to name. AnalysisAbandon.IsExpected is one shape long because that is what was measured, not because it was assumed — and widening it would cost fault evidence for nothing.

Threading the reads alone would have accomplished almost nothing

The fact collector's 27 per-query catches are bare catch { } on purpose — a missing table degrades to "no facts", never an exception — so a fired token would have been caught 27 times and the pass would have carried on to the next collector each time. (#2419's per-collector checkpoint bounds that to one wasted attempt per collector: much better than nothing, and still 27 of them.)

The nine AnomalyDetector catches are worse, because they do not swallow — they log AppLogger.Error. Unfiltered, an ordinary budget expiry would have produced nine ERROR lines describing nine detectors that "failed". BaselineProvider's is the same shape, and is the one that produced five of the seven lines #2299 was filed about on the Darling side.

So all 40-odd now classify through AnalysisAbandon.IsExpected, and an abandonment propagates to the single line AnalysisService logs for it. Catch bodies and comments are unchanged; the only thing that stops being caught is the one thing we asked for.

AnalysisAbandon exists because #2419's inline predicate was right for one site and forty copies of a two-clause predicate is how the clauses drift. Both halves stay load-bearing, and the TYPE half especially: this token fires on an ordinary timeout, so it is signalled during normal running, and a filter asking only "has the token fired?" would relabel any fault landing after the budget as an abandonment — #2419's own first review finding, at forty sites instead of one.

What cancellation means, per caller

Caller Cancellation abandons Threaded?
DuckDbFactCollector.* a read (and its read lock) yes — 93
DrillDownCollector.* a read, plus a live plan fetch on a monitored server yes — 53
AnomalyDetector / BaselineProvider a read yes — 36
FindingStore.FilterMutedFindingsAsync a read of the mute registry yes
FindingStore.InsertFinding* a write no, deliberately

A partial persist cannot happen on the cancellation path

Identical to the Darling twin's decision, reached the same way — a divergence here would be a parity bug, not a local choice. The lock and the connection open are the last abandonment points; past them the batch finishes.

There is no transaction, every row in a batch shares one analysis_time, and GetLatestFindingsAsync takes the newest analysis_time — so a batch cut in half does not read as truncated. It reads as a complete analysis that found fewer problems, and the server looks healthier for having been abandoned. Cancelling inside a row would be worse: duckdb_interrupt can leave a row that did commit, in a set nothing marks as partial — an ambiguous commit for microseconds of in-process work. A partial set is still reachable from a store fault mid-batch; that predates this work and is filed as #2448 against both SKUs.

The 15 exemptions

Four read-back surfaces (the recommendations reader, the viewer's latest-run findings, the MCP mute verb, the retention sweep) plus the finding INSERT — the same five the Darling twin exempts, for the same two reasons. Each is exempt by method name in the test and carries a #2443 exempt note in its own doc comment, with a second test asserting the reverse direction.

The test

Lite.Tests/AnalysisPassTokenThreadingTests, six tests. AcquireReadLock() sits on the forbidden list beside the ADO calls rather than in a check of its own, deliberately: the lock and the reads are one property, and splitting them into two tests would let half of it be reverted quietly.

Two are behavioural. The read-lock test holds a real write lock on another thread — what an archival looks like from here — and asserts the read side gives up when asked. Reverted, it does not fail, it hangs until the writer lets go, which is the defect stated as precisely as a test can state it.

Verification

  • Full solution builds clean on macOS (EnableWindowsTargeting), 0 errors, no new warnings. Each of the five Lite commits builds clean on its own, checked individually.
  • Lite.Tests is net10.0-windows and cannot run on macOS, so the new test file was compiled into a net10.0 xUnit harness together with the real Lite/Analysis/AnalysisAbandon.cs and Lite/Database/DuckDbInitializer.cs sources (one harness-only stub for the single service-layer symbol DuckDbInitializer reaches for — a pure hash on a legacy re-key path that nothing under test calls). 6/6 pass. With Lite/Analysis reverted, the four source pins go red; with the pre-change EnterReadLock() spliced back in, the read-lock test hangs rather than passing. CI is the arbiter for the rest of the suite.
  • The DuckDB cancellation numbers above come from a separate throwaway probe against DuckDB.NET 1.5.5, not from repeating Make the analysis timeout mean something, and stop a wedged server going silent #2419's note.

Deferred

Co-Authored-By: Claude Opus 5 noreply@anthropic.com

erikdarlingdata and others added 12 commits August 21, 2026 17:15
IPlanFetcher.FetchPlanXmlAsync is the only call on the analysis pass that opens a
session on a MONITORED SQL Server rather than on the monitoring store, which makes
it the one an abandoned pass most needs to be able to let go of -- and it was the
one call with no token at all. Its own sibling on the same class,
FetchPlanBySqlHandleAsync, has taken one since it was written, so a cancelled
fetch kept a session alive or not depending on which key the caller happened to
have. Threading it means moving the interface, and the interface moves with all
three implementers and all three call sites in one commit or none of them compile.

The parameter is REQUIRED rather than defaulted, which is the substance of this
commit rather than a detail of it. A defaulted token would have let every existing
call site keep passing nothing while the signature said the fetch was abandonable
-- the exact shape #2443 exists to remove -- and requiring it is what surfaced the
real find here: DarlingWorker's fetch_plan command already had a cancellationToken
in scope and was already passing it to the by-sql_handle arm, and simply did not
pass it to the by-plan_handle arm two lines above. Nothing was defended against
that; the compiler found it the moment the default was refused.

PgPlanFetcher's catch stops swallowing the abandonment it is now handed, for the
same reason the by-sql_handle arm has excluded cancellation since it was written:
an abandoned fetch is not a plan that failed to come back, and logging it as one
would both add an ERROR for work we called off and let the drill-down carry on
enriching under a token that had already fired.

The deprecated Dashboard implementation moves too. It is still in the solution and
still built by CI, so leaving it behind would have been a red build rather than a
decision.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
146 store calls across the fact collector and the drill-down collector took the
no-argument overload, which is why #2438's budget could abandon a pass between
stages but never inside a read. A wedged query held its connection until it
finished on its own, and the service that had already given up on that server sat
there holding a session it was no longer waiting for.

Threading the reads alone would have accomplished almost nothing, and that is the
part worth reading twice. The fact collector's per-query catches are bare
`catch { }` on purpose -- a missing table degrades to "no facts", never an
exception -- so a fired token would have been caught thirty times over and the
pass would have carried on to the next collector each time, dialling the store
again to be told again. The measured proof is in the behavioural test: reverted,
it fails with an NpgsqlException instead of an OperationCanceledException, which
is the collector connecting to the store while holding a token that had already
been cancelled before the call.

So all 27 of those catches now carry the classification the rest of the pass has
used since #2299 -- `when (!AnalysisShutdown.IsExpectedAbandon(ex, ...))` -- and
an abandonment propagates to the single line the pass logs for it instead of being
swallowed per-metric. The catch bodies and their comments are unchanged; the only
thing that stops being caught is the one thing we asked for. Four collect methods
have no catch at all and never did: their reads propagate straight up, which is
the same outcome by a shorter route.

AppendDmvSnapshotRowsAsync takes a required token rather than a defaulted one for
the reason the plan fetcher did: three callers share it, two are on the pass, and
a default would have let either keep passing nothing silently. The viewer is the
third and it genuinely has no pass to abandon -- it says so at its own call site
rather than in the shared method, because the exception belongs to the caller.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…written"

The finding store is the one place on this pass where cancellation abandons a
WRITE rather than a read, and #2443 asked for that to be decided rather than swept
along with everything else. It is decided here in the direction that produces
fewer outcomes, not more.

The mute-filter read gets the token like every other read: an unreadable mute
registry fails open by design, which is right, but "the mutes could not be read"
and "we stopped reading" are different answers and only the first should fail
open. Swallowing the second would have let the pass go on to enrich and persist an
unfiltered finding set under a token that had already fired.

The insert does not, and its connection open is deliberately the last abandonment
point on the pass. A half-written finding set is a third outcome, and unlike the
timeout and the shutdown that #2430's classifier names, this one is invisible.
There is no transaction -- per-row failure isolation is the Dashboard twin's
posture, one bad row logs and the batch continues -- every row in a batch shares
one analysis_time, and GetLatestFindingsSql keys on MAX(analysis_time). So a batch
cut in half does not read as truncated. It reads as a complete analysis that found
fewer problems, which means the server looks HEALTHIER for having been abandoned,
with nothing anywhere marking the set incomplete. Cancelling before the first row
costs this cycle's findings and says so in the line the pass already logs;
cancelling after it quietly changes what the store means.

Cancelling inside a single row would be worse still and buys nothing measurable:
Npgsql's cancel is a request to the server, so a cancelled INSERT can leave a row
that did land, in a set nothing marks as partial -- an ambiguous commit in
exchange for milliseconds of loopback work. The tail is affordable to finish: N
small INSERTs against the local managed store, which is not where a pass wedges.
This is the same call DarlingAnalysisService made a layer up in #2299 ("the
post-enrichment tail carries no check"), restated at the write it protects so the
next person to read it finds the reasoning at the code rather than two files away.

The read-back surfaces -- the viewer's findings and mute reads, the MCP mute verb,
the retention sweep -- are exempt for a different reason and are marked as such
one by one: they are off the pass entirely, serving lifetimes with no per-pass
budget and no wedged analysis, so there is no token to thread and inventing one
would be pretending. Note that a partial set is still reachable today from a store
FAULT mid-batch, which this commit does not change and which is filed separately;
what it does change is that the cancellation path can no longer reach it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The threading is mechanical; this is the part that lasts. A sweep of 168 call
sites only stays swept if something counts it, and the specific way this one would
regrow is known in advance: add `CancellationToken cancellationToken = default` to
a signature, stop there, and every existing call site keeps passing None while the
code LOOKS threaded. So the pin is on the CALL and not the signature. A
no-argument ExecuteReaderAsync() is what goes red, and no default can hide it.

It scans the whole analysis project directory rather than a file list, because a
file list is a thing a seventh PgFactCollector partial can be added outside of.

The exemptions are enumerated by method name with a reason each, and the reason
also has to appear in the method's own doc comment -- the enumeration is here so a
new one cannot arrive silently, and the marker is there so the reason travels with
the code. The exemption list this repo spent a day removing was a wildcard, and
the way it grew was that nobody ever had to name a new entry. A second test asserts
the reverse direction too, because an exemption that no longer corresponds to an
untokened call is a claim the code stopped making, and it must not sit there
authorising the next one.

Three of the five are behavioural rather than textual. The fact-collector catch pin
is what stops the token being silently useless -- a bare catch there swallows the
abandonment and the pass keeps collecting under a fired token. The collection test
runs a pre-cancelled pass with no store at all: reverted, it fails with an
NpgsqlException instead of an OperationCanceledException, which is the defect
stated exactly -- handed a token that had already fired, the collector still
dialled. The insert test pins the abandonment point itself, including the absence
of a between-rows check, because adding one there would MAKE the partial set rather
than prevent it and that is not obvious from reading the loop.

All five go red with the change reverted, verified by reverting it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…l as the room

AcquireWriteLock has taken a TimeSpan? since it was written; AcquireReadLock took
nothing and blocked in EnterReadLock() until it got in. That asymmetry is the one
place in #2412's story where cancellation genuinely could not be replaced by
better reporting, and it is why the observability half of #2419 was load-bearing
rather than a fallback.

The reason it matters is the ORDER of a store read on the analysis pass. Every one
of them takes this lock first and opens its connection second, so a pass queued
behind a long archival or a compaction was uninterruptible no matter how carefully
its reads were threaded -- the token would have reached the reads and stopped at
the door. Threading the reads without this would have produced a pass that could
be abandoned everywhere except where it was actually stuck.

A token rather than a timeout, and that is the substance of the choice. A timeout
needs a NUMBER, and there is no honest one to pick here: the right budget for
waiting on this lock is exactly the caller's remaining budget, which the caller
already holds and which nothing in this class can know. Handing it the token says
that directly.

ReaderWriterLockSlim has no token-taking overload, so a poll is the only way to
say it. The interval is only ever reached under contention -- an uncontended
TryEnterReadLock returns immediately -- and CancellationToken.None takes the
original EnterReadLock() path unchanged, so the fourteen callers outside the
analysis pass keep exactly the behaviour they had rather than being quietly
re-timed by a change they did not ask for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…name

#2419 put the judgement inline, in one catch, as `catch (OperationCanceledException
ex) when (context.CancellationToken.IsCancellationRequested)`. That was right for
one site. Threading the store layer needs the same judgement at forty-odd catch
sites, and forty copies of a two-clause predicate is how the clauses drift, so it
moves to AnalysisAbandon.IsExpected unchanged -- signalled token AND a cancellation
shape, both halves still load-bearing.

The TYPE half especially. Since #2419 this token fires on an ordinary timeout, so
it is signalled during perfectly normal running; a filter asking only "has the
token fired?" would relabel any genuine fault landing after the budget elapsed as
an abandonment and swallow the one line of evidence it left -- at forty sites at
once instead of one. That was #2419's first review finding and it gets worse, not
better, with scale.

The shape list is one entry long because that is what was MEASURED. Darling's
classifier also names a disposed data source and the 57P0x trio because its store
is a separate postmaster that can vanish under an in-flight read; Lite's is an
embedded file in this process, and the concern was that DuckDB's interrupt would
surface as a DuckDBException needing its own pattern-match. It does not. Against
DuckDB.NET 1.5.5 on net10.0: a pre-cancelled token throws
OperationCanceledException without touching the database; a token fired mid-query
reaches duckdb_interrupt through DuckDBCommand.Cancel() and also surfaces as
OperationCanceledException; a 2,790 ms query cancelled at 2,000 ms aborted at
2,004 ms; and both the interrupted connection and a fresh one against the same
file were fully usable afterwards. So naming the one shape loses nothing the
cancellation actually produces, and widening it would cost fault evidence.

The data-span probe is the first read the pass makes and the first to be threaded.
Its bare `catch { return 0; }` had to go with it: a probe failure reading as "no
data yet" is right, but an abandonment reading that way turns a cancelled pass
into an insufficient-data SKIP, which is a different and far calmer-looking answer
than the one the caller is about to log.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…es eating it

188 store calls across the fact collector, the drill-down collector, the anomaly
detector and the baseline provider took the no-argument overload, which is why
#2419's budget could abandon a pass at a stage boundary but never inside a read.
A collector that had already started ran to completion, holding the read lock and
the connection of a pass that had stopped waiting for it.

Threading the reads alone would have accomplished almost nothing, and that is the
part worth reading twice. The fact collector's 27 per-query catches are bare
`catch { }` on purpose -- a missing table degrades to "no facts", never an
exception -- so a fired token would have been caught 27 times and the pass would
have carried on to the next collector each time, taking the lock again to be told
again. #2419's per-collector ThrowIfCancellationRequested bounds that to one
wasted attempt per collector, which is much better than nothing and still 27 of
them.

So every one of those catches now classifies through AnalysisAbandon.IsExpected,
and an abandonment propagates to the single line AnalysisService logs for it
instead of being swallowed per collector. The catch bodies and their comments are
unchanged; the only thing that stops being caught is the one thing we asked for.
Four collect methods have no catch at all and never did -- their reads propagate
straight up, which is the same outcome by a shorter route.

The nine AnomalyDetector catches are the loudest of the lot, because they do not
swallow, they log AppLogger.Error. Unfiltered, an ordinary budget expiry would
have produced nine ERROR lines describing nine detectors that "failed". The
BaselineProvider catch is the same shape and is the one that produced five of the
seven lines #2299 was filed about on the Darling side.

AppendDmvSnapshotRowsAsync takes a required token rather than a defaulted one:
three callers share it, two are on the pass, and a default would have let either
keep passing nothing silently. The viewer is the third and genuinely has no pass
to abandon -- it says so at its own call site rather than in the shared method,
because the exception belongs to the caller that has it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…hing was written"

The same decision as the Darling twin's, reached the same way, because the two
finding stores have the same shape and a divergence here would be a parity bug
rather than a local choice.

The mute-filter phase gets the token like every other read. The insert does not,
and its lock acquisition and connection open are deliberately the last abandonment
points on the pass. A half-written finding set is an outcome that reads like a
healthy one: there is no transaction, every row in a batch shares one
analysis_time, and GetLatestFindingsAsync takes the newest analysis_time -- so a
batch cut in half does not read as truncated, it reads as a complete analysis that
found fewer problems. The server looks HEALTHIER for having been abandoned, with
nothing marking the set incomplete. Cancelling before the first row costs this
cycle's findings and says so in the line the pass already logs; cancelling after it
quietly changes what the store means.

Cancelling inside a row would be worse and buys nothing: DuckDB's cancel is
duckdb_interrupt, so an interrupted INSERT can leave a row that did commit, in a
set nothing marks as partial -- an ambiguous commit in exchange for microseconds
of in-process work. And this restates at the write the same call AnalysisService
made a layer up in #2419 ("the post-enrichment tail carries no check on purpose"),
so the reasoning sits at the code rather than two files away.

The four read-back surfaces -- the recommendations reader, the viewer's
latest-run findings, the MCP mute verb, the retention sweep -- are exempt for a
different reason and are marked one by one: they are off the pass entirely,
serving lifetimes with no per-pass budget and no wedged analysis, so there is no
token to thread and inventing one would be pretending.

A partial set is still reachable from a store FAULT mid-batch, which this does not
change and which is filed as #2448 against both SKUs; what it does change is that
the cancellation path can no longer reach it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The twin of Darling.Tests' pin, guarding the same regrowth: add
`CancellationToken cancellationToken = default` to a signature, stop there, and
every existing call site keeps passing None while the code LOOKS threaded. So the
assertion is on the CALL. A no-argument ExecuteReaderAsync() is what goes red, and
no default can hide it. It scans the whole Lite/Analysis directory rather than a
file list, because a file list is a thing a seventh DuckDbFactCollector partial
can be added outside of.

AcquireReadLock() is on the forbidden list beside the ADO calls rather than in a
check of its own, and that placement is the point. Every store read on this pass
takes the lock BEFORE it opens its connection, so a pass whose reads had tokens
and whose lock did not would still be uninterruptible behind an archival -- the
two are one property and splitting them into two tests would let half of it be
reverted quietly.

The exemptions are enumerated by method name with a reason each, and the reason
also has to appear in the method's own doc comment: the enumeration is here so a
new one cannot arrive silently, the marker is there so the reason travels with the
code. A second test asserts the reverse direction, because an exemption that no
longer matches an untokened call is a claim the code stopped making and must not
sit there authorising the next one.

Two of the six are behavioural. The classifier truth table pins both halves of the
predicate, including the one that matters most -- a fault that merely coincides
with the budget expiring is still a fault. The read-lock test holds a real write
lock on another thread, which is what an archival looks like from here, and
asserts the read side gives up when asked; with the pre-change EnterReadLock()
spliced back in it does not fail, it HANGS until the writer lets go, which is the
defect stated as precisely as a test can state it.

Verified by reverting: the four source pins go red, and the read-lock test cannot
even be compiled against the old signature.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both findings check out and the first one is the substantive one.

I wrote in the PR description that the IPlanFetcher move was "signature and
call-site edits only -- no Lite behaviour changes here". That is not true for
cancellation semantics, and review said so precisely. Lite arms a real per-pass
budget (#2412/#2419), the token now reaches SqlPlanFetcher's OpenAsync and
ExecuteScalarAsync through DrillDownCollector.Plans.cs, and its catch was
unconditional -- so the first time a Lite pass abandoned mid-fetch it would have
logged "Failed to fetch plan for handle ...: The operation was canceled." as an
ERROR for work we ourselves called off, and swallowed it, letting the pass carry
on enriching under a fired token instead of unwinding to AnalysisService's one
quiet line. That is the exact pattern this PR spends its diff removing on the
Darling side, reintroduced in Lite by the change that made the token real. The arm
matches PgPlanFetcher's for the identical call shape.

The deprecated Dashboard implementation has the same unconditional catch and is
left alone: its scheduler calls AnalyzeAsync without a token at all, so nothing
there can fire and adding a filter would imply a cancellation that cannot happen.

The second finding is a doc-comment defect in my own insertion. Six #2443-exempt
<para> blocks in PgFindingStore were appended to the END of the preceding summary
line rather than starting on their own, so the literal "///" became part of the
doc text and would have rendered as stray slashes. Worth more than its size: this
is the file whose exemptions are the argument for the whole sweep, and a reader
who does not trust the formatting will not trust the reasoning either.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The same doc-comment defect review found in the Darling twin, in the same four
places for the same reason: each #2443-exempt <para> block was appended to the END
of the preceding summary line instead of starting on its own, so the literal "///"
became part of the doc text and would have rendered as stray slashes.

Worth more than its size in this file specifically. These four exemptions are the
argument for withholding the token from a whole surface, and a reader who does not
trust the formatting will not trust the reasoning either.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Keeps this branch buildable on top of #2449 while that PR takes review. The two
commits picked up are the Lite plan-fetcher catch (which belongs to #2449 because
that is where its token arrives) and the Darling half of the doc-comment fix this
branch just made to its own twin.
@erikdarlingdata
erikdarlingdata merged commit 60c696f into dev Aug 21, 2026
7 checks passed
erikdarlingdata added a commit that referenced this pull request Aug 21, 2026
#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
erikdarlingdata deleted the fix/2443-lite-token-threading branch September 12, 2026 20:29
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.

1 participant