Repository navigation
Chosen database states alert once, not forever — edge-trigger OFFLINE/RESTORING (#2166) - #2182
Conversation
| using var command = new NpgsqlCommand(@" | ||
| INSERT INTO config.database_state_expected (server_id, database_name, expected_state, is_user_override, updated_at, last_alerted_state, last_alerted_at) | ||
| VALUES ($1, $2, $3, false, (now() AT TIME ZONE 'UTC'), $3, (now() AT TIME ZONE 'UTC')) | ||
| ON CONFLICT (server_id, database_name) DO UPDATE SET | ||
| last_alerted_state = EXCLUDED.last_alerted_state, | ||
| last_alerted_at = EXCLUDED.last_alerted_at", connection); |
There was a problem hiding this comment.
This upsert breaks the "integrity states must keep re-firing" guarantee for the exact case the PR calls out as the composition property to protect.
expected_state is NOT NULL (V49), and this INSERT sets it to $3 = effectiveState — the state that was just alerted, reused for both expected_state and last_alerted_state. That's fine when a row already exists (the ON CONFLICT branch only touches last_alerted_state/last_alerted_at), but it's wrong for a brand-new row.
Concrete sequence:
- A database with no
config.database_state_expectedrow (never seen deviating before) goes SUSPECT.SeedDatabaseStateExpectedSqldeliberately skips seeding SUSPECT/RECOVERY_PENDING/EMERGENCY on first observation ("onboarding a server mid-outage must not learn the bad state as expected... stays pending (no row)"). DatabaseStateDeviationsSql'sexpected_state IS NULLbranch matches (two-sample rule), the alert fires at Critical with the "no baseline yet" message.SaveDatabaseStateAlertedAsyncnow INSERTs a fresh row withexpected_state = 'SUSPECT'.- Next cycle:
e.expected_stateis no longer NULL, andl.eff IS DISTINCT FROM e.expected_stateis false (both are'SUSPECT') — the database drops out of the deviation query entirely. No more alerts, ever, even though it's still corrupted.
This directly contradicts DatabaseState_IntegrityState_StillRepeats_EvenWhenAlreadyAnnounced (and the PR's own stated intent that SUSPECT/RECOVERY_PENDING/EMERGENCY must keep nagging on cooldown) — it's just not caught because that test stubs IAlertStateStore and never exercises this SQL against real Postgres.
Since expected_state is NOT NULL, this write path needs to either leave the pending/critical case alone (only stamp last_alerted_state/last_alerted_at, e.g. via a plain UPDATE ... WHERE EXISTS, doing nothing when no row exists yet) or explicitly avoid baselining critical states the same way the seed SQL does.
| /// <summary> | ||
| /// V60 — restart-surviving edge memory for the database-state alert (#2166). Two nullable columns on | ||
| /// <c>config.database_state_expected</c>, which is already keyed per (server, database) and already | ||
| /// exists for this alert, so the memory lives beside the config it belongs with rather than in a new | ||
| /// table or smuggled into <c>config_edge_trigger_watermarks</c>' metric_name (that column feeds alert | ||
| /// history and mute matching; a compound key hidden in a label is a trap). | ||
| /// | ||
| /// <para>Why it must persist: the reporter's case is a database deliberately parked OFFLINE for a | ||
| /// month. Edge-triggering on in-memory state would re-fire every parked database on every service | ||
| /// restart — worse than the cooldown-repeat it replaces. NULL means never alerted, so an upgraded | ||
| /// store's first evaluation fires once per deviating database and then goes quiet.</para> | ||
| /// </summary> | ||
| private const string V60Sql = @" |
There was a problem hiding this comment.
Doc-comment/field mismatch: this V60 /// <summary> block is inserted directly after V59's existing /// <summary> block (lines ~1142-1153, "V59 — the two collector memory knobs...") with no field declaration between them. C# attaches an uninterrupted run of /// lines to whatever member follows, so both summaries merge onto V60Sql below, and V59Sql (declared right after V60Sql) ends up with no doc comment at all. Worth reordering — e.g. put V60's comment+const after V59's comment+const — so each constant keeps its own doc comment, matching how V57/V58/V59 are laid out elsewhere in this file.
|
Reviewed. Solid design overall (edge-triggering only the 'chosen' states, persisting to survive restarts, keeping the integrity states on cooldown) and the engine-level test coverage for
No Lite/Darling parity issues — the Lite no-op ( |
| public async Task SaveDatabaseStateAlertedAsync(string serverKey, string databaseName, string effectiveState) | ||
| { | ||
| try | ||
| { | ||
| await using var connection = await _postgres.OpenConnectionAsync(); | ||
| using var command = new NpgsqlCommand(@" | ||
| INSERT INTO config.database_state_expected (server_id, database_name, expected_state, is_user_override, updated_at, last_alerted_state, last_alerted_at) | ||
| VALUES ($1, $2, $3, false, (now() AT TIME ZONE 'UTC'), $3, (now() AT TIME ZONE 'UTC')) | ||
| ON CONFLICT (server_id, database_name) DO UPDATE SET | ||
| last_alerted_state = EXCLUDED.last_alerted_state, | ||
| last_alerted_at = EXCLUDED.last_alerted_at", connection); | ||
| command.Parameters.AddWithValue(ParseServerKey(serverKey)); | ||
| command.Parameters.AddWithValue(databaseName); | ||
| command.Parameters.AddWithValue(effectiveState); | ||
| await command.ExecuteNonQueryAsync(); | ||
| } | ||
| catch (Exception ex) | ||
| { | ||
| /* Same posture as the watermark writes: a failed stamp costs a duplicate alert next cycle, | ||
| never a missed one, so it logs and continues rather than failing the sweep. */ | ||
| _logger?.LogWarning("Could not record the alerted database state for {Database}: {Message}", databaseName, ex.Message); | ||
| } | ||
| } |
There was a problem hiding this comment.
Correctness: this upsert can permanently silence — and falsely "resolve" — a SUSPECT/RECOVERY_PENDING/EMERGENCY database, exactly the case #2166 says must never go quiet.
Walk through a database first observed already critical (no config.database_state_expected row yet — the "pending" path):
SeedDatabaseStateExpectedSql(DarlingAlertReadAdapter.cs) deliberately skips baselining SUSPECT/RECOVERY_PENDING/EMERGENCY (... NOT IN ('SUSPECT','RECOVERY_PENDING','EMERGENCY')), specifically so the database stays "pending" and keeps alerting until it recovers or an operator sets an override.- Two critical samples in a row →
DatabaseStateDeviationsSql's pending branch (e.expected_state IS NULL AND l.eff IN (...) AND p.eff IN (...)) fires the alert. AlertEngine.CheckDatabaseStateAsynccalls this method after delivery witheffectiveState = "SUSPECT"(say). Because no row exists yet, theINSERTbranch runs — andexpected_stateisNOT NULL, so it gets set to$3, i.e. the current critical state itself.- Next cycle: the row now exists with
expected_state = 'SUSPECT'. The pending branch no longer matches (expected_state IS NULLis false), and the general branch'sl.eff IS DISTINCT FROM e.expected_stateis also false (SUSPECT == SUSPECT). The database silently drops out ofcurrentinCheckDatabaseStateAsync. - Because it drops out of
currentwhile still inactive, the recovery block (~AlertEngine.cs L1325-1339) treats it as recovered and fires "Database State Resolved" — even though the database is still SUSPECT. - It will never alert again unless it later transitions to a different critical state (SeedDatabaseStateExpectedSql never overwrites the row,
ON CONFLICT DO NOTHING).
So a real corruption's very first alert both permanently silences the alert and tells the operator it "resolved" — worse than the pre-PR behavior (which just kept re-firing forever), and the opposite of what the PR sets out to guarantee for integrity states.
The doc comment's justification doesn't hold up either: "the baseline column takes the current state in that case, which the seed logic would have written anyway on the next healthy observation" — but the seed logic explicitly refuses to write a baseline for these three states while they remain critical, so this INSERT does something the seed logic never would while the outage is ongoing.
None of the new tests catch this because the in-memory test harness's fake adapter/state-store are decoupled — nothing exercises the real interaction between this INSERT and DatabaseStateDeviationsSql against the same store.
Possible fix: only persist last_alerted_state for the states that actually consult it (DatabaseStateTokens.RepeatsAreNoise), or make this an UPDATE ... WHERE EXISTS so a not-yet-baselined critical database never gets an accidental baseline written.
| /* Stamped AFTER delivery so a failed fire is retried next cycle rather than silenced. | ||
| Written for every state, not just the edge-triggered ones, so that flipping a state's | ||
| classification later has correct history to work from. */ | ||
| await _stateStore.SaveDatabaseStateAlertedAsync(key, dbName, db.StateDesc); |
There was a problem hiding this comment.
Correctness: recovery (below, ~L1325-1339) never clears the persisted last_alerted_state, so the same "chosen" state recurring in a later, independent episode goes silent forever.
That block clears the in-memory active set and the cooldown entry when a database returns to its expected state, but nothing calls back into _stateStore to clear the persisted last_alerted_state/last_alerted_at this line writes (PgAlertStateStore.cs L172-194).
Concretely: a database is parked OFFLINE → alerts once, last_alerted_state persisted as 'OFFLINE'. Operator brings it back ONLINE → the recovery block fires the "resolved" notice, but the persisted last_alerted_state column is untouched. Weeks later the same database is deliberately parked OFFLINE again (a second, unrelated soft-delete cycle — exactly the workflow described in the PR). DarlingAlertReadAdapter's deviation query still returns last_alerted_state = 'OFFLINE' from the first episode, so alreadyAnnounced (L1262-1264 above) is true and the second parking never alerts at all.
The "composition property" test added here (DatabaseState_ChosenState_ChangingToADifferentState_FiresAgain) only proves re-firing across a different subsequent state (OFFLINE → SUSPECT); nothing pins the same-state-after-recovery case, which is the one most likely to recur in practice for a repeated soft-delete/park workflow on the same database.
Worth clearing the persisted state on recovery (a _stateStore call in the block below), plus a test that pins: park → alert → recover → re-park with the same state → alerts again.
|
Reviewed the diff. Overall the edge-trigger design and the persisted-vs-in-memory reasoning in the PR description are sound, and Lite parity (documented no-op) is correctly inert. Left two inline comments on what I believe are real correctness regressions in the persisted-memory interaction, both missed by the new unit tests because the in-memory test harness's fake adapter/state-store never exercise the real SQL round-trip together:
Both are edge-memory lifecycle gaps rather than problems with the overall approach — I think both are fixable by (a) only ever persisting No Lite/Darling parity issues beyond what's already called out (Lite deliberately inert), and no SQL-injection or secret-handling concerns — all queries are parameterized. |
|
Both findings confirmed and fixed in 5e2a74b. Both were real, and the first one was worse than what this PR set out to fix — thank you. The upsert. Walked the pending path and it lands exactly as described: no row → INSERT → Worth stating why UPDATE-only loses nothing, since "skip the no-row case" sounds lossy: a row is absent only when a database was first observed in an integrity state, and The permanent memory. Also correct, and the OFFLINE → restore → OFFLINE-again sequence is the exact workflow the issue describes, so this would have bitten the reporter specifically. Added On "none of the new tests catch this." That was the most useful part of the review, because it named the structural reason rather than just the miss: the stub state store counted calls while the adapter supplied states, so nothing crossed the round trip the edge trigger actually depends on. Verified: Alerting, Darling.Tests, Lite and Lite.Tests all build clean (the interface addition needed the Lite no-op and three stubs). |
| edge only ever triggers once per database. Cleared even when suppressed — suppression | ||
| governs whether operators are TOLD about a transition, never whether the engine keeps | ||
| accurate state, and leaving a stale memory behind would swallow the next real episode. */ | ||
| await _stateStore.ClearDatabaseStateAlertedAsync(key, dbName); |
There was a problem hiding this comment.
This clear — the thing that makes the persisted last_alerted_state restart-safe against the exact bug the 3rd commit just fixed — is itself only reachable through _activeDatabaseStateAlerts (line 140), which is in-memory only and resets to empty on every process restart.
ClearDatabaseStateAlertedAsync runs for active.Where(d => !current.ContainsKey(d)) — i.e. only for databases this process instance previously observed as deviating. Walk through a restart landing between an alert and its recovery:
- DB parks OFFLINE. Fires, persists
last_alerted_state = 'OFFLINE'.active = {db}. - Service restarts (deploy/patch/crash) —
activeresets to{}. The persisted value is untouched (that's supposed to be the point). - Before the next eval cycle observes the database as still-deviating, it recovers to ONLINE.
- First post-restart eval: the deviations query no longer returns this db (back to expected), so it's never in
current— andactiveis empty, so it's never inrecoveredeither. This line never runs.last_alerted_statestays'OFFLINE'forever. - Weeks later the DB is parked OFFLINE again — the exact repeat-park workflow [FEATURE] Database State alert: per-state notify mode - fire OFFLINE/RESTORING once instead of re-firing every cooldown #2166 exists for.
LastAlertedState('OFFLINE') still equalsStateDesc('OFFLINE'),alreadyAnnouncedis true, and the second parking is silently swallowed. Nobody is ever told.
This is narrower than the bug the 3rd commit fixed (needs a restart in the gap between alert and recovery, not just any recovery), but it's the same failure mode and it undercuts the PR's stated restart-survival goal. It's also a departure from how every other _active* flag in this file is documented to behave (lines 87-89: "In-memory only... the restart protection is the persisted watermarks... not these"). For those alerts, losing active on restart risks only a missed/duplicate resolution notification, because the persisted watermark that actually gates re-firing is a monotonic count/time that naturally supersedes stale values. Here the persisted value is a sticky state string whose only path to being cleared is this ephemeral set, so that invariant no longer holds for this alert.
DatabaseState_RepeatEpisode_OfTheSameState_FiresAgainAfterRecovery doesn't catch this because it drives the whole episode (alert → recover → re-park) through one AlertEngine/active instance — it never simulates a restart landing between the alert and the recovery.
A fix probably needs the clear to not depend on process-local state — e.g. have the adapter/store also identify databases with a non-null last_alerted_state whose current effective state now equals expected_state, so the memory can be cleared from what the store actually holds rather than from what this process happened to observe.
ReviewWent through the three commits (edge-trigger the chosen database states, fix the migration-doc reorder, and the author's own self-caught fixes for baseline-poisoning + falling-edge reset). Overall the design is sound and well-tested for the scenarios it targets — the UPDATE-only fix for One correctness gap remains (left as an inline comment on Other notes, none blocking:
|
|
Confirmed and fixed in 5be13e7. Third real one, and the diagnosis of why was the valuable part. You're right that this alert can't follow the convention documented at lines 87-89. For the other Fixed by taking your suggestion literally: the clear is now derived from the store. I kept the engine-side clear as well, and want to be explicit that this is two layers rather than accidental duplication: the engine clear is the immediate path, so a recovery inside one process doesn't wait for the next cycle's sweep, and the store sweep is what owns the invariant. If they ever disagree, the store wins by construction. Three details that took some thought:
On testing: you were right that the harness test can't reach this, and I don't think any harness test can — the restart gap is defined by there being no process state, so anything driving the engine proves only the path a running process takes. So it's a gated-live test ( Worth noting these three statements had no test coverage at all before this, so that's a small net improvement beyond the fix. |
| DatabaseStateTokens.RepeatsAreNoise(db.StateDesc) | ||
| && string.Equals(db.LastAlertedState, db.StateDesc, StringComparison.OrdinalIgnoreCase); | ||
|
|
||
| if (!suppressed && !alreadyAnnounced && CooldownElapsed(_lastDatabaseStateAlert, cooldownKey, now, alertCooldown)) |
There was a problem hiding this comment.
Worth double-checking the "composition property" this PR pins (OFFLINE → SUSPECT must fire again at Critical): alreadyAnnounced correctly goes false on a state change, but the fire is still gated by CooldownElapsed(_lastDatabaseStateAlert, cooldownKey, now, alertCooldown), and cooldownKey (DatabaseStateCooldownKey) is keyed per database, not per state.
So if a database goes OFFLINE (alerts, stamps _lastDatabaseStateAlert[cooldownKey] = now) and then flips to SUSPECT within the same cooldown window, the transition-to-SUSPECT alert will be silently suppressed by the still-running cooldown from the OFFLINE alert — even though alreadyAnnounced is false for SUSPECT. That's exactly the "going quiet must not mean going blind" scenario the PR calls out as the important safety property, and none of the new tests exercise it because they all start from a fresh harness where the cooldown key has never been stamped (so CooldownElapsed trivially returns true).
This isn't introduced by this PR (the cooldown key was already shared across states pre-#2166), but the new edge-trigger feature raises the stakes: previously every deviation re-fired every cooldown regardless of state, so a same-cooldown-window SUSPECT would eventually re-announce on the next tick anyway. Now that OFFLINE goes quiet indefinitely, a fast OFFLINE→SUSPECT flip landing inside one cooldown window means the SUSPECT transition can be missed for a full cooldown period. Given SUSPECT is the integrity/safety-net case the PR explicitly designed to never go quiet, might be worth confirming this is acceptable (cooldown windows are typically much shorter than an outage) or keying the cooldown/announced-state check by state as well as database.
|
Reviewed the diff (C#/Postgres, no T-SQL touched here). Overall this is solid — the design rationale in the commits/PR body is clear, and it's evident the branch already went through a few rounds of self-review (the UPSERT→UPDATE-only fix and the store-derived clear are both good catches on prior iterations). Things I verified and didn't find issues with:
One correctness edge case worth a look, left as an inline comment on Minor/non-blocking style nit: No security or missing-index concerns; the new SQL is fully parameterized and scoped by |
|
Fixed in 91a3ecf rather than accepted, because I think accepting it would contradict the property this PR claims as its reason for existing. Your framing is exactly right: the shared cooldown key predates this PR, and this PR is what turns it from a delay into a silence. Before, every deviation re-fired every cooldown, so a transition suppressed by the previous state's clock re-announced on the next tick. Now that a chosen state goes quiet indefinitely there's nothing to retry it — the suppression lasts the full cooldown window — and the state it lands on is SUSPECT, which the PR explicitly designs to never go quiet. "Cooldown windows are typically much shorter than an outage" is true and still not a good enough answer for the integrity case. So each state now carries its own clock, per your second suggestion. One consequence worth naming: recovery has to clear every state's clock for that database, not one, or a stamp left behind would rate-limit a later episode against a cooldown that started before the recovery — the same class of stale-state bug as the previous two findings, one layer down. Two tests, because the fix can fail in both directions:
You were right that none of the existing tests could have caught this: they all start from a fresh harness where the key was never stamped, so |
| var cooldownPrefix = DatabaseStateCooldownKey(key, dbName, string.Empty); | ||
| foreach (var stamped in _lastDatabaseStateAlert.Keys) | ||
| { | ||
| if (stamped.StartsWith(cooldownPrefix, StringComparison.Ordinal)) |
There was a problem hiding this comment.
Prefix-match delimiter collision: cooldownPrefix = key + "|" + dbName + "|", matched with StartsWith. A database literally named Foo|Bar produces a cooldown key key|Foo|Bar|<state>, which StartsWith("key|Foo|") also matches — so recovering Foo wipes Foo|Bar's cooldown clock too (and vice versa isn't possible the other way, but the false-positive prefix match is real). The doc comment above already calls this out and argues the blast radius is bounded ("at most one extra alert, never a missed one"), which is true, but it's a pre-existing gap that got wider here: the old code did an exact-key TryRemove (no collision possible), and this PR is what introduces the prefix sweep.
Since SQL Server does technically allow | in a (bracket-quoted) database name, this is reachable, not just theoretical. A cheap fix: delimit with a character that can't appear in a sysname (e.g. a control character, or length-prefix the segments) instead of |, or switch the cooldown store to a nested ConcurrentDictionary<string, ConcurrentDictionary<string, DateTime>> keyed by (serverKey|dbName) -> state -> timestamp so clearing a database's states doesn't need string matching at all.
ReviewWent through the engine change, the two new store methods, the V60 migration, the schema-probe wiring, and both live/harness test suites. Correctness — the core logic holds up under scrutiny:
Left one inline comment on a delimiter-collision edge case in the new cooldown-prefix sweep ( Lite/Darling parity — intentional and correctly scoped. Security — no injection risk; all production SQL is parameterized. The one string-interpolated Minor nit (not filed inline, too trivial): both No missing-index or other DMV-folklore suggestions offered, per repo convention. |
|
Fixed properly in the latest commit rather than left documented, because you're right that this one is on me in a way the earlier findings weren't: the old code did an exact-key Your second suggestion is the one I took, slightly generalised. There's no delimiter a That needed The test pins your exact scenario: Five findings on this PR, and every one was real. The pattern I'll take away is that all five lived at a boundary the tests didn't cross — process memory vs store, one state vs another, one database's key vs another's. |
| /* V60 (database-state edge memory, #2166): column-existence sentinel, newest-first arm. | ||
| config.database_state_expected.last_alerted_state exists only at V60 or later. The viewer NAMES | ||
| that table's columns in its expected-state editor, so the gate must refuse a V59 store rather | ||
| than let a Settings read fail 42703, and a fully-migrated V60 store must map to exactly | ||
| RequiredStoreSchemaVersion. */ |
There was a problem hiding this comment.
Nit: this justification doesn't hold for V60. It says "the viewer NAMES that table's columns in its expected-state editor, so the gate must refuse a V59 store rather than let a Settings read fail 42703" — but ViewerDataService.DatabaseStates.cs never references last_alerted_state anywhere (its seed/read/set/reset SQL only touches expected_state/is_user_override). Nothing in the Viewer would 42703 against a V59 store.
This looks like the V59 rung's comment (which is accurate — query_store_text_budget_mb really is named in ServiceConfigSelectSql/UpdateFlagsSql) got copy-pasted without updating for V60's actual situation. Bumping RequiredStoreSchemaVersion here is still correct (per the general RequiredStoreSchemaVersion == StorageVersion.SchemaVersion invariant this repo enforces every rung), just the stated reason is wrong and could mislead someone deciding later whether the gate can be relaxed for this column.
Review summaryI read through the full diff (engine, both stores, migration V60, viewer schema gate, and the new tests) with a focus on correctness, Lite/Darling parity, security, and performance. Overall: solid. The PR's own commit history shows five rounds of self-review that already caught and fixed real bugs (insert-vs-update baselining a SUSPECT database, the falling-edge clear depending on in-memory state across restarts, the per-database cooldown key colliding across states, and the Parity: Verified this is genuinely Lite-inert as claimed — Security: No new SQL injection surface — all new statements are parameterized ( One nit posted inline: the doc comment on the new |
|
Right on both counts — fixed in the latest commit. The V60 justification was false, not just imprecise. I'd copy-pasted the V59 wording and it claimed the viewer names the new columns and would 42703 on a V59 store. It doesn't: the expected-state editor touches only The bump is still correct, so the comment now gives the actual reason rather than being deleted: the standing The stacked summary was mine too — That pin has now caught me three times in this change set, always the same way: writing a doc block for a member without checking whether it already had one. So rather than keep relying on CI to find it, I added a scripted stacked-summary scan over every file I touch as a pre-push step — it's what confirmed the other five files are clean this time. Worth noting the pin is doing real work; all three would have shipped as silently wrong documentation, since XML docs take the last block and tooling looks fine. |
| against a cooldown that started before the recovery. Matching on two tuple parts rather | ||
| than a string prefix is what keeps a database named 'Foo|Bar' from being swept when 'Foo' | ||
| recovers. */ | ||
| foreach (var stamped in _lastDatabaseStateAlert.Keys) |
There was a problem hiding this comment.
Minor/low-severity: this recovery sweep now does a full scan of _lastDatabaseStateAlert.Keys — which holds entries for every server, database, and state currently tracked, not just this server — for each recovered database, filtering down with stamped.Server == key. Previously (string key) this was an O(1) TryRemove. It's now O(total tracked cooldown entries across all servers) per recovered database, run every evaluation cycle.
Given the entry count here is probably small (servers × databases × handful of chosen-states), this is unlikely to matter in practice, but flagging since it's a real algorithmic regression from the old per-database key. If it's ever worth avoiding, a secondary index keyed by (Server, Database) -> HashSet<State> (or just tracking the small set of states-in-play per active database) would make the sweep O(states-for-this-db) instead of O(everything).
ReviewWent through the full diff (engine, both stores, the V60 migration, viewer schema gate, and the new tests). Overall this is solid — the edge-trigger logic, the falling-edge reset, and the restart-survival story all hold up under scrutiny, and the test suite (including the delimiter-collision and repeat-episode cases) actually exercises the failure modes the PR description calls out. Specific things I checked and confirmed correct:
Left one inline nit on the falling-edge recovery sweep ( No correctness bugs, security issues, or unintentional Lite/Darling parity drift found. |
|
Fixed — you're right that it's a real regression and not just a theoretical one, since the old string key made this an O(1) remove and the tuple key turned it into a scan of every server's entries, once per recovered database. Hoisted to a single pass over the cooldown map against a I did consider your secondary-index suggestion and decided against it, for what it's worth: That should be all seven findings on this PR closed. |
| string prefix is what keeps a database named 'Foo|Bar' from being swept when 'Foo' recovers. */ | ||
| if (recovered.Count > 0) | ||
| { | ||
| var recoveredSet = new HashSet<string>(recovered, StringComparer.OrdinalIgnoreCase); |
There was a problem hiding this comment.
recoveredSet is built with StringComparer.OrdinalIgnoreCase, but the comment right above current/active (lines 1233-1236) explicitly documents that per-database keys here must be Ordinal (case-sensitive) to match the store's case-sensitive database_name joins. recovered itself is sourced from that Ordinal active set, so this line silently widens the match.
Concretely: on an instance with a case-sensitive collation where databases Foo and foo coexist, recovering Foo will also TryRemove foo's per-state cooldown stamps from _lastDatabaseStateAlert (matched via recoveredSet.Contains(stamped.Database)), letting foo's cooldown — its only remaining quiet mechanism for an integrity state like SUSPECT — reset early and fire again ahead of schedule.
This is the same collision class the PR just finished eliminating for '|' in database names (structural tuple key instead of a delimited string) — worth using StringComparer.Ordinal here too for consistency with the rest of this method:
var recoveredSet = new HashSet<string>(recovered, StringComparer.Ordinal);|
Reviewed this PR (V60 edge-trigger for chosen database states, plus the five follow-up fixes already folded into the commit history). Overall: this is a well-tested, carefully-reasoned change — the persisted-vs-in-memory tradeoff is argued convincingly, the tuple-keyed cooldown correctly avoids the '|'-in-database-name collision, the store-derived recovery sweep correctly closes the restart-gap, and the UPDATE-only stamp correctly avoids baselining a critical first observation. Lite parity is handled the right way: the interface grew two methods that Lite implements as documented no-ops, and since Lite's adapter never populates LastAlertedState (defaults to ""), the shared AlertEngine code is provably inert for Lite — verified against Lite/Services/LocalDataService.DatabaseStates.cs. One finding posted inline: AlertEngine.cs:1352 builds recoveredSet with StringComparer.OrdinalIgnoreCase, which contradicts the Ordinal (case-sensitive) invariant documented three lines above it for the same per-database keys, and reintroduces — for case-differing database names — the same class of cross-database cooldown collision that this PR just finished eliminating for the '|' delimiter case. Narrow blast radius (only matters on a case-sensitive server collation with two databases differing only by case), but a straightforward one-word fix for consistency. No other correctness, security, or Lite/Darling parity issues found. Migration V60 is additive/idempotent and the schema-version ladder (StorageVersion, MapProbedSchemaVersion, all the pinned ladder tests) is updated consistently. |
|
Correct, fixed. And this one stings a little, because it's the same collision class the tuple key had just eliminated for Verified your reading against the code rather than taking it on faith: the comment at 1231-1235 does state per-database keys are ORDINAL to match the stores' case-sensitive expected-state joins, While I was there I checked the other two Eight findings on this PR now, every one real. The through-line is worth naming: each lived at a boundary — process memory vs store, one state vs another, one database's key vs another's, and now one casing of a database name vs another. The store-derived clear, the tuple key and this comparer are all the same lesson about identity having to mean exactly one thing on both sides of a boundary. |
OFFLINE / RESTORING / RECOVERING / STANDBY become edge-triggered: alert on the transition, then quiet until the state changes. The integrity states keep re-firing on the cooldown — nobody parks a database in SUSPECT, so there the repetition is the signal, and a test pins that it must never go quiet. The edge memory is PERSISTED (V60: last_alerted_state / last_alerted_at on config.database_state_expected, the table that already holds this alert's per-database config). In-memory would have been worse than the behavior it replaces: every service restart would re-announce every deliberately-parked database, which is precisely the reporter's complaint amplified. Deliberately not stored in config_edge_trigger_watermarks despite that being the existing edge-memory table: it is keyed (server_id, metric_name) and metric_name feeds alert history and mute matching, so per-database use would mean smuggling a compound key into a label column. Darling only. The engine change is inert for Lite by construction: with no persisted memory the adapter reports empty, every deviation reads as new, and Lite behaves exactly as before. Lite's no-op is documented as such rather than faking an in-memory cache, which would go quiet and then re-fire on restart — the worst of both. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The V60 rung's doc + const landed between V59's <summary> and V59Sql, so NoMemberCarriesTwoStackedSummaryBlocks tripped: V60Sql carried two summaries and V59Sql was left undocumented. Reordered so each rung's doc sits on its own const. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…2166) Two correctness bugs the review caught, both worse than the repetition #2166 set out to fix. The alerted-state stamp was an upsert. INSERT has to supply expected_state (NOT NULL) and the only value available is the state being alerted ON, so a database first observed SUSPECT got SUSPECT written as its accepted baseline: it stopped deviating, dropped out of the deviation query, was read as RECOVERED (firing a false resolution on a still-corrupt database), and never alerted again. The seed logic refuses to baseline SUSPECT/RECOVERY_PENDING/ EMERGENCY precisely so those stay pending; this write was doing behind its back what it declines to do in front. Now UPDATE-only. Nothing is lost: a database with no row was first seen in an integrity state, and integrity states are never edge-suppressed, so the memory is never consulted for them. Recovery cleared the in-memory cooldown but never the PERSISTED last_alerted_state, making the memory permanent. Park a database OFFLINE (alerts), restore it (resolves), park it again weeks later - the stale memory still read OFFLINE, the repeat was judged already-announced, and the second parking was swallowed for good. That repeat park/restore cycle is the workflow the alert exists for. Added ClearDatabaseStateAlertedAsync across the interface, both stores and every stub, called on the falling edge - including when suppressed, since suppression governs what operators are told, never whether the engine keeps accurate state. The review also named why the tests missed both: the stub state store counted calls while the adapter supplied states, so no test crossed the round trip the edge trigger actually depends on. The stub now models what the store HOLDS, and the new episode test feeds that memory back in as LastAlertedState - park, recover, re-park in the same state - so it fails if either direction is missing. A source pin keeps the stamp an UPDATE. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
#2166) Third finding, and correct: the clear I added in the previous commit hangs off the engine's in-memory active set, which empties on every restart. A service restart landing between an alert and the recovery therefore left last_alerted_state sticky forever - the database was never in active to be noticed as recovered - so the next parking of that database read as already-announced and was swallowed silently. Narrower than the bug it fixed (needs the restart to land in that specific gap) but the same failure mode, and it undercut the restart-survival the whole change is for. The reviewer also named why this alert cannot follow the convention the other _active* flags document. For those, losing the set on restart risks only a missed resolution notice, because the persisted thing that gates re-firing is a monotonic count or time that stale values naturally supersede. Here the persisted thing is a sticky state string whose only path to being cleared WAS that ephemeral set, so the invariant did not carry over. Fixed by deriving the clear from the store: a new maintenance statement running beside the existing seed and prune, on the same connection, clearing the memory for any database the store now shows back at its expected state. It cannot have the gap because it asks rather than remembers. The engine's clear stays as the immediate path - a recovery inside one process should not wait for the next cycle - and the store sweep is what owns the invariant. Details worth stating: it reads the same EFFECTIVE state the deviation query does (standby resolves to STANDBY, not the raw state_desc), so a log-shipping secondary clears correctly; it clears the (ignore) sentinel too, since a silenced database should not keep a memory that outlives the silence; and one sample at expected is enough where deviation needs two, because clearing is the safe direction and a flap cannot exploit it - a flap never survives the two-sample deviation test to alert in the first place. Tested live rather than in the harness, deliberately: the restart gap is invisible to any test that drives the engine, since it is defined by there being no process state at all. The new gated-live test seeds five databases covering recovered, still-deviating, deviating-differently, standby-effective-state and (ignore), runs the real statement against a real store, and asserts a second sweep changes zero rows - it runs on every evaluation of every server. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Fourth review finding, and the right call is to fix rather than accept it. The cooldown key was per database, so a database going OFFLINE (which stamps the clock) and then SUSPECT inside the same cooldown window had its SUSPECT transition suppressed even though alreadyAnnounced was correctly false for it. The reviewer is right that this predates the PR, and also right that the PR is what makes it matter. Before, every deviation re-fired every cooldown, so a transition suppressed by the previous state's clock re-announced on the next tick anyway - a delay. Now that a chosen state goes quiet indefinitely, the same suppression lasts as long as the cooldown with nothing to retry it, and it lands on SUSPECT: the integrity state this whole design promises never to silence. Accepting that would have contradicted the property the PR states as its reason for existing. Each state now carries its own clock. Recovery clears every state's clock for the database rather than one, since a single removal would leave stamps behind to rate-limit a later episode against a cooldown that started before the recovery. Two tests, because the fix has two failure directions: a transition to a different state must fire inside one cooldown window (both evaluations run back to back, so no wall-clock time passes - the exact condition that broke it), and the same state must STILL rate-limit itself inside that window, proving the cooldown was re-keyed and not disabled. Neither could have failed before, because every existing test starts from a fresh harness where the clock was never stamped. Also documented the one prefix-sweep edge (a database literally named 'Foo|Bar' when clearing 'Foo') and why it is harmless: a wrongly-cleared stamp costs at most one extra alert, never a missed one. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ision (#2166) Fifth review finding, and the reviewer is right that the previous commit made this worse rather than merely inheriting it: the old code removed an EXACT key, so no collision was possible, and my per-state re-key introduced the prefix sweep that created one. Documenting the blast radius as bounded was not a good enough answer to a gap I had just widened. SQL Server permits '|' in a database name, so recovering 'Foo' prefix-matched and wiped 'Foo|Bar' as well. There is no delimiter a sysname cannot contain, so the fix is to stop encoding a compound key as a string at all. _lastDatabaseStateAlert is now keyed by a (Server, Database, State) tuple, and recovery matches two of the three parts structurally instead of comparing string prefixes. The bug class is gone rather than bounded. CooldownElapsed gained a key type parameter to allow it - one line, every existing string-keyed caller infers unchanged, and the nine other families are untouched. That is the smallest change that lets one family key structurally without giving it a private copy of the cooldown helper to drift. Test pins the reviewer's exact case: two databases, 'Foo' and 'Foo|Bar', both alerting; 'Foo' recovers; 'Foo|Bar' must stay quiet. It is observable through an integrity state on purpose - SUSPECT is not edge-suppressed, so its cooldown is the only thing keeping it silent on the second evaluation, and a wrongly-cleared clock makes it fire. Under the string-prefix version this test fails. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…on (#2166) Two fixes, both about comments being true rather than code being wrong. NoMemberCarriesTwoStackedSummaryBlocks tripped: CooldownElapsed already carried a one-line summary, and adding the generic-key explanation above it left the member with two. Merged into one block. That pin has now caught me three times in this change set, always the same way - writing a doc for a member without checking whether it already had one - so I added a scripted stacked-summary check over every file I touch before pushing, which is what confirmed the rest are clean. The V60 arm's justification in ViewerDataService was copy-pasted from V59 and was simply false: it claimed the viewer NAMES the new columns and would 42703 against a V59 store, but the expected-state editor only touches expected_state and is_user_override - nothing in the viewer reads either V60 column. The bump is still right, for a different reason, so the comment now says the real one: the standing RequiredStoreSchemaVersion == StorageVersion.SchemaVersion invariant, plus the fact that it is the SERVICE (the alert engine's edge memory and the deviation query) that would fail on a V59 store, which is exactly why the viewer must not report V59 as good. Written out because the V59 wording does not transfer, and anyone later deciding whether this gate can be relaxed would have been reasoning from a false premise. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…2166) Review caught a real algorithmic regression I introduced with the tuple key: the sweep scanned the whole cooldown map - which holds entries for every server, not just this one - once per recovered database, where the old string key was an O(1) remove. Hoisted to a single pass over the map against a set of the recovered names, so it is O(tracked entries) per evaluation instead of O(recovered x tracked), and skipped entirely when nothing recovered. Correctness is unchanged: still per-state removal (a single remove per database would leave its other states' stamps to rate-limit a later episode against a pre-recovery cooldown), and still matching on two tuple parts so a database named 'Foo|Bar' is not swept when 'Foo' recovers. The reviewer's suggested secondary index would make it O(states-for-this-db), but that is a second structure to keep in sync with the first for a collection whose size is servers x databases x a handful of states. The factor this removes is the one that was actually new; the remaining scan is the same order the surrounding code already pays elsewhere. Not worth the extra invariant. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Review caught a case-sensitivity inconsistency I introduced: the recovered set used OrdinalIgnoreCase while `current` and `active` in the same method are both Ordinal, deliberately and with a comment saying why - per-database keys must be case-SENSITIVE to match the stores' case-sensitive expected-state joins, so a database cannot key differently here than it does in the baseline table. On a case-sensitive collation where `Foo` and `foo` both exist, recovering `Foo` would have cleared `foo`'s per-state cooldown stamps too, resetting the only quiet mechanism an integrity state has. That is the same collision class the tuple key had just removed for '|' in a database name, reintroduced one line later by a comparer choice. Checked the two remaining OrdinalIgnoreCase uses in this method and both are correct as they stand: the excluded-databases list is deliberately case-insensitive (a user-facing list, matching the other alerts), and the already-announced check compares STATE tokens rather than database names. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
c2743dc to
eee2605
Compare
|
Rebased onto dev after #2183 merged — the only real conflict was Rebased rather than merged so the nine commits stay linear and each review finding remains its own reviewable step. Re-verified after the rebase rather than assuming it was mechanical: V60 is still the ladder tip ( |
| /* Stamped AFTER delivery so a failed fire is retried next cycle rather than silenced. | ||
| Written for every state, not just the edge-triggered ones, so that flipping a state's | ||
| classification later has correct history to work from. */ | ||
| await _stateStore.SaveDatabaseStateAlertedAsync(key, dbName, db.StateDesc); |
There was a problem hiding this comment.
Muting a database-state alert can permanently silence it, even after the mute is removed.
SaveDatabaseStateAlertedAsync runs whenever this block fires, regardless of isMuted (line 1295) — muting only skips the deliverer's external channels (attemptChannels: !outcome.Muted in DarlingAlertDeliverer.SendAndRecordAsync), it doesn't stop the "announced" memory from being written.
For the four edge-triggered states (OFFLINE/RESTORING/RECOVERING/STANDBY), that memory is what gates all future firing until the state changes (alreadyAnnounced at line 1276). So the sequence:
- Operator mutes the Database State alert for a database (or the metric globally) while it's OFFLINE.
- Engine evaluates, sees the deviation, skips delivery (muted), but still calls
SaveDatabaseStateAlertedAsync(key, dbName, "OFFLINE"). - Operator removes the mute rule, expecting to be notified going forward.
- Database stays OFFLINE.
LastAlertedState == "OFFLINE" == StateDesc→alreadyAnnouncedis true forever, so it never fires again — not even once — until the state actually changes.
This is a real behavior change from the old cooldown-repeat model: previously, unmuting would surface the alert again within one cooldown window regardless of what happened while muted. Now a database parked in one of these states can go permanently unnotified after a temporary mute, which seems like exactly the kind of silent gap this PR is trying to eliminate (just from a different cause). Worth deciding intentionally — e.g. only stamp last_alerted_state when !isMuted, or gate alreadyAnnounced on delivery rather than evaluation — and covering it with a test, since none of the new tests exercise mute interaction.
|
Reviewed the #2166 edge-trigger change (engine, adapter SQL, migration V60, Lite no-op parity). Left one inline finding on Everything else checked out:
Minor/non-blocking: both |
…2166) Review found that muting a database-state alert made the mute permanent. The announced-state memory was stamped whenever the block fired, regardless of isMuted - but muting only skips the deliverer's external channels. Since the four edge-triggered states gate ALL future firing on that memory, the sequence was: mute a parked database, remove the mute, and the alert never returned for as long as the state held. An operator's temporary mute silently became irreversible, which is the same class of permanent silence this PR exists to remove, arrived at from a different direction. This is the one place the memory and the cooldown beside it must disagree, and it is worth being explicit about why. The cooldown is rate limiting: it applies whether or not anyone was told, which is why it is stamped even when muted. This memory means "the operator has been told about this state" - and under a mute they have not. Skipping the stamp costs a repeat inside the mute, which is invisible by definition and is exactly the pre-#2166 cooldown behavior, and it keeps unmuting meaningful. Test drives the whole sequence rather than just the stamp, using the harness's injected clock: muted fire must be marked muted AND leave the memory empty, then with the mute removed and the cooldown elapsed the same still-deviating state must produce a real deliverable alert and stamp the memory. Under the old code the second half is silent. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Real bug, fixed. And it's the same failure mode this PR exists to remove, reached from a direction I hadn't considered: an operator's temporary mute silently became irreversible. You've also identified the one place where this memory and the cooldown next to it must disagree, which is worth stating in the code rather than leaving as a subtlety. The cooldown is rate limiting — it applies whether or not anyone was told, which is exactly why the line above it is stamped even when muted. This memory means "the operator has been told about this state," and under a mute they have not. So the stamp is now skipped when muted while the cooldown stamp stays. I took your first option rather than gating Cost of the fix is a repeat inside the mute, which is invisible by definition and is precisely the pre-#2166 cooldown behaviour for a muted alert. The test drives the whole sequence instead of just asserting the stamp is absent, using the harness's injected clock: muted fire must be marked muted and leave the memory empty; then with the mute removed and the cooldown elapsed, the same still-deviating state must produce a real deliverable alert and stamp the memory. Under the old code that second half is silent, which is the regression. You were right that no existing test touched mute interaction — that's now three findings on this PR whose root cause was a boundary the tests never crossed, so I'll take the hint about where to look first next time. |
| _logger?.LogError("Could not persist failed-job watermark: {Message}", ex.Message); | ||
| } | ||
| } | ||
| /// <summary> |
There was a problem hiding this comment.
Nit: missing blank line between SaveFailedJobWatermarkAsync's closing brace (line 164) and this new doc comment — every other member in this file is separated by one blank line. Same nit applies at the other end: lines 235-236 have two blank lines before NaiveUtcNow() instead of one.
| var serverId = ParseServerKey(serverKey); | ||
| return Task.Run(() => _store.SaveFailedJobWatermarkAsync(serverId, watermark)); | ||
| } | ||
| /// <summary> |
There was a problem hiding this comment.
Nit: missing blank line before this doc comment (line 94 } runs straight into line 95), and there's a double blank line at 110-111 before ParseServerKey. Every other member in this file uses a single blank-line separator.
Review summaryTraced the full engine/store/adapter change for the database-state edge trigger. Overall this is careful, well-reasoned work — calling out what I checked and why it holds up, plus two small nits. Correctness
Lite/Darling parity
Security
Minor nits (posted inline)
No missing-index-DMV suggestions offered per instructions. Nothing here rises to a blocking finding. |
Review nits, both mine: a doc comment running straight into the preceding member's closing brace, and a double blank line before the private helper, in each of PgAlertStateStore and LiteAlertStateStore. Every other member in both files uses a single blank-line separator. Both files are CRLF, and my first attempt anchored on bare LF and matched nothing - the assert caught it before writing rather than after, which is the point of asserting the anchor count. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Both fixed — a doc comment running into the preceding closing brace, and a double blank before the private helper, in each of the two state stores. Real inconsistencies I introduced; every other member in both files uses a single blank-line separator. Small process note since it nearly bit me: both files are CRLF, and my first pass anchored on bare LF and matched nothing. The assertion on anchor count caught that before anything was written rather than after, which is exactly why I anchor-assert scripted edits in this repo instead of trusting a replace to have done something. Verified afterwards that CRLF counts are unchanged (114 and 243) with zero bare LF introduced, and that no triple-newline runs remain in either file. That's every review finding on this PR closed — nine substantive plus these two. Green across all five checks and CLEAN on merge state as of the last run; this one's ready pending whatever CI says about this push. |
| case-sensitive collation where both exist — resetting the only quiet mechanism an integrity | ||
| state has, which is the same collision class the tuple key just removed for '|'. */ | ||
| var recoveredSet = new HashSet<string>(recovered, StringComparer.Ordinal); | ||
| foreach (var stamped in _lastDatabaseStateAlert.Keys) |
There was a problem hiding this comment.
Minor performance note: this sweep now does a full linear scan of _lastDatabaseStateAlert.Keys on every database recovery — and that dictionary holds cooldown entries for every monitored server, not just the one currently being evaluated, since it's a single field shared across the whole engine instance.
The comment above frames the tradeoff as "one full scan" vs. "O(recovered × everything tracked)", but there's a cheaper option that keeps the old O(1)-per-database behavior without reintroducing the string-prefix collision bug: DatabaseStateTokens only defines a small, fixed vocabulary of state tokens (OFFLINE, RESTORING, RECOVERING, STANDBY, SUSPECT, RECOVERY_PENDING, EMERGENCY). For each recovered database you could just TryRemove((key, dbName, state)) directly for each of those ~7 known tokens instead of enumerating the shared dictionary — O(recovered × 7) with no scan of other servers' entries at all.
Probably not a big deal at today's fleet sizes, but worth a look since this runs on every recovery event, for every server, against one shared dictionary.
Review summaryWent through the whole diff (engine, adapter SQL, migration, both apps' stores, and the test suite). This is a carefully done change with unusually thorough test coverage for the edge cases that actually matter here (restart-survives-memory, mute-doesn't-permanently-silence,
Lite/Darling parity: this is Darling-only by design, and it's genuinely inert for Lite rather than a drifted implementation — One minor performance note left as an inline comment on No security concerns — all new SQL is parameterized, no new external input paths. |
|
Considered and declining this one, with a specific reason rather than on cost grounds — and merging, since every substantive finding is closed and all five checks are green. The enumerate-the-known-tokens approach would leak entries. That is precisely the failure class three earlier findings on this PR shared: stale state on one side of a boundary silently suppressing a real alert. Trading a rare scan for a new instance of it isn't a good deal, and guarding it would mean keeping the token list exhaustive forever against a vocabulary Microsoft controls — a coupling I'd rather not create. Worth noting the scan is also narrower than it reads: entries only exist for databases that have actually fired, so the dictionary holds deviating databases, not all servers × all databases. And it runs only on a recovery event, which is by definition rare — recoveries are the transition out of an alerting state, not a per-cycle occurrence. If entry volume ever becomes real, the right fix is the secondary index from your earlier comment — Ten findings on this PR, all ten legitimate. Six were genuine bugs (the SUSPECT baseline, the permanent memory, the restart-gap clear, the cooldown-borrowed-across-states silence, the delimiter collision, the permanent mute), and the diagnosis of why the tests missed them was consistently more valuable than the report itself. Thank you. |
…alf) The memory an edge trigger needs is persisted per database, Lite's config_database_state_expected had no such columns, and LiteAlertStateStore's two methods were documented no-ops. So alreadyAnnounced was always false in Lite and a database parked OFFLINE for a month still alerted every cooldown forever - the complaint #2166 was filed about, still live in the SKU most likely to hit it. Four pieces: - Schema v53: last_alerted_state + last_alerted_at, nullable, plus an ADD COLUMN IF NOT EXISTS migration so existing stores get them. NULL means never announced, which is what a first observation, a fresh store and a recovered database all look like - all three must be free to alert. - The two store methods write for real. Save is an UPDATE and never an upsert, the constraint #2166 established: an INSERT would have to invent expected_state (NOT NULL) from the state being alerted ON, so a database first observed SUSPECT would get SUSPECT as its accepted baseline, stop deviating, read as recovered while still corrupt, and never alert again. The seed refuses to baseline the integrity states for exactly that reason and this must not do it behind the seed's back. - The deviation read returns the value, so the shared engine can use it here. - A STORE-DERIVED recovered-clear running before the seed, which is the part worth reviewing. The engine also clears on the falling edge it witnesses, but that path is reachable only through an in-memory active set that empties on restart - so a restart landing between an alert and the recovery left the memory sticky forever and silently swallowed the next parking. Darling needed the same fix during #2182's review; porting the no-op without porting this would have shipped the bug I already fixed once. One sample at expected is enough where deviation needs two: clearing can only cause an extra alert, never a missed one, and a flap cannot exploit it because a flap never survives the two-sample test to alert at all. Five tests against a real in-process DuckDB, no live gate: the value round-trips to the deviation read; a recovered database has the memory cleared BY THE STORE with nothing held in memory (the restart-gap invariant); (ignore) clears it too, since a silenced database should not keep a memory outliving the silence; and the engine's immediate clear forgets the announcement while leaving the baseline intact - clearing the expected state instead would re-baseline and silence a real deviation. Deliberately unchanged: the integrity states keep repeating on the cooldown. One self-inflicted note: the schema comment initially used double quotes inside a C# verbatim string and broke the literal. Caught by the compiler, and I then scanned every file I touched for the same mistake. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
What changes
The Database State alert re-fired for as long as a database stayed deviated. @gotqn's case: a soft-delete workflow parks a database OFFLINE for a month, which produced hundreds of identical alerts for one intended action.
Now OFFLINE / RESTORING / RECOVERING / STANDBY are edge-triggered — alert on the transition, stay quiet until the state changes. SUSPECT / RECOVERY_PENDING / EMERGENCY are untouched and keep re-firing on the cooldown.
The split is the whole idea: those first states are usually ones somebody chose, and repetition tells the operator nothing new. Nobody parks a database in SUSPECT, so there the repetition is the signal — there's a test pinning that an already-announced integrity state must keep firing, because the failure mode worth guarding is a real corruption quietly stopping its nagging.
Why the memory is persisted, not in-memory
This is the part that decided the design. The case is a database parked for weeks. In-memory edge state would go quiet, then re-announce every parked database on the next service restart — worse than the cooldown-repeat it replaces, and it would look like a regression to exactly the person who asked for this.
So V60 adds
last_alerted_state/last_alerted_attoconfig.database_state_expected— the table that already holds this alert's per-database config, already keyed(server_id, database_name).Rejected alternative:
config_edge_trigger_watermarksalready does restart-surviving edge memory, but it's keyed(server_id, metric_name)andmetric_namefeeds alert history and mute-rule matching. Per-database use would mean smuggling a compound key into a label column — fine today, a trap later.The composition property @gotqn identified
Pinned by test: a database announced OFFLINE that turns SUSPECT fires again, at Critical. Going quiet for a parked state must not mean going blind, and the new state doesn't inherit the old one's quiet treatment.
Scope
Darling only, matching where the issue was filed. The engine change is inert for Lite by construction: with no persisted memory the adapter reports empty,
current != emptyis always true, and Lite behaves exactly as before. Lite'sSaveDatabaseStateAlertedAsyncis a documented no-op rather than a fake in-memory cache — a cache would go quiet and then re-fire on restart, which is the worst of both. Lite parity follows separately, same as #2167 → #2176.Behavior-change warning
This alters a default. Anyone relying on the repeat-nag to track deliberate offlines will see it go quiet after the first alert — called out in the changelog in those terms.
Testing
Four engine pins: an already-announced chosen state stays silent with no cooldown involved; a first observation fires and records the state (recording is what makes the silence survive a restart); an already-announced SUSPECT still fires at Critical; and OFFLINE→SUSPECT fires again at Critical. Plus the full V60 ladder — rung identity, probe sentinel, gate arm mapping a fully-migrated store to 60 and a V59 store to 59, and the suite's ladder-tip pins moved. Alerting, Darling service, Viewer, Lite, and both test projects build clean.
Addresses #2166 (Darling half).