Fixes #3013 - #3040
Fixes #3013#3040
Conversation
…here 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.
…t 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.
…s 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.
…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.
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.
|
Reviewed this PR (in-memory swallowed-read counter for the alert pass, surfaced on both SKUs' One real gap, left as an inline comment: the six PostgreSQL-predictor read sites in Everything else (thread-safety posture on |
…n 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.
…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.
|
Reviewed the diff ( Things I specifically checked and verified against source (not just the PR description's claims):
Nothing I'd block on. This is a well-scoped, well-tested addition (source-scanned census with a positive/negative scanner control, red-proofed mutations, cross-SKU field/description parity pins) and I couldn't find a gap in either the counting logic or the Lite/Darling parity. |
…on 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.
… 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.
|
Reviewed the diff (non-test production code: Overall the change is careful and the census/pin machinery is genuinely thorough. I found one real correctness gap and two smaller consistency issues; left them as inline comments:
Lite/Darling parity looks solid otherwise: key derivation matches each SKU's own alert pass (invariant vs. default culture |
… 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.
Review summaryReviewed the net diff against What it does: adds Lite/Darling parity: good. Checked both Test-suite sanity check: manually re-derived the call-site census the tests assert ( One finding posted inline on No security, injection, or missing-index-DMV concerns — |
…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.
ReviewScope: no T-SQL touched, so CONTRIBUTING.md's SQL style section and the missing-index-DMV rule dont apply here. This is a C# change (new AlertReadFailureCounter, wiring through AlertEngine, DarlingSelfAlertEvaluator, DarlingWorker, both get_collection_health MCP tools, and the web panel), reviewed for correctness, Lite/Darling parity, security, and performance. Correctness
Lite/Darling parity
Security
Performance
No correctness bugs, parity drift, or security issues found. This PR ships an unusually large amount of self-verification (the census tests, the red-proof table, the reflection-based field-set checks), and it holds up under a manual pass over the actual diff. |
Every condition check in the alert pass wraps its store read in log-and-skip: on a failure it writes one
[ERRORline and returns, because firing on absent evidence fabricates an alert and resolving on it fabricates a recovery. That posture is correct and is unchanged here. What was missing is that a swallowed read reached no surface a person reads — it is not a collector run, so it writes nocollection_logrow, soget_collection_healthand every other health read stayed green while the alert pass went blind one condition at a time. Only a grep of the service log found the class.This is option 1 of the three the issue lays out: count them. Options 2 (alert on the alerting) and 3 (separate deadline exhaustion from unreachable) are deliberately not done — see below.
Severity, stated honestly
The issue reads as though the elevated rate is ongoing. It is not, and that was caught independently twice: the sample behind the filing was taken while the last pre-treatment continuous-aggregate refresh was still finishing, and the monitoring session noticed the same contamination on its own about ninety minutes later. Two separate corrections, arrived at separately, agreeing.
At rest the rate is 2–3 per hour, measured four times across six hours on one dogfood store (2/hr, 3/hr, 2/hr, 3/hr, all at
max_concurrent_sweeps = 4) — at or near the pre-incident noise floor. So this closes a blind spot; it is not a response to a live fire.What justifies it is the incident series, not the current rate. During the store-side lock contention the service log's
[ERRORrate rose 41 → 61 per hour — roughly 20x the measured floor — and every one of those lines was a store read from the alerting or analysis side. Over the same hours, actual collector failures incollection_logwere falling: 23, then 7, then 5, then 2. Two populations moving in opposite directions, and the rising one appeared on no health surface at all.The mechanism is deadline stratification. The alert pass runs on
DarlingAlertReadAdapter.AlertPassCommandTimeoutSecondswhile the collection sweep runs on budgets an order of magnitude longer, so as store latency rises the short-deadline consumers cross their limit first and at a rising rate while the long-deadline ones still complete. The first casualty of store contention is the alerting layer; the last is the thing alerting exists to report on.The counter's introduction exposed a latent outage-shaped behaviour, and that is the strongest argument for it
Found in review of this PR, in the code path this PR instruments — pre-existing on
dev, not introduced here.DarlingWorker.EvaluateAlertsAsyncread the latest CPU sample inside the sametryasengine.EvaluateServerAsync. That read runs on the alert pass's own command deadline and is the pass's first store read, so under the store contention this issue documents it is the first thing to fail. When it did, thetryunwound and the shared engine sweep was never invoked — so for that server, on that tick, not one of its thirteen check families was evaluated — CPU, blocking, deadlocks, poison waits, long-running queries, TempDB space, low disk, PVS pressure, file growth, anomalous jobs, failed jobs, database state, forced-plan failures, plus the blocking-wait gate nested inside blocking and the restart-survival watermark seed. One cheap read on the shortest deadline took all of them down with it, and nothing said so: the sweep's catch logged a single line naming the server, not the conditions that went unjudged.That is this issue's thesis in its sharpest available form. #3013 argued that under contention the alerting layer fails first and silently while collection keeps working; this is the same mechanism one level deeper, where the alerting layer's own cheapest read silently disables the rest of the alerting layer. It was invisible for the same reason the original class was: it produces no
collection_logrow and no health-surface change.Fixed here because the counter cannot be made honest without it — the numerator incremented on this path while the denominator did not, since
EvaluateCoreAsyncnever ran to record its pass. The CPU read now has its owntryand degrades to(null, null);AlertServerSnapshotalready documents a null CPU pair as normal input andCheckCpuAsyncgates onHasValue, so the tick loses its CPU alert and keeps the other twelve.Worth stating plainly: an instrument whose introduction surfaces a latent outage-shaped behaviour in the layer it measures is a better case for the instrument than anything in the original issue. The blind spot was not only that failures went uncounted; it was that nobody could see a failed read taking a whole sweep with it.
Where the counter lives, and why
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 to be readable would be unavailable exactly when it has something to say. Both SKUs host their MCP surface in the same process as the alert pass, so there is nothing to persist it for.
Both scopes on the response, because neither substitutes for the other. The per-server count is the actionable unit and matches this tool's scope. The instance total is the only home the fleet-scoped store self-alerts have — disk pressure, compression-job health, store-job cadence and retention holds belong to no server, so a per-server-only figure would have given them no home at all, reproducing #3013's own defect one level down. They are held in a bucket that is a separate field rather than a sentinel key in the per-server map, so no server key can resolve to it however it is spelled.
Shape follows #3033/#3017's conventions with one deliberate difference. There, the output figures went flat on the collector row because the denominator (
total_runs) was already on the row and nesting the numerator away from it would have split a ratio. Here neither number exists on the response yet, so a block is what keeps them together:last_failure_readis the actionable half — which condition went blind, not merely that one did. It is a compile-time constant per site, never the exception message: Npgsql renders both a deadline and an unreachable backend as the same seven words, so the message adds nothing the count does not already carry, and an exception message can carry host and database names that must not reach an MCP response.last_failure_atis the currency term. This figure never ages out of a window, so without a stamp beside it a healed episode from days ago and one still in progress read identically —last_error's own lesson (#3010), on a different field.Deliberately not a band, and not a status input. #3033 declined a membership set because on one collector it would have said "no action needed" about a collector that was in fact broken. The argument is stronger here: a threshold over blind alert reads would have to guess how many make alerting unhealthy, and a wrong guess on this surface fails by saying nothing is wrong.
CollectorHealthClassifier.Classifystill takes nine parameters and none of them is about alerting; that is pinned off the type.Both windows named, and the one disclaimed
Measured: this process's lifetime, from
counting_sinceto now. In memory; a restart takes it to zero.Disclaimed: the fixed trailing seven days that
total_runs, the three durations and #3033'srows_stored/runs_with_rowscover.alert_read_healthis the only block on the response that is not on that window, and the note says so outright — a reader who assumed otherwise would read a zero as "none in seven days" when a process that started a minute ago can only report on a minute. On the web surface the same statement lives in the panel's own subtitle ("since this service started — NOT the trailing 7 days"), which is why it is a separate panel rather than six more tiles on Sweep Pressure: inheriting that panel's "trailing 7 days" subtitle would have made it assert a window it never measured.Also disclaimed: it does not count alerts that failed to deliver, and makes no claim about them. That is
get_alert_history's question, and it is answered separately below.And the denominator is a denominator, not a rate.
server_alert_passesexists so three failures over two hundred passes can be told from three over four. It is not a quotient of anything: a pass issues many reads, and a Darling sweep of a connected server runs two passes (the shared engine's conditions and the service's own store-polled self-alerts) where a Lite sweep runs one. The note says that too.The population: 27 counted sites, 17 exempt, each with a reason
The population is store reads whose result feeds an alert decision. Not the one instance reported — swept by shape:
AlertEngine.cs(shared, both SKUs)DarlingSelfAlertEvaluator.csDarlingWorker.cs(alert-pass members only)The exemptions are stated at each site in source and are keyed by log message in the pin:
Alert resolution callback failed,Connection-change self-alert delivery failed) — a different fact with a different remedy, and option 2's territory.Evaluate*methods on the self-alert evaluator — they are handed their evidence as parameters and perform no store read; their catches cover the apply/deliver half. The reads that feed them are counted at their own sites inDarlingWorker.EvaluateCompressionJobHealthAsync. This is the one place where reading the log message alone would have got the population wrong.DriveInforead, not a store read; Under store contention the alert pass fails first and silently, so the self-alert that would report it is the first thing lost #3013's mechanism has no bearing on it.pg_database_sizeread — context for the alert text, not the evidence the alert is judged on.msdbover its own connection and its own timeout, not the store over the alert pass's deadline. It is a swallowed alert read, but not one this mechanism can produce, and pooling the two would put a target-side outage into a number the operator reads as store contention.Cross-SKU parity: all four surfaces checked
get_collection_healthexists on both SKUs, and #3033 found that the web dashboard's descriptors are a fourth surface that silently drops anything they do not list.DarlingMcpDataTools.cs) — block added; key derived withCultureInfo.InvariantCulture, matching that SKU's alert pass.McpHealthTools.cs) — same block, same field set; key derived withint.ToString(), matching Lite's alert pass. Each SKU mirrors its own pass rather than one spelling being imposed on the other, because changing Lite's alert key would re-key its suppression, badge and watermark state too. Both agreements are pinned from source, each from its own side.server-tabs.js) — a new "Alerting Reads" panel in the Collection Health fanout on both the SQL Server and PostgreSQL server tabs, as one sharedALERT_READ_PANELconst so the two cannot drift. Pinned by count against theSWEEP_STATSpanel's, not by presence.The tool descriptions stay byte-identical across SKUs (10,172 chars each, both assembled from the shared
FleetScopedReadsconst), pinned.Lite gets the instrument for free because the counting lives in the shared engine. What does not transfer is the mechanism: Lite's alert reads hit a local DuckDB store, so the deadline stratification measured on a Postgres store does not apply there. The surface gap did, and this is a parity change rather than a port of the mechanism — said in the code comment so nobody later reads the Lite block as a claim about Lite's deadlines.
Option 3 (separating deadline exhaustion from unreachable): not done, and why
Exception while reading from streamis Npgsql's undocumented 30 s defaultCommandTimeout— a socket read timeout restarted by every backend message, so the bound is backend silence, not work. Duration separates the two bounds where the message cannot: about 10,000 ms for the alert pass's own deadline versus about 30,000 ms for the inherited default.Distinguishing them cheaply at the catch site would need either a
Stopwatcharound every one of 27 reads — whose value is then a duration this surface would have to band — or a claim about which inner exception type Npgsql wraps each case in, which needs its own measurement against a genuinely contended store to verify. What was available for free, and is done, is recording which read failed: the operator learns which condition went blind without the surface asserting why. The duration discriminator is recorded here as the measurement a future option-3 change should be built on.The issue's own "Not verified", now verified
Checked, and the answer is that deliveries were not missed. Over the contended window on the affected store, alert history carries 66 rows spread continuously across every hour — every hour of the window has rows, with no gap. Every row's
send_erroris null. Firing and resolution rows pair up throughout (Deadlocks Detected/Deadlocks Cleared,Blocking Detected/Blocking Cleared,High CPU/CPU Resolved,Collector Cost Regression/Cost Regression Cleared). The store's ownStore Job Over Cadenceself-alert fired three times inside the window — so the self-alert family kept delivering while the collection-health self-alert's read was failing.That reduces the severity, and it is the honest reading: the alerting layer degraded partially. Specific condition reads went blind; the rest of the pass and the whole delivery path kept working.
Two limits on that check, both real. Alert history records what the engine decided to fire, so it structurally cannot show an alert that was never evaluated because its read failed — the absence of a
Collection Stoppedrow proves nothing either way (collection was not stopped; collectors were succeeding). Andalert_sent: falseon the firing rows is the headless service's channel configuration, not a delivery failure — the interleavedalert_sent: truerows are what prove the path was alive.The denominator's population, after review
Review found a real gap and it is fixed: the six PostgreSQL predictor checks each recorded a swallowed read and none recorded a pass, so a PostgreSQL target's denominator reported two passes where three ran. A guarded numerator over an unguarded denominator is worse than neither, because the pair still renders and now understates its own exposure.
Counted rather than excluded, and at GROUP granularity rather than per site. Excluding them was not viable: these are store reads on the alert path whose failure means that condition went unjudged, which is exactly #3013's population — dropping them would put PostgreSQL targets back in the blind spot the issue is about, on the fleet where the managed store actually lives. And one pass for the group rather than six, because that is the shape already in the tree:
AlertEngine.EvaluateCoreAsyncdispatches fourteen independently failure-isolatedCheck*Asynccalls and records one pass;EvaluateStoreAlertsAsyncdispatches four and records one. Isolation granularity is not pass granularity. Six per-site passes would have counted per-check on PostgreSQL targets while counting per-group on SQL Server ones — the same category error, relocated. Recorded inEvaluatePostgresAlertsAsyncafter its null-store guard, for the same reason the engine records after its master switch.One correction to how the gap was described, because it would misdirect a future change. It is not that the pair "reads as a ratio above 1" and therefore cries wolf. Failures-per-pass above 1 is the ordinary shape on every engine: a pass issues many reads, so a SQL Server target failing all fourteen engine reads in one sweep posts 14 against 2. That is not a defect, and it is precisely why this surface computes no ratio and says so — "a denominator for judging whether the failure count is large, not a rate." The actual defect was narrower and is what was fixed: a whole evaluation pass ran and was absent from the denominator, so the figure understated its own exposure by a third on PostgreSQL targets.
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 state that the denominator is comparable within a host and engine and not across them.
Pinned, both directions.
EveryAlertEvaluationPass_RecordsItselfInTheDenominatorasserts every pass entry point records itself and that the tree-wideRecordPasscount equals the number of entry points — so a fourth pass that forgets to record fails, and aRecordPassplaced somewhere that is not a pass fails too. Entry points are an explicit(file, member)list with a count assertion, the mechanismAlertPassCommandTimeoutTestsalready uses, and deliberately never enumerated by log-line shape: that is what keeps the five fleet-scopedEvaluate*methods — which look like read sites from their log lines but are handed evidence as parameters — from slipping in.The pins caught four bugs in themselves, all worth recording:
"runs three"appeared. One true clause is not a true inventory. Split into four separately-asserted arms; the variant now fails.unclassifiedlist from the previous fixture, soAssert.Singlesaw two entries. The list is cleared between fixtures now — a control that fails for the wrong reason is not a control.The census matched one spelling of
catch, and that was a gapThe re-review came back clean, and one of its verifying claims was wrong in a way worth chasing: it reported that
DarlingWorker.cs's narrower catch types "all live outside the scanned alert-pass members." One does not.FetchFailedJobsAsync— which is in the scoped member list — swallows a failed msdb read incatch (SqlException ex) when (SqlServerPermissionErrors.IsPermissionDenied(ex.Number)), and the census regex matchedcatch (Exceptiononly, so it could not see that block at all.Nothing was miscounted today: that member is exempt for its own stated reason, and it now carries an exemption entry for both its arms instead of one. But the pin was the #2786 shape the rest of this file exists to avoid — a guard that names the arm it was written for — and a store read moved into a narrower catch inside any scoped member would have reported CLEAN. Found by auditing the caught types directly rather than trusting that they were all
Exception.s_catchnow matches any caught type.OperationCanceledExceptionis the one exclusion and it is proven rather than assumed:NoCancellationCatch_QuietlySwallowsAReadFailureasserts every such block in scope either rethrows or logs nothing at error level, with a floor on how many blocks it examined so it cannot pass vacuously. Two scanner controls were added beside the existing ones — a narrower type with awhenfilter must be reported, and a cancellation catch must not be a census subject at all.One incidental finding worth recording: at the forced-plan site the rethrow is compiler-enforced — removing
throw;there fails with CS0165 on an unassigned local, so that particular block cannot host the defect at all. The pin is the only guard at the sites where it isn't.Presence cannot see placement
The denominator pin originally asserted that each pass entry point contains a
RecordPass. That is not the whole claim. ARecordPassmoved inside thetry— 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. Identical outcome to omitting the call, arriving through position instead of absence, and presence alone cannot see it.The pin now asserts the pass is recorded before the reads begin. Three details that matter:
try—AlertEngine.EvaluateCoreAsyncdispatches fourteen checks that each own theirs — so where there is none the ordering claim does not apply. The arm counts how many entry points it actually reached and asserts a floor, so it cannot go vacuous for all three unnoticed.trystatement, not the substring: "try" occurs insideretry,entryandgeometry, and a substring hit would compare the pass offset against an arbitrary identifier.EvaluatePostgresAlertsAsync" gap: the ordering is now pinned, the runtime increment is still not.Four review findings: three upheld, one declined
Reachability — upheld, and the smaller half was the arithmetic.
EvaluateAlertsAsyncread the latest CPU sample inside the sametryasengine.EvaluateServerAsync. That read runs on the alert-pass deadline and is the pass's first store read, so under the contention this issue measures it fails first — and when it did, the engine sweep was skipped,EvaluateCoreAsyncnever recorded its pass, and the caller's catch still recorded a failure. A third route to the same defect: not omission, not placement, but a pass site that is real, correctly placed, and never entered. Neither existing arm could see it.The larger half is not the counter at all: a single failed CPU read aborted the whole shared sweep for that server that tick — blocking, deadlocks, poison waits, long-running queries, TempDB, low disk, PVS, file growth, jobs, database state and forced plans all unevaluated, because one cheap read on the shortest deadline threw first. That is this issue's own thesis in its sharpest form, and it is pre-existing rather than introduced here. The CPU read now has its own
tryand degrades to(null, null), which is safe rather than convenient:AlertServerSnapshotdocuments a null CPU pair as normal input andCheckCpuAsyncgates onHasValue, so the tick loses its CPU alert and nothing else. Guarded byNoCountedRead_SharesATryWithThePassItWouldSkip, which brace-balances everytryin the member and asserts the two statements sit in different ones.Fleet inventory — upheld, and wrong in both directions. The enumeration omitted the collector-cost regression self-alert, which records a null-key failure. Measuring the other side found the mirror error: it named store disk pressure, whose two feed reads are both exempt (a local
DriveInforead; apg_database_sizeread that is context for the alert text), so it can never contribute a failure. The list was simultaneously missing a real cause and advertising a phantom one.An inventory maintained by hand in four places cannot fail when the set grows, so it is not maintained in four places any more:
AlertReadFailureCounter.FleetScopedReadsis onepublic const, andWindowNote, the class remarks and both tool descriptions concatenate it — legal inside aDescriptionattribute because a const-string concatenation is a compile-time constant.TheFleetScopedInventory_MatchesWhatTheCounterActuallyRecordsderives the set from source, asserts the count, asserts each site is represented, asserts the phantom stays gone, and asserts every tool file references the constant rather than repeating it.Worth recording how nearly this was missed: the first
grepforRecordReadFailure(nullreturned one site when there are two, because an earlier commit had wrapped one of those calls across lines. A line-bound pattern would have "proved" a single fleet-scoped condition. The pin matchesSinglelinefor exactly that reason.Failed jobs — declined as reported, and a different defect fixed. The reported consequence rests on
SaveFailedJobWatermarkAsyncthrowing. Both implementations swallow —PgAlertStateStoreandDuckDbAlertHistoryStoreeach log "Could not persist failed-job watermark" and do not rethrow — which is the contract this file's own remarks state. So a write failure cannot reach that catch and cannot be mislabelled. The structural observation is accurate and inert: the write is inside the read'stry(measured: try at +8, save at +51) where the blocking, deadlock and database-state checks keep theirs after the catch (+55, +45, +139). Not moving it — relocating a store write in the shared engine to fix an unreachable mislabel is a behaviour change to both SKUs for no observable gain.What was wrong is mine. The only thing that can throw into that catch is the fetcher, and on Lite
FetchFailedJobsForAlertAsynchas no catch at all — so on Lite it can. That fetcher reads the monitored server's msdb: precisely the populationDarlingWorker.FetchFailedJobsAsyncis exempted for, on the stated ground that pooling target-side reads with store reads puts a target outage into a number read as store contention. Counting it here while exempting it there was an inconsistency inside this PR. Now exempt, with the reasoning at the site.A fourth finding, and an open question this PR does not answer
Review of the commit above caught my own comment claiming something the code does not do. The new
RecordPassinEvaluatePostgresAlertsAsyncsaid it was recorded after its guard "for the same reason the engine records after its master switch." It is not. The shared engine records afterEvaluateServerAsync's!_settings.AlertsEnabledearly return, andDarlingSelfAlertEvaluator.EvaluateStoreAlertsAsyncreturns before recording on the same check. This path is guarded only on_postgres/_alertDelivererbeing non-null.Measured: zero
AlertsEnabledreferences inDarlingWorker.cs, against one inAlertEngine.csand ten inDarlingSelfAlertEvaluator.cs— that contrast is the positive control. The only gates between the sweep body and the predictors are the null-runtime check,Engine == PostgreSql, and the null-store check, andEvaluatePostgresAlertsAsyncreaches_alertDeliverer.DeliverAsyncdirectly.So the consequence is larger than the denominator: with alerting switched off, the PostgreSQL Tier 0 predictors still read, still evaluate and still deliver. Pre-existing, not introduced here.
The
RecordPassis deliberately not gated. Gating only that line would make the denominator deny passes that genuinely ran and alerts that genuinely fired — a surface understating its own exposure, which is the defect this change exists to remove. The count stays truthful and the gap is named at the site.The predictors are deliberately not gated either, and that is the open question. Whether the Tier 0 group is intentionally exempt from the master switch is a question about what alerting does on a live fleet, and a reporting change should not answer it by side effect. Nothing in the code claims the exemption — the method's own doc comment says recording and mute handling stay with the deliverer "exactly as for an engine-emitted alert", which reads like intended parity — but absence of a claim is not a decision. Routed as one question rather than assumed either way.
Verification
Real test files compiled into staged
net10.0xunit v3 hosts (AssemblyNameDarling.Tests/Lite.Tests), with a pristineorigin/devcontrol host built the same way for both SKUs:origin/dev)Darling.TestsLite.TestsThe failure sets are byte-identical between base and branch (diffed, not eyeballed): 7 Darling failures are
DarlingServiceInstallLocationTests(Windows paths, DPAPI, registry) and the 38 Lite failures areParitySource.RepoRoot()walking up from the stagingbindirectory plus twoMcpAlertSettingsKeyTests— all harness and platform artifacts of running Windows suites on macOS, none of them reachable from anything this change touches. +18 Darling tests and +6 Lite tests, all discovered by name and all green.Red-proofed twenty-seven ways across twenty-three distinct assertions, each variant rebuilt from source (a stale harness makes source mutations invisible) and restored by stashing the mutation only, with the fix re-asserted by content — 27
RecordReadFailurecall sites present — after every restore rather than by a cleangit status:deadlocks)RecordPassServerAlertPasses— the denominatorSWEEP_STATS'counting_sinceReading-member coverage, reflected off the recordInvariantCultureFormatFindingalways returns a sentenceRecordPassplaced where there is no passtrytrypattern brokentryThe census pin is red-proofed itself, not just used to red-proof the change:
TheScanner_FindsAPlantedCatchBlockAndRejectsAPlantedProseOneruns three fixtures through the identicalClassifycall — a counted block, an exempt block, and a stray block that must be reported — plus proof that acatch (Exception ex)written in a comment is not a catch block, which matters because this change added fourteen prose exemption comments that a naive scanner would have counted.The
Darling.Testscontrol that decides whether the healthy case means anything (AHealthyPass_CountsItselfAndLeavesTheFailureCountAtZero) exercises the case worth worrying about — four checks enabled against an adapter that answers — and asserts the reads actually happened, so its zero is a zero from a pass that looked rather than one that was gated off.Five commits, and the last four came out of reviewing my own diff rather than out of CI: a null-conditional on a non-nullable field that could never short-circuit;
counting_sinceoverstating its own floor (it is the counter's first touch, early in host startup, not the process's first instruction); the two new timestamps serializing as rawDateTimewhere every other timestamp on this response goes out as round-trip"o"; and — the one that mattered — three comments crediting #2966 for thelast_errorcurrency lesson when it is #3010's, measured on the managed PostgreSQL fleet and pinned byLastErrorCurrencyTests.git grepfor the number found #2966 attached to something unrelated, which is exactly what the house rule about citing figures from closed issues exists to catch. The fifth commit is the tail of that one: two XML doc comments still saidcounting_sincewas "process start" a line away from the corrected citation. Every red-proof variant in the table below was re-run against the final head, not only the ones whose target files those commits changed, and all are still red with the tree clean and 27 call sites intact after each restore. One of them changed shape usefully in the process: the "panel on one server tab only" mutation now matches its witness TWICE rather than once, because the two tabs' panel blocks are byte-identical — which is the shared-const property that variant exists to protect, arriving as a precondition failure rather than as an assertion.No counted site can fire on cancellation, checked across all 27 rather than sampled: every one is either preceded by an
OperationCanceledExceptioncatch that rethrows, or carries awhenfilter excluding it. That property is load-bearing rather than incidental — a broadcatch (Exception)at any of them would make an ordinary service stop or a tripped budget register as a swallowed store read, inflating precisely the number this change exists to make trustworthy, and it would do so on every shutdown.Verified out of the built assembly rather than the source file: reflection over
PerformanceMonitor.Alerting.dllreportsWindowNoteat 1,536 chars with all eight load-bearing phrases present and no trace of the retracted#2966, andReadingcarrying exactly the six members the two surfaces render. The staleness control is the note-mutating red-proof variant above — mutating that constant's content turned the harness red, so the harness demonstrably sees changes in that file (a rebuild alone proves nothing, since .NET builds deterministically).Scrubcheck clean at exit 0 over the diff's added lines (104,897 bytes on the first commit, 22,140 on the follow-up), 9/9 internal controls fired; the planted-marker control through the identical invocation returns exit 1 and finds all three plants. A whole-file sweep reports one host name in
AlertEngine.cs— pre-existing onorigin/dev, in a comment this change does not touch, zero occurrences in added lines.Not verified
get_collection_healthcall was made against a running service carrying this build, on either SKU, so no client has received this payload. What is verified, by rendering the block through the realReading, the realFormatFindingand the realWindowNote: the snake_case field names survive serialization verbatim (McpHelpers.JsonOptionsisnew() { WriteIndented = false }— read from source, no naming policy, so a camelCase rewrite ofserver_read_failureswas a live risk and is ruled out); a clean reading renders"finding":nullrather than a reassuring sentence;counting_sinceandlast_failure_atboth emit round-trip"o"; and a fleet-scoped failure lands ininstance_read_failureswhile leavingserver_read_failuresat zero. The serializer options were replicated rather than referenced, becauseMcpHelpersisinternal— so that one line is read from source, not executed. What remains unverified is that the tool's own initializer produces this exact object (source-pinned against theReadingrecord and the other SKU, not run) and anything requiring a store.last_failure_readhas no ARIA relation to the counts it qualifies, and the panel carries no severity hint by design. Two mechanical risks were checked rather than assumed: everyformattoken the descriptor names (int,reltime,text) exists inutil.js'sFORMATTERSregistry, so no tile falls through to the em-dash default on a typo; and the round-trip"o"timestamps this change now emits carry seven fractional digits, which is outside what ISO 8601 requires a parser to accept — tested in V8,new Date("2026-09-05T08:00:00.0000000Z")parses and truncates to milliseconds, sorelTimerenders a relative string rather than an em-dash. That risk was introduced by this PR's own timestamp-format change and would have silently blanked two of the six tiles.noteis not on the web surface — only the window statement, in the subtitle. A web reader gets no delivery disclaimer and no "deliberately not persisted" statement, and thefindingsentence is not rendered there at all (prose does not fit a stat tile).msdbfetch is a judgement, not a measurement. It is a swallowed alert read that this counter does not count. If an operator ever wants "every swallowed alert read" rather than "every swallowed alert read of the store," that decision has to be revisited.server_alert_passescounts two passes per sweep on Darling and one on Lite. That is documented in the note and in the counter's own doc comment, but it does mean the denominator is not comparable across SKUs, and nothing enforces that a reader notices.last_failure_atgives the recency answer without that hazard.EvaluatePostgresAlertsAsyncneeds aServerRuntimeon a PostgreSQL engine and a reachable store to reach itsRecordPass, so no functional test drives it. Its presence, its position relative to the reads, and its placement after the null-store guard are all pinned from source; that the increment actually lands on a live PostgreSQL target is not. This is the weakest link in the denominator fix.ReadInstance()andServerKeys()have no production caller. Both are exercised only by the pin that proves the fleet bucket is held apart from every server —ServerKeys()is how that test can assert the empty string is not a key at all. They are kept as the observability surface for that invariant and as the shape a fleet-level reader would need; nothing on either SKU calls them today, and their doc comments state a purpose rather than claiming one does.unavailableenvelope before reaching the response body when the collector rollup is empty, so a brand-new or never-collected server reports nothing about its alerting reads. That early return predates this change and is shared with the whole payload; moving the block above it would change the envelope's shape, which other pins read. In practice the gap is narrow — a server that has never collected has barely any alert reads to fail, the fleet-scoped total is on every other server's response, and the per-server figure for such a server would be near zero anyway — but it is a real hole and it is not fixed here.Store Job Over Cadencealerts quoted above name the store's own background jobs, and are read as evidence the self-alert family kept delivering. They are not proof that every self-alert family did.Closes #3013.
Fixeskeywords are a no-op ondevmerges, so this needs closing by hand.