Skip to content

Never answer one cooldown key's question with another key's history (#3456) - #3459

Merged
erikdarlingdata merged 2 commits into
devfrom
fix/3456-cooldown-seed-identity
Sep 15, 2026
Merged

erikdarlingdata merged 2 commits into
devfrom
fix/3456-cooldown-seed-identity

Conversation

@erikdarlingdata

@erikdarlingdata erikdarlingdata commented Sep 15, 2026 •

Copy link
Copy Markdown
Owner

Fixes the delivery-correctness half of #3456 — the throttle-instead-of-deliver mechanism and the first-notice suppression. The roster half of that issue's acceptance (a self-alert burst folding into carrier + roster) needs cooldown identity that survives the integer server_id column, which is a schema-shaped follow-up argued below and now filed as #3458; it is deliberately not attempted here.

Both history stores mapped the string server key onto config_alert_log's integer server_id with int.TryParse(...) ? sid : 0, on both sides of the table. On the write side that is a tolerable collapse: the row must be recorded somewhere, the column is an integer, and 0 is where every non-integer key lands. On the read side it was the defect. The restart seed asked "when did THIS key last deliver?" and got back MAX(alert_time) over EVERY collapsed key's rows — the fleet-wide last successful send of the metric — because the whole self-alert family's keys are non-integers by construction (cost:<id>:<collector>, the Availability Group composites, the fixed sentinels) and every one of their rows sits in the same bucket. The per-fingerprint cooldown #1154 built therefore degraded, for exactly this family, back into the fleet-wide per-metric mutex #1154 removed — one layer down, where the alert log's own rows could not show it until #3446's dispositions made the shape legible.

Measured live, twice, identically (the issue's banked reproductions): two servers' Collector Cost Regression fires for the same collector land 3.7 ms apart. The first seeds against the fleet-wide last send — 60m07s prior, outside the window — and delivers. The second seeds 3.7 ms later against the first's just-written row and renders throttled, despite its own last delivery being 2h00m13s prior. The 60m07s figure equals the fleet-wide MAX(alert_time) for the metric to the second, which is what identified the seed as the mechanism. Retroactively this is also the pre-#3446 exactly-one-winner bursts: every burst member after the first seeded against the winner's row.

In severity order, per the issue: a first notice could be suppressed by another server's send — an unannounced incident, the class #1154 exists to prevent and the line RepeatDeliveryBudget's own remarks say must not be crossed; #3436's roster never engaged for this family, because the throttle returns before the budget evaluates; and the throttled disposition inverted its meaning, reporting re-tell suppression of an incident it was actually suppressing on other incidents' sends.

The change

The mapping now lives in one shared type, AlertHistoryServerIdentity, used by both stores — the same arrangement AlertValueParser already gives the value columns, and for the same reason: two stores answering an identity question with separately-maintained parses is the drift risk, and this question is the one the issue turned on.

  • StorageId is the write side, unchanged in behavior: an integer key stores its own id, everything else collapses to 0. Centralized so the rows the bucket absorbs are demonstrably the same ones the seed refuses to read, and so the follow-up changes one line per direction.
  • SeedScope is the read side and the fix: it declines (null — no seed) any key whose rows are not distinguishable by server_id alone. That is the unparseable keys, and also the literal key "0": the bucket's rows are mixed by construction, so an answer from it is cross-key history even for a caller whose key genuinely is 0. The bucket is write-only. A declined seed reads as a first notice and posts — the direction the delivery doctrine already names ("fails toward posting"): the cost of failing this way is a post; the cost of the old way was an unannounced incident.

The invariant, pinned: the seed never answers one key's question with another key's history. Even the asking key's OWN rows are unrecoverable from the bucket — nothing in a collapsed row says which key wrote it — so the honest answer for the whole family is "no answer", which is the pre-seed (#1145) behavior: the in-memory stamp governs within the process lifetime.

PgAlertHistoryStore.ReadMaxAlertTimeAsync serves all three seed reads, so one early return fixes the webhook seed (the measured defect), the email seed (same mechanism, same fix), and GetLastAlertTimeAsync. Lite's DuckDbAlertHistoryStore carried the same parse in all three of its reads — the twin the Pg store's class doc claims query-for-query parity with — and takes the same change, which keeps that claim true.

Other callers of these reads, checked: GetLastAlertTimeAsync feeds the analysis notification cooldown and the #2716 Tier-0 predictor seeds. Every one of those callers passes an integer key exclusively — Lite resolves f.ServerId.ToString(), Darling resolves finding.ServerId and snapshot.ServerKey, which is the storage-name hash rendered as a string — so their behavior is unchanged. The only populations whose reads change are unparseable keys (previously: garbage answer from the bucket; now: no answer) and the literal "0" (previously: the bucket; now: no answer), both of which resolve toward posting.

Parse-to-0 fallbacks in the delivery/history path, named and left alone: both stores' RecordAlertAsync (the write-side collapse, now spelled StorageId and kept deliberately — a row must be recorded, and the seed no longer reads the bucket); Lite's EmailAlertService.SendFindingAlertAsync (collapses alert.ServerId to an int parameter; its only producers pass integer finding ids, so the fallback is dead in practice and it feeds a send path, not a seed); and the deprecated Dashboard's JsonAlertHistoryStore, which compares the string key directly and never had this defect. DarlingWorker's resolveServerOverride already falls back to null rather than 0 — the pattern this change extends to the seeds.

What this deliberately does not do

The issue's acceptance has three clauses, and this ships two: a first-notice self-alert is never throttled by another server's send, and the banked pairs' shape re-runs green (both pinned below). The third — a multi-server self-alert burst delivering one carrier with the siblings folded onto its roster — is not met, and cannot be met by any seed that declines: with no per-key history, an evicted or post-restart key reads as a first notice, and a first notice is exempt from folding by #1154's rule, correctly. So the hourly multi-server pairs that identified this defect now deliver two full cards rather than one throttled and one delivered. Nothing is lost — the old build lost announcements, this one spends posts — but #3436's fold stays structurally unreachable for the self-alert family, and the family keeps no cooldown continuity across a restart.

Restoring both needs seed-and-stamp identity that survives the integer column. The two candidate shapes are weighed in #3458: hashing the string key into the column on both sides is two lines but mints ids outside the registered population — a hash of an arbitrary composite can land ON a registered server's id and interleave the family's rows into that server's per-server reads and dismiss keys, a new and silent defect class — while a rung persisting the string key alongside the column carries identity instead of inferring it and changes no reader. The rung shape is the recommendation. No rung is claimed here, because this change needs no schema and a rung ships with the change that needs it.

Tests

AlertHistoryServerIdentityTests pins the pure mapping: integer keys answer the same id on both sides (including negative hashes), the self-alert shapes write to the bucket and read nothing, the literal "0" writes to the bucket and still reads nothing, and the invariant is stated as a property — the seed either declines or answers with the write side's own non-zero id — so a future edit that lets the halves drift fails by name.

StoreRoundTripTests.SeedReads_ForUnparseableServerKeys_DeclineRatherThanReadTheCollapsedBucket runs the real DuckDB store, like its siblings, because the refusal is a line of SQL scoping plus a guard and a text assertion would pass against any code containing the right words. It pins the write-side collapse first — the refusal only means something if the rows really land at 0 — then the seam the defect lived in: a same-metric sibling key's seed reads null against a fresh bucket row, as does the writing key's own, as does "0"'s, for webhook, email, and unfiltered reads. The control records under "7" and asserts "7" still seeds while "8" does not, so the #1145/#981 restart guarantee is measured, not assumed.

WebhookCooldownSeedTests gains the service-level half, following that file's dead-URL discipline (a suppressed post never touches the network; an attempted one fails observably): a first notice on an unparseable key comes back Failed — attempted — with the seed consulted, never Throttled by another key's fresh send; the integer-key control is one fixture where the only difference between Throttled and Failed is whose row the store holds. The store double models the integer column through the shared mapping rather than keying on the caller's string, which is the distinction the whole issue turns on: a string-keyed fake keeps every key's history separate and the defect cannot reproduce against it.

RepeatDeliveryBudgetTests.OnTheWire_TwoSelfAlertSiblingsMillisecondsApart_TheSecondIsNotThrottledOffTheFirstsSend is the banked shape end to end: two same-metric self-alerts on different unparseable keys through the real deliverer, real cooldown, and a column-faithful store double whose RecordAlertAsync collapses exactly as the real stores do — so the first delivery writes the row the second's seed would have read. Both post; both history rows say webhook-delivered; both rows demonstrably landed in the bucket. On the pre-fix seed the second call reads the first's just-written row and comes back throttled, verified out of band against the built assembly, so the fixture discriminates the mechanism rather than passing by shape. The deferred half is asserted rather than left silent: neither card carries a roster, because both siblings are first notices — the pin says nothing is dropped, and deliberately does not say the burst folds.

Tests cannot run on this lane's platform (net10.0-windows test projects); every touched assembly builds clean, and the mapping, the composed cooldown behavior (both siblings post; integer controls throttle and pass correctly), and the pre-fix reproduction were executed out of band against the built PerformanceMonitor.Notifications assembly.

A separate preparatory commit makes the one-line change CollectorCostDigestTests' factory reserved for the #3441 merge — the #3441 and #3448 lanes crossed and Darling.Tests has not compiled on dev since the second of them landed. BaselineP95MsPerRun is pinned to each fixture's mean so ThresholdMsPerRun resolves to the pre-#3441 bound exactly and every digest fixture keeps asking the routing question it was written to ask.

The branch also carries the one-line cure for #3457 — dev's Darling.Tests compile break from the #3441×#3448 cross-merge (the digest factory passing nine positional arguments to a CostRegression that #3441 gave ten): the named argument the factory's own comment reserved, ordered first on the branch so every commit after it builds against a green dev. ci-sentinel filed that issue from the red run; this is its fix.

 merge

The digest tests' Regression() factory carried a remark saying #3441
would add a member to CostRegression's positional constructor and that
keeping every fixture behind one factory made that merge a one-line
change here. The two lanes crossed and the line was never written, so
Darling.Tests has not compiled on dev since the second of them landed.

BaselineP95MsPerRun is pinned to the fixture's mean, which makes
ThresholdMsPerRun resolve to the pre-#3441 bound exactly - every
fixture keeps asking the routing question it was written to ask
(single-server demotion, fleet-wide paging, the majority boundary)
rather than silently gaining a dispersion conjunct that belongs to
CollectorCostRegressionDispersionTests.
…old, instead of answering with the fleet-wide bucket (#3456)

Both history stores mapped the string server key onto config_alert_log's
integer server_id with int.TryParse(...) ? sid : 0, on both sides of the
table. On the write side that is a tolerable collapse - the row must be
recorded somewhere, and 0 is where every non-integer key lands. On the
read side it was the defect: the restart seed asked 'when did THIS key
last deliver?' and got MAX(alert_time) over EVERY collapsed key's rows -
the fleet-wide last send of the metric - so for the whole self-alert
family (cost:<id>:<collector>, the AG composites, the sentinels) the
per-fingerprint cooldown #1154 built degraded back into a fleet-wide
per-metric mutex, one layer down. Measured twice on one day: same-metric
pairs 3.7 ms apart where the second seeded against the first's
just-written row and rendered throttled despite its own last delivery
being two hours old.

The mapping now lives in one shared type, AlertHistoryServerIdentity,
the way AlertValueParser already does for the value columns and for the
same drift reason. StorageId keeps the write-side collapse unchanged;
SeedScope declines (null - no seed - first notice) any key whose rows
are not distinguishable by server_id alone: the unparseable keys, and
the literal 0, because the bucket's rows are mixed by construction and
an answer from it is exactly the cross-key history the invariant
forbids. The invariant: the seed never answers one key's question with
another key's history. Declining fails toward posting, the direction
the delivery doctrine already names.

What this deliberately does not do: restore cross-restart cooldown
continuity for the self-alert family, or make #3436's roster engage for
their slow recurrences - both need seed-and-stamp identity that
survives the integer column, which is a schema-shaped follow-up, not a
parse fix. StorageId is the one line per direction that follow-up
changes.

Pinned four ways: the pure mapping in AlertHistoryServerIdentityTests;
the real DuckDB store's refusal (and the write-side collapse it guards)
in StoreRoundTripTests; the service-level consequence - a first notice
on an unparseable key attempted, never throttled by another key's send,
with the integer-key seed as its control - in WebhookCooldownSeedTests;
and the banked shape end to end in RepeatDeliveryBudgetTests, two
self-alert siblings milliseconds apart through the real deliverer
against a column-faithful store double, both posting, with the
no-roster consequence asserted rather than left silent.
@erikdarlingdata
erikdarlingdata enabled auto-merge (squash) September 15, 2026 12:19
@claude

claude Bot commented Sep 15, 2026

Copy link
Copy Markdown

Review summary

Reviewed the diff against CONTRIBUTING.md (C# style, Two-Store Parity section) with a focus on correctness, Lite/Darling parity, security, and performance. No SQL was touched, so the T-SQL style rules don't apply here.

Verdict: looks correct and solid. The fix is narrowly scoped, well-tested, and centralizes the identity mapping exactly where the "Two-Store Parity" guidance in CONTRIBUTING.md says duplicated parses are a drift risk.

What I verified:

  • Mechanism: confirmed DarlingSelfAlertEvaluator builds keys as $"cost:{regression.ServerId}:{regression.CollectorName}" — a genuinely unparseable string — which validates the described defect (fleet-wide MAX(alert_time) bleed via the collapsed server_id = 0 bucket).
  • Parity: AlertHistoryServerIdentity.StorageId/SeedScope is applied identically in PgAlertHistoryStore (write + ReadMaxAlertTimeAsync, which backs all three seed reads) and DuckDbAlertHistoryStore (write + all three Get*SentUtcAsync/GetLastAlertTimeAsync methods). Both sides got equivalent new tests (RepeatDeliveryBudgetTests for Darling; StoreRoundTripTests + WebhookCooldownSeedTests for Lite). No parity drift found.
  • SeedScope correctness: excludes both unparseable keys and the literal "0" (since that bucket is inherently mixed), returns null → callers treat as "no seed" → first notice → fails toward posting. Matches the stated invariant and is pinned by a property-based test (TheSeedEitherDeclines_OrAnswersWithTheWriteSidesOwnNonZeroId).
  • JsonAlertHistoryStore (deprecated Dashboard) — confirmed it compares the string key directly (entry.ServerId != serverId) and never had this defect, consistent with the PR description.
  • No behavior change for the write side (StorageId mirrors the prior inline int.TryParse(...) ? sid : 0), and NumberStyles.Integer + CultureInfo.InvariantCulture is equivalent to the previous implicit-culture int.TryParse for this use (no thousands separators are accepted either way) — so this is a safe refactor, not a behavior change, on the write path.
  • Performance: the decline path returns before opening a DB connection, which is actually a small win over the old behavior (no more wasted round trip / scan against the server_id = 0 bucket for the self-alert family).
  • Security: no new SQL surface — same parameterized queries ($1/$2, AddWithValue), no string concatenation of user input.
  • Test double fidelity: both new store doubles (CollapsingColumnHistoryStore in Darling, CollapsedColumnHistoryStore in Lite) correctly model the real stores' column collapse via the shared mapping rather than keying on the caller's string — which is the detail that makes them actually able to reproduce the original bug pre-fix.

One minor/non-blocking observation (out of scope for this PR's diff, so noting here rather than inline): Lite/Services/EmailAlertService.cs:142 (SendFindingAlertAsync) still has its own inline int.TryParse(alert.ServerId, out var sid) ? sid : 0 instead of the new AlertHistoryServerIdentity.StorageId. The PR description explains why it's left alone (write/send path only, producers currently always pass integer ids), which is a reasonable call — just flagging it as the one remaining duplicate of this parse in case a future producer starts feeding that method an unparseable key.

🤖 Generated with Claude Code

@erikdarlingdata
erikdarlingdata merged commit a1031ec into dev Sep 15, 2026
10 checks passed
@erikdarlingdata
erikdarlingdata deleted the fix/3456-cooldown-seed-identity branch September 15, 2026 12:29
erikdarlingdata added a commit that referenced this pull request Sep 16, 2026
…ctors that already saw it (#3467) (#3469)

* Page on a plan-flip pileup from the two collectors that were already current while it ran (#3467)

A parameterized statement joining a #temp table flipped to a plan doing
~35x the logical reads. Every caller shares the parameterized plan, so
ten tenants inherited the flip together, the copies convoyed on
PAGEIOLATCH_SH, physical reads saturated the volume, and sixteen
executions died at the client's 30-second command timeout. The episode
ran about ninety seconds and self-cleared when the plan churned out.

The store was AHEAD of it the whole time. The 20:10:31Z active-query
snapshot held three concurrent copies of the one statement - 29,124 ms /
28,343 ms / 27,703 ms elapsed, 287,049 logical reads on the leader, two
suspended on page IO - next to that same statement's 54 ms observation
from 3.5 minutes earlier. Nothing could page on any of it: the timely
findings were correct and capped under the threshold
(ANOMALY_QUERY_DURATION 0.72, ANOMALY_BATCH_REQUESTS 1.0,
ANOMALY_CPU_SPIKE 0.56, against notify_severity 1.5), the finding that
DID clear it keys off query_store data that had not landed yet (~28
minutes), and no alert family covers the shape - long_running_query
fires at 30 minutes and the victims died at 30 seconds, poison_wait
does not class PAGEIOLATCH, CPU stayed near 50%. The tool watched an
outage it could describe and could not report.

* The finding. SAME_STATEMENT_PILEUP: N concurrent sessions on one
  statement at one snapshot instant, EVERY copy past a floor of elapsed
  time, at least one suspended on an IO-class wait, where that same
  statement's own snapshots across the prior 45 minutes were sub-second.
  The conjunction is what makes it specific rather than any single
  threshold - on four production servers x 24 hours (~4,160 snapshot
  instants) it fired exactly once, on this incident.

* Placement is the point, not an implementation detail. It is evaluated
  on the COLLECTION cadence - the Darling worker's per-server sweep on
  the 30-second alert-sweep stamp, Lite's one-minute collection loop -
  immediately behind the collectors that write its input, so the
  20:10:31Z snapshot produces a notify-eligible finding inside one
  collection cycle instead of 21 minutes later at the scheduled pass. It
  is NOT on the 5-360 minute analysis interval, and it deliberately does
  not inherit the scheduled pass's 24-hour data-span gate: that gate
  exists because fraction-of-period fact scoring distorts on short
  windows, and this finding's evidence is one snapshot plus one
  statement's own recent history.

* Query Store independence is a requirement, not a convenience. On at
  least one production store class the Query Store readers are
  deliberately disabled (#2296) - the deployment this incident happened
  on, where tonight's plan-count evidence had to come from outside the
  product. A detector keyed on query_store identity would compile, test
  green against a QS-enabled fixture, and ship blind on exactly the
  servers that needed it. So the trigger, the baseline, the statement
  identity and the incident fingerprint are all computed from the
  active-query snapshot collector alone, and the statement key is the
  DMV's own query_hash off sys.dm_exec_requests rather than anything
  Query Store hands out. SameStatementPileupSourceCensusTests holds that
  from source for the detector and BOTH editions' readers: no
  query_store identifier in code or SQL, and the only tables the trigger
  path may read are the permitted collector tables. PLAN_REGRESSION and
  the #2138 force-plan bot stay what they were - follow-on enrichment
  where QS happens to be readable, never the trigger.

* Thresholds from fleet distributions, named per #3060, each derivation
  stated on its constant in the unit the gate compares. Three sessions,
  because n>=2 same-statement groups number 484 in four server-days and
  are full of ordinary paired workloads while n>=3 past the elapsed
  floor numbered four; a two-session pileup therefore produces NO
  finding at any duration, which is the issue's "must not notify" held
  one step stronger than asked. Ten seconds, because the victims died at
  the client's 30-second timeout and a 10 s floor pages while they are
  still alive (the measured pack cleared it with 2.7x headroom). One
  IO-waiting session, not two: page-IO waits are millisecond-scale - the
  incident's suspended copies showed wait_time_ms 5 - so demanding two
  SIMULTANEOUSLY suspended makes the gate a coin flip on sampling phase,
  and specificity is carried by the conjunction instead. A 45-minute
  sub-second baseline window with at least one observation in it, which
  is what turns "normally fast" into a measurement: the three fleet
  shapes that reach n>=3 past ten seconds and are NOT incidents fail
  precisely this gate or the IO one - a sync job at n=5 with no IO wait
  and no prior observation, a permission rebuild whose priors were
  already at 11.9 s, a nightly catalog scan with no baseline at all.

* Severity prices magnitude, not presence (#3462), in the unit the gate
  compares (#3463): sessions x the pack's minimum elapsed seconds, over
  40 session-seconds per severity point, capped at the band ceiling. The
  measured incident - 3 x 27.703 s = 83.109 session-seconds - scores
  2.0777 before the cap, 0.578 severity points above the 1.5 gate, and
  would still clear it with two of its three sessions discounted to the
  floor. The minimum firing shape (3 x 10 s = 30 session-seconds) scores
  0.75: a persisted, drill-downable, visible finding that does not page.
  Notify-eligibility begins at exactly 60 session-seconds - three
  concurrent copies each past two-thirds of the timeout that killed the
  measured victims.

* Recurrence folds instead of surprising. The measured statement
  re-drew its regressed plan later the same evening after fully
  self-clearing, so the finding gets the incident-fingerprint treatment
  every alert family already has: the IncidentId recipe extended with
  the statement identity, which keeps episode two on episode one's
  incident trail as an occurrence (first_seen / last_seen / occurrences,
  the drill-down an operator wants when deciding whether the plan
  deserves forcing) and gives #2138 a trigger token that is stable
  across episodes, plan churn, restarts and QS resets. The story path
  carries the statement identity too, so muting or cooling down one
  statement's pileup cannot answer another statement's question
  (#3459's rule).

* Both editions, one detector. Lite's active-query snapshots feed the
  same seam - one shared collector definition, one table shape - so the
  predicate, the thresholds, the severity arithmetic, the fingerprint
  and the noise filters live once in PerformanceMonitor.Analysis as a
  pure function of snapshot rows, and each edition contributes only its
  window reader (Npgsql over query_snapshots, DuckDB over
  v_query_snapshots) and its sweep call site.

* It adds no delivery semantics. The finding rides the existing
  analysis-finding machinery end to end - mute registry, two-phase
  materialize-enrich-persist, the (story_path_hash, incident_id)
  occurrence folding, notify_severity, the incident-keyed cooldown and
  its #2054 worsening rule - which also means a recurrence inside the
  6-hour cooldown folds silently rather than re-paging, because a
  band-saturating severity cannot worsen. It IS a new delivery call site
  on both SKUs, which is a different thing and is accounted for as one:
  each sits under ShouldNotifyAnalysisFindings (master AND family), each
  carries its own #3465 census entry, and AlertMasterSwitchSurfaceTests
  pins both in both directions, with the predicate's call count raised
  from two to three. The README sentence that makes alerts_enabled a
  promise widened from "scheduled-analysis finding notifications" to
  "analysis finding notifications" - the narrower wording described a
  promise smaller than the one the gates keep, and an operator could
  reasonably have read a non-scheduled finding as escaping the switch,
  which is the exact confusion #3464 was about.

* One read-side change, in both stores, for the same reason. The
  latest-batch read excludes pileup rows from DEFINING MAX(analysis_time)
  and overlays the ones newer than it. #2448's contract is that the
  newest batch is one indivisible statement about a server - the
  complete output of one pass - and a per-sweep row claiming the MAX
  would replace the last complete analysis with a single-row one, making
  a server read HEALTHIER mid-incident. That is #2448's own misreading
  arriving through the write cadence instead of a torn batch. The
  overlay is self-limiting (visible until the next scheduled pass
  completes, then in the windowed reads and its incident trail), and the
  epoch fallback keeps a pileup visible where analysis is disabled and
  no scheduled batch exists.

No migration rung is claimed and none is needed: the finding persists
through analysis_findings' existing columns, reads query_snapshots'
existing columns, and keeps its one piece of cross-sweep state
(the last-evaluated snapshot instant) in memory, where a restart costs
at most one repeated evaluation that folds onto the same incident id.

Tests: the 20:10:31Z incident reconstructed verbatim from the store's
own rows (notify-eligible severity, statement identity, session and
elapsed evidence, the drill-down's sessions and baseline, and the frozen
value-stated advice quoting the measured wait type); the 20:07:01Z
pre-incident snapshot must not fire; a busy-but-healthy eight-session
parallel workload on DIFFERENT statements must not fire; a long-running
single session must not fire; two sessions must not fire at 12 s, 29 s
or 600 s; the fleet's three other deep groups must not fire, each
failing a different gate; no-IO, stale-snapshot, noise-shape,
one-deep-copy-among-fast-siblings and duplicate-session shapes must not
fire; episode two folds onto episode one's incident id and mute key
while the fingerprint still separates statements, databases and servers;
the severity table is pinned at six points with the notify boundary
expressed in session-seconds; and QS independence is pinned both
behaviorally (the row type is the contract) and structurally (the source
census over three files).

* State the pileup detector's onset blind spot, and stop the dedup stamp from outrunning the persist (#3467)

Review of the pileup rung surfaced one behavior worth fixing and one worth
stating out loud instead of fixing.

The statement first, because it is the bigger admission. The detector's
baseline gate reads the statement's prior in-flight observations - and the
collector can make the episode itself one of them. If the first sample of
an episode lands while the sessions are between the sub-second ceiling and
the elapsed floor (1-10 s in), that instant correctly fires nothing, and
its rows then sit in the 45-minute lookback as multi-second priors: every
later instant of the same episode fails the sub-second ceiling against
them, and the episode never fires at all. It fails quiet - a missed page,
never a false one - and the exposure is sampling phase: a 9-second landing
band in a 60-second collection cycle, roughly 9-in-60 on the measured
90-second shape. This is accepted, deliberately: both repairs are gate
changes (onset-exclusion, or pricing on the qualifying subset past the
floor), each with edges of its own, and neither ships ahead of a measured
miss to calibrate against - the recurrence watch, which folds episodes onto
a statement-scoped incident id, is the instrument that would produce one.
The detector's remarks now carry the full paragraph, and the poisoned
sequence is pinned as accepted behavior: instant one at ~5 s (no finding,
the floor), instant two past the floor (still no finding, the ceiling reads
instant one's rows), and the counterfactual - the identical second instant
without the onset sample - firing. The every-session floor's fresh-arrival
dilution gets the same treatment beside its comment: the qualifying-subset
alternative is named, the reason it waits (the fleet calibration was
measured under this predicate) is stated, and the same reopen shape covers
it.

The fix: both call sites advanced the per-instant dedup stamp BEFORE
InsertFindingsAsync, so a transient persist fault marked a firing instant
"done" and lost its finding for good if the episode cleared before the next
collector write. The stamp now advances only once the instant's outcome is
durable - evaluated clean, fully muted, or persisted - and a fault leaves
it behind so the next sweep re-derives the same instant, where occurrence
folding absorbs the re-persist and the incident-keyed cooldown absorbs the
re-notify. The ordering is pinned from source in both files.

The polish, each item its own small honesty:

* The census's permitted-tables set pre-cleared waiting_tasks, which made
  "adding it later is a deliberate census change" false - the read could
  have arrived without touching a census line. The permission is removed:
  adding the read now means widening the set in the same change, so the
  dependency lands as a recorded decision.
* The worker's doc quoted the README's pre-#3467 contract sentence
  ("scheduled-analysis finding notifications"); the quote now matches the
  widened sentence this lane shipped.
* Both finding stores justified the epoch COALESCE with a deployment that
  cannot exist (analysis disabled writes no pileup rows either - the sweep
  rides the same gate); the comments now name the real anchors: a young
  install whose pileup fired before the data-span gate let a first
  scheduled batch exist, and analysis toggled off after pileup rows landed.
* PgPileupSnapshotReader's catch now uses the directory's own
  AnalysisShutdown.IsExpectedAbandon filter (#2299) instead of a bare
  OperationCanceledException carve-out, so shutdown residue propagates
  quietly and a cancellation that nobody requested keeps its log line.

Out-of-band verification (the test projects target net10.0-windows): the
detector harness replays 102 checks and the census harness 93, both clean,
including the onset pin and the stamp-ordering pin.

* Re-pin the dashboard-shape read to the carved batch anchor it now ships (#3467)

CI's only failure on 601ab8a (1 of 9,449) was FindingStoreSql_CarriesTheDashboardShape:
its GetLatestFindingsSql pin still asserted the pre-#3467 single-line anchor,
"SELECT MAX(analysis_time) FROM analysis_findings WHERE server_id = $1" — a string that
no longer exists, because the carve-out deliberately rewrote it: the MAX now excludes
SAME_STATEMENT_PILEUP rows from DEFINING the batch (a per-sweep pileup row claiming the
MAX would replace the last complete analysis pass and read the server healthier
mid-incident), and the subquery spans two lines.

The re-pin asserts MORE than the old pin, not less: both fragments as they actually
appear in the const — the MAX anchor line and the WHERE line carrying the exclusion
predicate. The full carve-out contract (overlay arm, epoch anchor, Lite twin parity)
already belongs to SameStatementPileupSourceCensusTests, which normalizes source
whitespace and passed on the same run; this pin's job stays what it was — the dashboard
shape of the Darling store's own consts — now stated against the SQL that ships.

Why the lane's out-of-band verification missed it: the detector and census harnesses
replay the NEW tests (102 + 93 checks, clean); this pin lives in the pre-existing suite
that only executes on Windows CI. A Contains pin between two compile-time strings is
fully verifiable by grep, which is how this fix was checked: each fragment appears
twice in GetLatestFindingsSql (anchor arm and COALESCE arm), and the stale single-line
form appears nowhere outside DELETE cleanup statements.
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