Skip to content

Part of #2874 - #2966

Merged
erikdarlingdata merged 2 commits into
devfrom
fix/2874-sweep-adopt-shared-scanner
Sep 5, 2026
Merged

erikdarlingdata merged 2 commits into
devfrom
fix/2874-sweep-adopt-shared-scanner

Conversation

@erikdarlingdata

@erikdarlingdata erikdarlingdata commented Sep 5, 2026

Copy link
Copy Markdown
Owner

The collection-sweep pin was asking the #2874 family's deadline question its own way. CommandDeadlineScanner (#2938) is the shared judgement, and this pin kept a private s_setsTimeout and a SetsTheSweepDeadline wrapper instead. Same divergence #2913 fixed for CSharpSourceWalker: private copies of one judgement, already drifted, only some carrying the hardening.

This does not close the family, and the inventory is worth stating exactly — re-derived after merging origin/dev, which brought in #2940. Measured by grep -l 'CommandDeadlineScanner.SetsAnExplicitDeadline' *.cs, six pins had adopted the shared judgement before this change — StorageCommandTimeoutTests, ViewerCommandTimeoutTests, AnalysisPassCommandTimeoutTests, FactCollectorCommandTimeoutTests, AlertPassCommandTimeoutTests and CommandPlaneCommandTimeoutTests — and this makes seven.

Two holdouts remain and are NOT touched here: StragglerCommandTimeoutTests.cs (private s_setsTimeout at line 100, used at line 548) and McpReadCommandTimeoutTests.cs (line 74, used at lines 158, 497, 505, 534 and 539). Both still carry exactly the single-span rule this change argues is unsound, so both plausibly hold the same live false-accept blind spots this fixed for the sweep. They are left for separate assessment rather than folded in: McpRead's surface is ~119 sites, so re-deriving its census is a scope call, not a rider on this one.

This is a behaviour change, not a tidy-up

The private copy was a SINGLE span — a bare CommandTimeout\s*= over the two statements from the construction. The shared judgement asks in two halves, and each half closes a false ACCEPT the single span cannot see:

  • The following construction's own initializer. An untimed command directly ahead of a timed one sits inside the untimed one's statement window, so the window's deadline is real — it just belongs to the next site. Reading initializers from the CONSTRUCTION span excludes it.
  • A sibling's assignment inside an untimed using header's block, which spends both counted statements on the sibling. It is in the same scope and in the very next statement, so no statement count or scope bound can exclude it — only the NAME the assignment qualifies can.

Both arrive as permanent fixtures in TheDeadlineScanner_DoesNotBorrowANeighboursDeadline, with a positive control so the theory cannot pass by rejecting everything.

The censuses were RE-DERIVED, not adjusted

The two-span rule is stricter in one direction: the assignment half requires name-qualification against the bound variable, so a site assigning CommandTimeout through a differently-named reference would newly report untimed. That would be a finding, not a number to tune — so both constants were re-derived by enumerating the real sources through the pin's own s_sweepMembers, MemberBody and s_commandCtor, and asking both rules at every site.

ExpectedSweepCommandSites = 13, unchanged. ExpectedCopyWriterSites = 4, unchanged. Zero sites disagree between the old rule and the shared one, so the offender list stays empty and nothing was reclassified.

All thirteen are answered by the name-bound half, none by the initializer half — so the stricter rule is not merely reachable for this pin's shapes, it is the only path that carries them. Two do it through receivers not called command: isDue and upsert, both _postgres!.CreateCommand sites in TryRefreshPgStatementTextAsync, which the BoundName walk resolves through a using header declaration and a plain declaration respectively.

What deliberately did NOT change

The scanner holds no construction regex — it takes an offset the caller found — so adoption cannot narrow construction matching. s_commandCtor stays the pin's own, qualified new Npgsql.NpgsqlCommand( alternative and bare .CreateCommand method group included. Kept for the same reason: the twelve (file, member) pairs of member scoping, which exist because .Service spans budget regimes; the store-versus-target receiver classifier, which has no equivalent in the shared helper and which .Storage and .Viewer never needed; and the COPY half, whose NpgsqlBinaryImporter.Timeout is a different property on a different type that the scanner knows nothing about.

Verification

Windows-only suite, so CI is the arbiter. Locally the REAL .cs files were compiled by absolute path into a throwaway net10.0 xunit host with AssemblyName=Darling.Tests, staged inside the repo tree: 34 tests, 0 failed, 0 skipped, 0 not run, up from 31 — the three added fixtures. The real net10.0-windows project also rebuilds with EnableWindowsTargeting=true: 0 errors, and no doc-comment cref left dangling by the deleted regex.

Proved red first, one mutation at a time, each confirmed applied by anchor count and content hash and each confirmed to have relinked the assembly, so no green came from a stale binary:

  • Reverting the judgement to the old single-span rule fails both new negative fixtures at Expected: False, Actual: True — the two false accepts, reproduced — while the census stays green, which is the same evidence that the thirteen real sites are unmoved.
  • Breaking the receiver classifier's store factory fails EverySweepBodyCommand_IsBuiltAgainstTheStore_NotAMonitoredTarget and both of its unit fixtures.
  • Breaking the COPY half's importer property reports all four writers as offenders, by name.
  • Dropping one member from the scoping list moves the census to 12.

No CHANGELOG.md entry — that file is the coordinator's, and this entry is reported to them. No production code, no schema bump, no migration rung.

Re-verified after merging origin/dev (which brought #2940 and #2965): both censuses unchanged at 13 and 4, zero old-versus-new disagreements, 34 tests green, and the real project still rebuilds with 0 errors. ServiceCommandDeadlines.CollectionSweepSeconds is still 10 and DarlingWorker.SweepWatchdogSeconds still 60, so the band assertion is unaffected.

@claude

claude Bot commented Sep 5, 2026

Copy link
Copy Markdown

Reviewed the diff (test-only change in Darling/Darling.Tests/CollectionSweepCommandTimeoutTests.cs, consolidating onto CommandDeadlineScanner.SetsAnExplicitDeadline). The mechanics check out: I traced the new TheDeadlineScanner_DoesNotBorrowANeighboursDeadline fixtures against CommandDeadlineScanner.SetsAnExplicitDeadline / BoundName / StatementSpanFrom by hand and all three match the shared scanner's actual behavior (initializer read from the construction span only, assignment read from the statement span gated on the bound name). No production code is touched, and there's no Lite counterpart to this Npgsql-specific harness, so parity doesn't apply here.

One thing worth flagging: the PR description says this closes out "the last pin in the #2874 command-timeout family still asking the deadline question its own way," listing .Storage, .Viewer, .Analysis, PgFactCollector, the alert pass, the stragglers, and the MCP reads as the seven that already route through CommandDeadlineScanner. That's not what's on this branch:

  • StragglerCommandTimeoutTests.cs:100 still declares its own private static readonly Regex s_setsTimeout = new(@"CommandTimeout\s*=", ...) and matches it directly against CSharpSourceWalker.StatementSpanFrom(code, ctor.Index, statements: 2) at line 141/143 — no call to CommandDeadlineScanner anywhere in the file.
  • McpReadCommandTimeoutTests.cs:74 has the identical private s_setsTimeout field, used the same bare way at lines 158, 497, 505, 534, 539 — also no CommandDeadlineScanner reference.

By contrast, AlertPassCommandTimeoutTests.cs, FactCollectorCommandTimeoutTests.cs, CommandPlaneCommandTimeoutTests.cs, ViewerCommandTimeoutTests.cs, AnalysisPassCommandTimeoutTests.cs, and StorageCommandTimeoutTests.cs do all call CommandDeadlineScanner.SetsAnExplicitDeadline, confirming six pins (not five, and not including stragglers/MCP) have actually migrated.

This isn't just a doc nit: the bare s_setsTimeout these two files still carry is exactly the pattern this PR's own commentary says is unsound — an unqualified CommandTimeout\s*= over a two-statement window accepts a following construction's own initializer and a sibling's assignment inside an untimed using block as if they were the site's own deadline (the two false-accepts CommandDeadlineScanner.Assigns/name-binding was built to close). So StragglerCommandTimeoutTests and McpReadCommandTimeoutTests likely still have live false-accept blind spots identical to the one this PR just fixed for the collection sweep, and the "last pin" framing risks the remaining two never getting picked up as follow-up work.

Worth either correcting the PR description's inventory, or opening a tracking issue for migrating those two files onto CommandDeadlineScanner before calling this family closed.

@claude

claude Bot commented Sep 5, 2026

Copy link
Copy Markdown

Reviewed. This is a clean, test-only change (Darling/Darling.Tests/CollectionSweepCommandTimeoutTests.cs) that retires the pin's private s_setsTimeout/SetsTheSweepDeadline duplicate in favor of the shared CommandDeadlineScanner.SetsAnExplicitDeadline (#2938), matching the consolidation already done for the other six timeout pins.

Traced the three new TheDeadlineScanner_DoesNotBorrowANeighboursDeadline fixtures by hand against CommandDeadlineScanner/CSharpSourceWalker:

  • Fixture 1 (untimed command directly followed by a timed one): ConstructionSpanFrom only returns the site's own argument list/initializer, so the neighbour's initializer isn't borrowed — correctly false.
  • Fixture 2 (using (var command = ...CreateCommand()) header whose block opens with a timed sibling): the header's own closing paren stops ConstructionSpanFrom before it ever reaches the block brace, and Assigns("command") correctly fails to match sibling.CommandTimeout = 10 — correctly false.
  • Fixture 3 (name-qualified assignment two statements later): Assigns("command") matches command.CommandTimeout = ... — correctly true.

No leftover references to the removed private regex/wrapper, doc-comment <see cref> updates point at the right shared members, and the file stays in the same namespace so no new using is needed.

No Lite/Darling parity concern — this scanner infrastructure (Npgsql command-timeout pins) has no Lite counterpart. No production code, T-SQL, or security-relevant surface touched. Nothing to flag.

@erikdarlingdata
erikdarlingdata merged commit 06769b7 into dev Sep 5, 2026
7 checks passed
erikdarlingdata added a commit that referenced this pull request Sep 5, 2026
… prose

The count of pins routing through CommandDeadlineScanner was stated in doc
comments and checked by nothing, so it went stale three times: #2938 wrote
five, #2940 made it six, #2966 seven, and this branch nine. The enumerated
list beside the count rotted the same way, which is the worse half - a reader
looking for the adopters found four names and no hint that others existed.

CommandDeadlineScannerAdoptionTests globs the pins and re-derives the set from
the tree, against a declared adopter list and a declared abstainer list. The
abstainer half is what earns the test: a new *CommandTimeoutTests.cs arriving
with its own private copy of the rule lands in neither list and fails asking
which it is, which is how the two holdouts consolidated here came to exist.
StartupCommandTimeoutTests is the one declared abstainer, because it judges
every site relationally against its own bootstrap constant and the shared
scanner cannot express which constant a site must take.

The membership test reads STRIPPED source, so prose naming the method does not
count as routing through it - every file in this family discusses the shared
judgement at length, and a raw read would pass on the commentary.

The prose counts in CommandPlaneCommandTimeoutTests and CSharpSourceWalkerTests
are dropped rather than corrected, since a number nothing asserts is what
failed here. CommandDeadlineScanner.cs's own stale count is left alone to avoid
colliding with the deletion in flight against that file.
erikdarlingdata added a commit that referenced this pull request Sep 5, 2026
#2981)

CommandDeadlineScanner's class summary opened with a count and a roster of
the pins routing through it. Nothing checked either, and both drifted: the
count was written five, then six, then seven, then nine over #2938, #2940,
#2966 and #2972, and the enumerated list never moved at all.

CommandDeadlineScannerAdoptionTests already re-derives the adopting set from
the tree and declares the deliberate abstainers beside it, so the summary now
points there instead of restating it. Nothing left in the comment is
countable, so there is no figure left to go stale.

The same file quoted the MCP read surface's census as "112 of the MCP
surface's 119 sites" to argue that the assignment spelling dominates. That
numerator no longer holds - every site in McpReadCommandTimeoutTests' scope
is assignment-spelled today - so the claim keeps its point and hands the
census back to the pin that maintains it.

The adoption pin's own rationale counted the names in the roster it replaced.
That figure described a list this change removes, so it goes too.
@erikdarlingdata erikdarlingdata mentioned this pull request Sep 5, 2026
erikdarlingdata added a commit that referenced this pull request Sep 5, 2026
…nd name a test class that exists

The three comments crediting #2966 for last_error's missing timestamp were crediting the wrong
issue: #2966 is about pin-count adoption, and nothing in the tree connects it to this. The
lesson is #3010's, measured on the managed PostgreSQL fleet and pinned by LastErrorCurrencyTests
- which also declines to make its own predicate a band input, for the same reason this block is
not one.

Lite's block pointed at LiteAlertReadSurfaceTests, which does not exist; the pin is Lite.Tests'
AlertReadFailureSurfaceTests. And the two stacked comment blocks over last_failure_at are one.
erikdarlingdata added a commit that referenced this pull request Sep 5, 2026
* Count the alerting layer's swallowed store reads, and put the count where collection health is

Every condition check in the alert pass wraps its store read in log-and-skip: on a failure it
writes one [ERROR line and returns, because firing on absent evidence fabricates an alert and
resolving on it fabricates a recovery. That posture is correct and is unchanged. What was missing
is that a swallowed read reached no surface a person reads - it is not a collector run, so it
writes no collection_log row, so get_collection_health stayed green while the alert pass went
blind one condition at a time. Only a grep of the service log found the class.

Twenty-seven such sites across the shared engine, Darling's self-alert evaluator and the worker's
PostgreSQL predictor passes now record the failure on a process-lifetime counter, naming which
read went blind. Both SKUs' get_collection_health carries an alert_read_health block: the
per-server count, the alert-pass count it sits over, the instance total (which is the only home
the fleet-scoped store self-alerts have), the newest failure's timestamp and read name, and
counting_since. The web dashboard's Collection Health fanout gains a panel on both server tabs,
with its own subtitle because these figures are not the trailing seven days every sibling panel
on that tab reports.

In memory and deliberately not persisted: what it counts is a failure to read the store, so a
counter that had to write the store would be unavailable exactly when it has something to say.
Not a band and not a status input, for #3017's reason one level down - a threshold here would
have to guess how many blind reads make alerting unhealthy, and a wrong guess on this surface
fails by saying nothing is wrong.

* Say what counting_since is precisely, and drop a null-conditional that can never fire

The worker's counter field is non-nullable and initialized to the process instance, so the
null-conditional at its eight call sites could never short-circuit and read as if the field
might be absent. The engine's and the self-alert evaluator's fields stay nullable — they take
the counter as an optional constructor argument so a test can build one that counts nothing.

counting_since is when this counter FIRST counted, which is early in the host's own startup
rather than literally the process's first instruction. Both tool descriptions say so identically
and the shared note matches; a surface that overstates its own floor by a few hundred
milliseconds is a small version of the defect this block exists to remove.

Also documented at the seam: a null key reads as no server rather than as the fleet bucket, and
the fleet bucket reaches every reader through instance_read_failures instead. RecordPass takes a
nullable key for symmetry with RecordReadFailure, and no caller passes null today.

* Serialize the two new timestamps the way every other timestamp on this response is

last_success, last_error_at and last_denied_at on the collector rows all go out as round-trip
"o". A raw DateTime serializes to an ISO string too, but with trailing zeros trimmed, so
last_failure_at and counting_since would have carried different precision guarantees from their
neighbours on one payload for no reason. Applied identically on both SKUs.

* Cite the issue that actually taught the last_error currency lesson, and name a test class that exists

The three comments crediting #2966 for last_error's missing timestamp were crediting the wrong
issue: #2966 is about pin-count adoption, and nothing in the tree connects it to this. The
lesson is #3010's, measured on the managed PostgreSQL fleet and pinned by LastErrorCurrencyTests
- which also declines to make its own predicate a band input, for the same reason this block is
not one.

Lite's block pointed at LiteAlertReadSurfaceTests, which does not exist; the pin is Lite.Tests'
AlertReadFailureSurfaceTests. And the two stacked comment blocks over last_failure_at are one.

* Stop the two remaining doc comments overstating when counting begins

CountingSince is the first touch of the counter, which for the process instance is early in the
host's startup rather than the process's first instruction. The shipped note and both tool
descriptions already said so; these two XML comments still said "process start", one line from
the citation they were sitting beside.

The note's own text is unchanged - only where its source lines break. Verified by dumping the
constant out of the built assembly: 1,536 characters before and after.

* Record the PostgreSQL predictor group as the third alert pass, and pin pass/failure pairing

Review found it: the six PostgreSQL predictor checks each recorded a swallowed read and none
recorded a pass, so a PostgreSQL target reported two passes for three while its failures landed
in the numerator normally. A guarded numerator over an unguarded denominator is worse than
neither, because the pair still renders and now understates its own exposure.

One pass for the whole group rather than one per check. The six are independently
failure-isolated exactly as the shared engine's fourteen Check*Async calls are, and those
fourteen are one pass: isolation granularity is not pass granularity. Recorded after the
null-store guard, for the same reason the engine records after its master switch.

The note and both tool descriptions said "two passes per sweep", which stopped being true the
moment a third existed. They now give the inventory per host and per target engine and say the
denominator is comparable within a host and engine and not across them.

EveryAlertEvaluationPass_RecordsItselfInTheDenominator is the guard the census tests did not
provide: they proved every swallowed read is counted and said nothing about whether the pass
that issued it is. It asserts per entry point AND asserts the tree-wide RecordPass count equals
the number of entry points, so a fourth pass that forgets to record fails, and a RecordPass
placed somewhere that is not a pass fails too.

* Assert each arm of the pass inventory separately, after red-proofing found one clause enough

The first draft of the inventory check asserted only that "runs three" appeared in the note.
Red-proofing it against a note whose SQL Server arm had been broken came back GREEN: one true
clause is not a true inventory. Each of the three arms and the cross-engine disclaimer is now a
separate assertion, so breaking any one of them fails.

* Census any caught type, not just Exception, and prove the one exclusion is safe

The scanner matched `catch (Exception` only, so a swallowed read behind a narrower type inside a
scoped member reported CLEAN. That is not hypothetical here: FetchFailedJobsAsync swallows a
failed msdb read in `catch (SqlException ex) when (IsPermissionDenied(...))` and the census could
not see it. Nothing was miscounted today, because that member is exempt for its own stated reason
and now has an entry for both arms rather than one - but the gap was the #2786 shape the rest of
this file was written to avoid, found by auditing the caught types rather than trusting that they
were all Exception.

OperationCanceledException stays out, and no longer on faith:
NoCancellationCatch_QuietlySwallowsAReadFailure asserts every such block in scope either rethrows
or logs nothing at error level, with a floor on how many it examined so the assertion cannot pass
vacuously. A block that logged an error and returned would be a swallowed read hiding behind the
one type the census does not look at.

Two scanner controls added beside the existing ones: a narrower caught type WITH a `when` filter
must be reported, and a cancellation catch must not be a census subject at all.

* Assert the pass is recorded BEFORE the reads, not merely somewhere in the method

Presence cannot see placement. A RecordPass moved inside the try - after the reads rather than
before them - records the pass only on cycles that succeeded, so a cycle whose read failed would
add to the numerator and nothing to the denominator. That is the same defect as omitting the call
entirely, arriving through position instead of absence.

Asserted structurally rather than behaviourally on purpose: reaching these bodies at runtime needs
a live store, and a test that opens a socket to prove a static ordering is a flaky test proving a
fact the source already settles.

The arm is conditional, because not every pass entry point owns a try - AlertEngine's
EvaluateCoreAsync dispatches fourteen checks that each own theirs - and it counts how many entry
points it actually reached, with a floor, so it cannot go vacuous for all three unnoticed. Matched
as the try STATEMENT rather than the substring, since "try" occurs inside retry, entry and
geometry.

* Isolate the CPU read from the sweep, single-source the fleet set, and stop counting a target read

Three findings from review, judged on their own merits and measured at the site rather than
reasoned from shape.

REACHABILITY, and the largest of the three. EvaluateAlertsAsync read the latest CPU sample inside
the same try as engine.EvaluateServerAsync. That read runs on the alert-pass deadline and is the
pass's first store read, so under the contention #3013 measures it fails first - and when it did,
the engine sweep was skipped entirely, so EvaluateCoreAsync never recorded its pass while the
caller's catch still recorded a failure. Numerator up, denominator unchanged, worst exactly when
the counter matters most. A third route to the defect the PostgreSQL group had: not omission and
not placement but a pass site that is real, correctly placed, and never entered.

The larger half is not the arithmetic. A single failed CPU read aborted the whole shared sweep for
that server that tick, so blocking, deadlocks, poison waits, long-running queries, TempDB, low
disk, PVS, file growth, jobs, database state and forced plans went unevaluated. The snapshot
already documents a null CPU pair as normal input and CheckCpuAsync gates on HasValue, so the read
now has its own try and degrades to (null, null): the tick loses its CPU alert and nothing else.
NoCountedRead_SharesATryWithThePassItWouldSkip is the guard, brace-balancing every try in the
member because the whole claim is that these two statements are in different ones.

FLEET INVENTORY, wrong in both directions. The list named store disk pressure, whose two feed
reads are both exempt so it can never contribute a failure, and omitted the collector-cost
regression self-alert, which does. Either way an operator reading it hunts the wrong thing. The
set is now one public const that WindowNote, the class remarks and both tool descriptions
concatenate - a const-string concatenation is a compile-time constant, so it cannot grow in one
place and go stale in three - with the set derived from source and checked against it.

FAILED JOBS, declining the reported defect and fixing a different one. The claim was that a
throwing SaveFailedJobWatermarkAsync would be mislabelled a read; both implementations swallow it
without rethrowing, so that is unreachable and the write-inside-the-try asymmetry is inert. What
IS wrong is that the fetcher reads the monitored server's msdb - the population DarlingWorker's
own fetcher is exempted for - so counting it here contradicted that exemption and would put a
target-side outage into a number read as store contention. Exempt, with the reasoning at the site.

* Stop the PostgreSQL pass comment claiming a master-switch parity the code does not have

The comment said the pass is recorded after the guard "for the same reason the engine records
after its master switch". It is not. The shared engine records after EvaluateServerAsync's
!_settings.AlertsEnabled early return and the self-alert evaluator returns before recording on the
same check; this path is guarded only on _postgres/_alertDeliverer being non-null, and
DarlingWorker holds no reference to AlertsEnabled anywhere in the file.

Measured rather than assumed: zero AlertsEnabled references in DarlingWorker.cs against one in
AlertEngine.cs and ten in DarlingSelfAlertEvaluator.cs, and the only gates between the sweep body
and the predictors are the null-runtime check, the engine check and the null-store check. So with
alerting switched off the PostgreSQL predictors still read, still evaluate and still reach
_alertDeliverer.

The denominator is left alone deliberately. Gating only this line would make the count deny
passes that genuinely ran and alerts that genuinely fired - a surface lying about its own
exposure, which is the defect this whole change exists to remove. Whether the Tier 0 predictors
are deliberately exempt from the master switch is a question about alert delivery, not something
to settle inside a counter, so the count stays truthful and the gap is named at the site.
@erikdarlingdata
erikdarlingdata deleted the fix/2874-sweep-adopt-shared-scanner branch September 12, 2026 20:30
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