Repository navigation
Forced Plan Failing fires once per observation, not once per cooldown: the adapter returns the collection that produced the rise and the engine remembers which one it already reported (#3579) - #3628
Conversation
…: the adapter returns the collection that produced the rise and the engine remembers which one it already reported (#3579)
There was a problem hiding this comment.
LGTM — reviewed the AlertEngine observation-guard logic (sameObservation/LastAlertedObservedAtUtc), the Darling (Postgres) and Lite (DuckDB) SQL/reader changes for byte-for-byte parity, index-coverage impact, SQL-style conventions, and edge cases (null stamp fallback, muted fire, recovery reset, cooldown-inside-window). No correctness, security, or parity issues found; the diff matches exactly what the PR description claims to touch.
… stamp round-trips PostgreSQL's timestamp to the tick (#3579)
|
Reviewed the diff (AlertEngine.cs, ForcePlanFailureInfo.cs, both read adapters, and the Lite/Darling test suites). What I checked:
No correctness bugs, parity drift, or security issues found. This is an unusually well-documented and well-tested fix (production evidence, six new AlertEngine tests, a new Lite DuckDB round-trip test, and updated access-path pins). |
There was a problem hiding this comment.
LGTM — verified the observation-stamp guard logic in AlertEngine.cs (correct once-per-observation semantics with proper null/mute/recovery handling), ordinal-safe SQL changes in both read adapters, Lite/Darling parity (text-identical SQL, identical reader mapping), the covering-index claim, and parameterized queries (no injection risk). No substantive issues found.
…le, Opus full tier (#3710) (#3718) * REVIEW-TRAPS.md: the repository's recurring defect shapes, one line each with the PR that bit (#3710) Seven layers (SQL, collectors and deltas, analysis, alerting and notifications, storage and MCP payloads, tests and censuses, workflows and CI, repository mechanics), fifty-odd lines, every one citing the pull request or issue that verified it: the >= limit truncation inference (#3594/#3679/#3699), ORDER BY 3 + 4 folding to a constant (#3613), MAX(max_dop) across plans (#3648), integer division in cntr_value_per_second (#3653 Q9), per-collection alert firing against a shorter cooldown (#3628/#3636), restart zeros in deltas and baselines (#3595/#3698/#3705/#3708), ScoreAll skipping amplifiers on a zero base and the non-virtual GetActiveEdges (#3688), the PgTargetFactKeys censuses (#3688), pg_total_relation_size vs the heap and the two TimescaleDB views that hide materializations and history (#3610/#3585), the -infinity crash backoff (#3629), the Slack block budget and the surrogate-pair cut (#3612/#3618/#3625/#3644), LCK_M_SCH_M folding into LCK so no SCH_M fact exists (#3709), one wait profile per run (#3709), and the CRLF round-trip. Each citation was read before it was written down; a wrong citation is worse than none. The review workflow reads this file before the PR body and must say, per trap the diff is near, whether it applies here and why. * Claude review: blind read before the body, callers rule, structured verdict with a ledger, and an Opus/Sonnet tier by changed path (#3710) The prompt runs in two phases. Phase 1 forbids gh pr view / git log / git show until the reviewer has read the diff, every changed region in context, the callers of every changed public symbol (Grep, both SKUs and both test projects) and .github/REVIEW-TRAPS.md, and written its own account. Phase 2 reads the body and grades every claim VERIFIED / UNVERIFIED / REFUTED, then reports the DIVERGENCE between the two accounts. The verdict is a fixed block -- Claims, Riskiest lines with the pinning test or UNPINNED, Traps near this diff, Divergence, Reviewer checklist -- ending in one machine-readable ledger line, posted through gh pr review --body-file - with a quoted heredoc (no Write tool; the allowlist is unchanged and still read-only). The #3650 verdict check now fetches the newest verdict in the window by id and fails the job when the body lacks the "## Verdict:" header or the ledger, or when the ledger's verdict disagrees with the review's API state (a "CHANGES REQUESTED" body submitted with --comment is the shape the guard would wave through). The guard's own arm is untouched and still keys on the state. A dorny/paths-filter@v4 step (the repo's pin style) routes changes under the shared libraries, Darling Storage/Analysis/Service, Lite Services/Mcp/Database, install/ and every .sql file to the full tier -- claude-opus-5[1m], 60 turns -- and everything else to claude-sonnet-5 at 30 turns; one decision step emits tier/model/max_turns, one review step consumes them (two steps would double the "review posted" accounting the guard does), and the prompt carries the tier and model into the ledger. A filter that dies degrades to FULL, not light. Step name "Claude review" and the ALWAYS POST sentinel are unchanged: both are read by claude-review-guard.yml. * Review tier: Alerting and Notifications read at the full tier (#3710)
The mechanism
CheckForcePlanFailuresAsyncdocumented itself as "a standing condition … while a plan keeps failing it re-fires on the cooldown, mirroring the database-state family." That is the right contract when the input is re-measured on every pass. This condition's input is not.ForcePlanFailuresSqlranks one plan's sightings bycollection_timeand returns the plan when the NEWEST sighting'sforce_failure_countis above the PREVIOUS one's — a fact about two collections. It becomes true the instant the newer collection lands and then stays byte-identical until the next one does. The engine re-asks every ~30 s; the cooldown is 5 minutes; the query_store collector's cadence is 5 minutes by default and ~15 on the store that found this. A cooldown shorter than the cadence expires several times against the same two rows, and at pass granularity "still failing" and "no new data yet" are indistinguishable. Nothing carried the observation's identity from the adapter to the engine, so the engine took the second for the first, every time.The evidence
One production store, one plan (#3579). The raw per-collection series was
0, 0, 1 (forced AUTO), 0, 0at 03:47 / 04:02 / 04:18 / 04:50 / 05:01. Between the 04:18 and 04:50 collections the engine fired six times — 04:18:53, 04:24:31, 04:30:04, 04:35:33, 04:40:49, 04:46:03 — one per cooldown, every card honestly reading New 1 / Total 1, because every pass re-read the same 04:02→04:18 rise. The channel saw two of the six only because webhook-side throttling ate the rest. The arithmetic was right throughout; the repetition was the defect.The codebase already names this class: "#2704/#2708 — a cooldown timer with no memory of which data point it last fired on." Poison wait took the unrefreshed-source-row guard for it under #2704 (
_lastPoisonWaitCollectionTime, on both the SQL Server engine and the PostgreSQL twin). This is that guard at plan grain.Why this shape
The adapter returns the observation's identity.
ForcePlanFailureInfo.ObservedAtUtcis the newer sighting'scollection_time, appended as the eighth columnobserved_atin both SKUs' SQL. Appended, not inserted, so the seven ordinals both readers already bind do not move. It isn.collection_time, already inper_collection's GROUP BY and already the second key column of the #3573 covering index — no newqs.reference, so the Index Only Scan is untouched (the access-path pins re-derive the column list from the statement text and still agree with the index).The engine fires once per NEW observation that shows a rise. Per plan it remembers the stamp it last fired on and declines to fire a stamp that is not newer, regardless of cooldown. A newer stamp with a rise fires — a plan failing across successive collections still re-fires, each collection being a new observation — and the cooldown still rate-limits those. Recovery is unchanged: the plan stops appearing, the resolution is announced, and the memory goes with it.
One per-plan state, not two. The memory lives beside the identity the condition already kept for the recovery message:
_activeForcePlanAlertsnow holds a smallForcePlanActivePlan { LastSeen, LastAlertedObservedAtUtc }per plan instead of the bare row. The two fields are deliberately separate memories —LastSeenis refreshed on every pass the plan appears;LastAlertedObservedAtUtconly when it fires. Folding them into one "last seen" stamp was the tempting shortcut and would have been wrong in the direction of silence: a collection landing INSIDE the cooldown would have been recorded as seen and then never fired. There is a pin for exactly that case.The comparison is "not newer", not "not equal." The contract reads "once per new observation," so an equal stamp is not new and neither is an older one (nothing produces one today — a deleted newest row or a clock step would). A muted fire stamps the observation as it stamps the cooldown: the operator muted the plan, not the engine's memory of it.
Stampless rows degrade toward repetition, not silence.
ObservedAtUtcis nullable. A null never matches a remembered stamp, so an adapter that supplies none keeps the pre-#3579 cooldown-repeat — the same stated fallbackIAlertStateStoregives a host that cannot persist. Both shipped adapters always supply it;collection_timeis NOT NULL in both stores. Pinned so the fallback is a decision rather than an accident of null comparison.Restart behaviour, stated. The family's state is in-memory (
alert_persistence_stateis not used by this condition and nothing about it is persisted today), so a restart empties the memory and the first pass after one may re-fire once for an observation still in the two-hour window. That is exactly what the cooldown clock, also emptied, did before this PR — the guard never makes a restart noisier than it was. Persisting it would be a newIAlertStateStoreseam for a once-per-restart card; not taken here.What it does NOT do
>filter, the severity, the card's fields, the mute key, or the recovery text.DarlingWorker, or any other condition. The census below found one sibling with the same shape whose fix is NOT the identical guard; it is named for a follow-up rather than widened into here.Census — conditions in
AlertEngine.csthat read a stored between-collections fact and re-fire on cooldownThe issue asked for this before writing. "Same shape" = the condition's input changes only when the collector lands a row, AND its cooldown (5 min default) is shorter than the feeding collector's cadence, AND nothing carries the observation's identity.
NewestCollectionTime)DatabaseFileGrowthInfohas nocollection_time; the adapter CTE hasc.collection_timebut does not project itRollingCountAlertGatewatermark on the count (#1091) is an edge, not a cooldown repeatSnapshotIsFreshgateLowDiskAlertGate/PvsAlertGatere-fire only on WORSENING, so an unrefreshed row cannot re-fireSnapshotIsFresh(#1812) refuses stale snapshotsOne sibling with the identical mechanism: Database File Growth's rise arm, 60-minute cadence against a 5-minute cooldown — up to twelve cards per hourly observation of one growth event. Not fixed here because the fix is not the two-line guard: the stamp is not available to the engine and would have to be plumbed through both adapters and the info type exactly as this PR does for forced plans. It should take the same observation-keyed guard in its own lane.
Tests
Executed locally (the mactest pattern — a
net10.0runner namedDarling.Testsover the built Alerting/Service/Storage assemblies; Alerting is not WPF):AlertEngineTests: all 108 pass. Six new pins replay the production series by its clock:ForcePlanFailuresAccessPathTests, all four, against a livetimescale/timescaledb:2.28.1-pg18rig (PG 18.4, TimescaleDB 2.28.1): the store migrated from V1, hypertables converted,PgTableTuningapplied, six servers seeded in the collector's interleaved pattern,EXPLAINof the shipped statement still an Index Only Scan onidx_query_store_stats_server_time_forcingwith noserver_idfilter; the adapter returns the one risen plan withObservedAtUtctick-equal to the seeded newestcollection_time, Kind Utc. New ungated pin:observed_atis the eighth and last column, the scan column set did not grow.observed_at= the 04:18 stamp; after the 04:50 collection it returns nothing. Rig torn down.CI is first execution (the Lite assembly is WPF and cannot load here; both test projects build
-c Release -p:EnableWindowsTargeting=truewith 0 warnings):ForcePlanFailuresSql_IsTheDarlingText_ReadingTheDedupView— Lite's text equals Darling's but forv_query_store_stats, held as whole-text equality (verified equal by script), plus the last-column pin.ForcePlanFailuresReadTests— the same series through Lite's real DuckDB read: the rise returned once with the newest collection's stamp to the tick, the re-read returning the same stamp, the reset collection returning nothing.Closes #3579