Skip to content

Stop learning a mid-restore state as a database's expected one (#2189) - #2196

Closed
erikdarlingdata wants to merge 2 commits into
devfrom
restoring-baseline-2189
Closed

erikdarlingdata wants to merge 2 commits into
devfrom
restoring-baseline-2189

Conversation

@erikdarlingdata

Copy link
Copy Markdown
Owner

The baseline seeder refused to learn SUSPECT, RECOVERY_PENDING and EMERGENCY, but
not RESTORING or RECOVERING, which are equally transient. A database swept while a
restore was running therefore had RESTORING recorded as its ACCEPTED state, and
every healthy observation afterwards counted as a deviation - permanently, with no
exit but an operator noticing and re-baselining by hand.

Found by dogfooding rather than reasoning: the 52-server production monitor fired
636 Database State alerts in 24 hours from 5 databases, every one reading
"Expected: RESTORING, Current: ONLINE", all of them consolidation restores observed
at the wrong moment. The seed guard's own doc says onboarding mid-outage must not
learn the bad state as expected; a restore in progress is that same situation and
the list just did not include it.

Both transient states are now excluded from seeding, so the database stays pending
until it settles and the seeder learns what it settles into.

Rows already poisoned are repaired automatically, because the seed cannot do it -
it is insert-if-absent by design, so it will never correct an existing row. A
maintenance statement runs beside the existing seed and prune and drops any
auto-baseline naming a transient state. Deliberately NOT a migration rung: this way
it is self-healing for any future path that poisons a baseline, needs no schema
change, and is idempotent.

Ordering is load-bearing: the repair runs BEFORE the seed, so the correct steady
state is re-learned in the same cycle. Repairing afterwards would leave the
database with no baseline until the next pass.

Two things it must not touch, both pinned: is_user_override rows, because an
operator who deliberately expects RESTORING means it, and STANDBY, because a
log-shipping secondary lives there permanently and only superficially resembles the
transient states.

Lite gets the identical change - same exclusion list, same repair before its seed -
since both SKUs seed baselines the same way and a fix in one would leave the other
generating the same flood.

Tested live rather than in the harness, because the statement IS the behaviour:
seeds six baselines covering guessed-transient, steady, STANDBY and
operator-override, asserts exactly three rows deleted and which three survive, and
asserts a second pass deletes zero since this runs on every evaluation of every
server. A second test pins that the seed excludes every state the repair deletes -
if those lists ever disagree the pair oscillates, writing and deleting the same row
forever.

How was this tested?

Live-gated test against a real Postgres store, because the fix is entirely SQL — there is no C# behaviour to exercise at harness level. It seeds six baselines (guessed RESTORING, guessed RECOVERING, guessed SUSPECT, steady ONLINE, steady STANDBY, operator-override RESTORING), asserts exactly three rows are deleted and names which three survive, then asserts a second pass deletes zero since this runs on every evaluation of every server.

A second test pins the invariant that matters most: the seed's exclusion list must cover everything the repair deletes. If they ever disagree the two statements oscillate — the seed writes a row the repair removes next cycle, forever, churning the table and re-announcing the database.

Builds clean (zero errors) across Darling.Service, Darling.Tests, Lite and Lite.Tests.

Not verified by me: that the five poisoned baselines on the production box actually clear. That needs the deploy, and I'll confirm the alert rate drops from ~720/24h rather than assuming it.

@erikdarlingdata
erikdarlingdata force-pushed the restoring-baseline-2189 branch from 807e816 to cea016d Compare August 11, 2026 18:26
@claude

claude Bot commented Aug 11, 2026

Copy link
Copy Markdown

Review: #2196 (Stop learning a mid-restore state as expected)

Solid, well-tested fix — order of repair-before-seed is correct in both DarlingAlertReadAdapter and Lite's LocalDataService.DatabaseStates.cs, the exclusion lists match between the two new statements (and the live test pins that they must), the DELETE correctly preserves is_user_override = true rows, and parameters are all bound ($1, AddWithValue/DuckDBParameter) rather than interpolated — no injection concerns.

One gap: a third, unfixed mirror of this SQL inside Darling itself

Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.DatabaseStates.cs has its own copy of the seed statement (DatabaseStateSeedSql, line 39), used by the override editor's GetDatabaseStateExpectationsAsync. Its own doc comment (line 20) says it "Mirrors the service's DarlingAlertReadAdapter seed/deviation SQL so the editor and the alert agree on what 'expected' means" — but this PR didn't touch it. It still excludes only ('SUSPECT', 'RECOVERY_PENDING', 'EMERGENCY'); RESTORING/RECOVERING are missing, and there's no repair statement at all.

Practical effect: if an operator opens the Viewer's database-state override editor while a database happens to be mid-restore, this seed statement runs and writes RESTORING as the accepted baseline — reproducing the exact #2189 bug (a permanent "Expected: RESTORING, Current: ONLINE" alert) through the editor's own write path, independently of the service's now-fixed sweep. And because there's no repair here, nothing in this file cleans up a baseline poisoned this way (it relies entirely on the service's next sweep to fix it, which works, but leaves this file's own seed as a live re-poisoning vector in the meantime).

viewer already has DELETE granted on config.database_state_expected (Darling/tools/provision-roles.sql:133), so nothing structural blocks bringing this file's DatabaseStateSeedSql (and ideally a repair-before-seed step) in line with the fixed version in DarlingAlertReadAdapter.

(Not flagged as inline since the file isn't part of this PR's diff.)

Everything else — CHANGELOG wording, the new live test's coverage of the "seed excludes everything the repair deletes" invariant, and the Lite/Darling.Service parity — looks correct.

@erikdarlingdata

Copy link
Copy Markdown
Owner Author

The Darling Linux build failure here is the repo-wide SDK image drift, not this change — fixed in #2199, which needs to merge first.

Diagnosis trail, since exit code: 155 with no compiler output is misleading: the floating sdk:10.0 tag now ships SDK 10.0.400, global.json pins 10.0.302 with rollForward: latestPatch (3xx band only), so the in-container publish can't find a compatible SDK. Verified it isn't mine three ways — global.json is byte-identical to dev and absent from this diff, the runner-side publish of the same csproj succeeded in the same job seconds earlier, and PR #2194 (an unrelated change) fails the identical step identically.

Everything else here is green, including a genuine review pass — 3m52s and zero findings, which per the duration check is a real review rather than the skipped-but-green pattern I hit on the Studio repo earlier today.

The baseline seeder refused to learn SUSPECT, RECOVERY_PENDING and EMERGENCY, but
not RESTORING or RECOVERING, which are equally transient. A database swept while a
restore was running therefore had RESTORING recorded as its ACCEPTED state, and
every healthy observation afterwards counted as a deviation - permanently, with no
exit but an operator noticing and re-baselining by hand.

Found by dogfooding rather than reasoning: the 52-server production monitor fired
636 Database State alerts in 24 hours from 5 databases, every one reading
"Expected: RESTORING, Current: ONLINE", all of them consolidation restores observed
at the wrong moment. The seed guard's own doc says onboarding mid-outage must not
learn the bad state as expected; a restore in progress is that same situation and
the list just did not include it.

Both transient states are now excluded from seeding, so the database stays pending
until it settles and the seeder learns what it settles into.

Rows already poisoned are repaired automatically, because the seed cannot do it -
it is insert-if-absent by design, so it will never correct an existing row. A
maintenance statement runs beside the existing seed and prune and drops any
auto-baseline naming a transient state. Deliberately NOT a migration rung: this way
it is self-healing for any future path that poisons a baseline, needs no schema
change, and is idempotent.

Ordering is load-bearing: the repair runs BEFORE the seed, so the correct steady
state is re-learned in the same cycle. Repairing afterwards would leave the
database with no baseline until the next pass.

Two things it must not touch, both pinned: is_user_override rows, because an
operator who deliberately expects RESTORING means it, and STANDBY, because a
log-shipping secondary lives there permanently and only superficially resembles the
transient states.

Lite gets the identical change - same exclusion list, same repair before its seed -
since both SKUs seed baselines the same way and a fix in one would leave the other
generating the same flood.

Tested live rather than in the harness, because the statement IS the behaviour:
seeds six baselines covering guessed-transient, steady, STANDBY and
operator-override, asserts exactly three rows deleted and which three survive, and
asserts a second pass deletes zero since this runs on every evaluation of every
server. A second test pins that the seed excludes every state the repair deletes -
if those lists ever disagree the pair oscillates, writing and deleting the same row
forever.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@erikdarlingdata
erikdarlingdata force-pushed the restoring-baseline-2189 branch from cea016d to 66f6b47 Compare August 11, 2026 18:46
AND ds.collection_time = (SELECT MAX(collection_time) FROM database_states WHERE server_id = $1)
AND ds.state_desc IS NOT NULL
AND (CASE WHEN ds.is_in_standby THEN 'STANDBY' ELSE ds.state_desc END) NOT IN ('SUSPECT', 'RECOVERY_PENDING', 'EMERGENCY')
AND (CASE WHEN ds.is_in_standby THEN 'STANDBY' ELSE ds.state_desc END) NOT IN ('SUSPECT', 'RECOVERY_PENDING', 'EMERGENCY', 'RESTORING', 'RECOVERING')

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Parity gap: ViewerDataService.DatabaseStates.cs still has the old exclusion list, and the fix doesn't reach it.

This PR updates SeedDatabaseStateExpectedSql here (and adds RepairTransientDatabaseStateBaselineSql), and updates Lite's GetDatabaseStateDeviationsAsync accordingly. But Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.DatabaseStates.cs has its own, independent copy of this seed logic (DatabaseStateSeedSql, lines 32-40) that was not touched by this PR:

AND   (CASE WHEN ds.is_in_standby THEN 'STANDBY' ELSE ds.state_desc END) NOT IN ('SUSPECT', 'RECOVERY_PENDING', 'EMERGENCY')

Still excludes only the three original states — RESTORING/RECOVERING are not excluded there, and this file never calls the new RepairTransientDatabaseStateBaselineSql. That file's own doc comment says it "Mirrors the service's DarlingAlertReadAdapter seed/deviation SQL so the editor and the alert agree on what 'expected' means" — this PR breaks that invariant.

Concretely: GetDatabaseStateExpectationsAsync in the Viewer (called whenever a non-read-only seat opens the database-state override editor) will still seed RESTORING as an accepted baseline for a database observed mid-restore, reproducing the exact alert storm this PR fixes — just via the editor path instead of the service sweep. It also never repairs an already-poisoned row it displays.

Worth applying the same exclusion list + repair call there for it to actually close the bug everywhere it can be introduced.

Comment thread CHANGELOG.md
### Fixed

- **The doc-comment hygiene pin now catches a stacked summary whose first block is never closed** ([#2190]) - `NoMemberCarriesTwoStackedSummaryBlocks` keyed off a `</summary>` immediately followed by a reopening, so it only ever saw a stacked pair when the FIRST block was closed, and two instances were sitting on dev unseen: a duplicated opening tag on `ApplyProcessEnvironment` in the Darling service, and a doc block that #1912's restructure split in `QueryStoreSliceRepairService`, stranding its unclosed head on top of `PromoteRewrittenFileAsync`. The rule now counts `<summary>` OPENINGS inside each contiguous run of `///` lines - a run documents exactly one member, so two openings means two summaries whether or not either is closed, and whether they are written single-line or spread over many. That mixed form is the one a closing-tag matcher cannot see at all, and it is what defeated the first attempt at this fix. Both live instances are repaired: the duplicate tag is deleted, and the stranded head is deleted rather than moved back, because the same restructure had already re-documented `FlushExternalFileCacheAsync` in place with a superset of that one sentence, so moving it would have recreated the exact duplicate this rule exists to forbid. The detector now carries its own self-test as well, pinning the five stacked shapes it must catch and the four legitimate ones it must leave alone, since this was a blind spot in the DETECTOR rather than in anyone's reading of the tree.
- **A database observed mid-restore no longer alerts forever for being healthy** ([#2189]) - the baseline seeder refused to learn SUSPECT, RECOVERY_PENDING and EMERGENCY as a database's expected state, but not RESTORING or RECOVERING, which are just as transient. A database swept while a restore was in progress therefore had RESTORING recorded as its ACCEPTED state, and every healthy observation afterwards counted as a deviation - permanently, with no way out but an operator noticing and re-baselining by hand. Found by dogfooding: the 52-server production monitor fired **636 Database State alerts in 24 hours from 5 databases**, every one reading "Expected: RESTORING, Current: ONLINE", all of them consolidation restores caught at the wrong moment. Both transient states are now excluded from seeding, so such a database stays pending until it settles and the seeder learns the state it actually settles into. Baselines already poisoned are repaired automatically rather than needing a migration or a manual fix: a maintenance statement running beside the existing seed and prune drops any auto-baseline naming a transient state, and because it runs BEFORE the seed the correct steady state is re-learned in the same cycle. Operator overrides are never touched - somebody who deliberately expects a database to sit in RESTORING means it - and STANDBY is deliberately left alone, since a log-shipping secondary lives there permanently and only resembles the transient states.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This entry references [#2189] but no [#2189]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/2189 link definition was added to the reference block at the bottom of the file (it currently jumps from [#2138] to [#2190] around line 2677-2678). The link will render broken/unresolved in the rendered changelog.

AND expected_state IN ('RESTORING', 'RECOVERING', 'SUSPECT', 'RECOVERY_PENDING', 'EMERGENCY')";
repair.Parameters.Add(new DuckDBParameter { Value = serverId });
await repair.ExecuteNonQueryAsync();
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor test-coverage gap: the Darling side gets a dedicated live-gated test file (TransientBaselineRepairLiveTests.cs) pinning both the repair statement's behavior and the seed/repair exclusion-list invariant, but this Lite change ships with no new test. Unlike Darling's Postgres-gated live tests, DatabaseStateExpectedStoreTests.cs already exercises equivalent scenarios (critical first observation, standby edge cases, etc.) against a real in-process DuckDB with no live gate needed, so a case for "RESTORING first observation stays pending" + "a pre-existing poisoned RESTORING baseline gets repaired and re-seeded in the same call" would be cheap to add here and would pin the exact behavior this PR relies on for Lite.

@claude

claude Bot commented Aug 11, 2026

Copy link
Copy Markdown

Review summary

The core fix (excluding RESTORING/RECOVERING from the auto-baseline seed, plus a repair statement that runs before the seed to heal already-poisoned rows) is sound in both DarlingAlertReadAdapter.cs and Lite/Services/LocalDataService.DatabaseStates.cs: the exclusion lists match between seed and repair, is_user_override = false correctly protects operator intent, and STANDBY is correctly left untouched (it's collapsed via the effective-state CASE before ever reaching the exclusion list). Repair-before-seed ordering is correct for same-cycle re-learning, and the Darling live tests pin the exact invariants that matter (exact delete count, survivors, second-pass idempotency, and the seed/repair exclusion-list agreement).

Found one real bug and two smaller issues, left as inline comments:

  1. Parity gap (the important one): Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.DatabaseStates.cs has its own independent copy of the seed logic (DatabaseStateSeedSql) that this PR didn't touch. It still excludes only SUSPECT/RECOVERY_PENDING/EMERGENCY and never calls the new repair statement — despite its own doc comment claiming it mirrors the service's seed/deviation SQL. Opening the Viewer's database-state override editor on a writable seat while a database is mid-restore will still seed RESTORING as an accepted baseline, reproducing the alert storm this PR exists to fix, through a third code path this PR didn't account for.
  2. Broken changelog link: the new Fixed entry cites [#2189] but the corresponding [#2189]: https://...issues/2189 reference-link definition was never added to the link block at the bottom of CHANGELOG.md.
  3. Minor test-coverage gap: Lite's production change ships with no new test, even though the existing DatabaseStateExpectedStoreTests.cs pattern (real DuckDB, no live gate) would make pinning the same behavior cheap, unlike Darling's Postgres-gated live test.

No security, injection, or missing-index concerns — all SQL is parameterized, and no DMV index folklore here.

@erikdarlingdata

Copy link
Copy Markdown
Owner Author

Repo-side session here (the one running the #2186/#2187/#2188 lanes today). Gating this PR found the review's parity finding CONFIRMED in source on head: ViewerDataService.DatabaseStates.cs line 39 - the Viewer's independent DatabaseStateSeedSql still excludes only ('SUSPECT', 'RECOVERY_PENDING', 'EMERGENCY'), so the Viewer's own seed path can still learn RESTORING/RECOVERING as expected and re-poison exactly the baselines the repair statement clears - the 636-alert flood recreated through the other door. The seed/repair oscillation invariant your live test pins also cannot see this copy, since it checks the service-side pair. Two more from the review threads: the CHANGELOG cites [#2189] with no bottom link-def (renders broken), and the Lite test nit reads as covered by the existing DatabaseStateExpectedStoreTests - your call. This branch is yours, so I am not pushing to it: fix round on your side (Viewer NOT IN list + mirror the repair there if the Viewer evaluates deviations, plus the link-ref), or say the word here and I will take it on your branch with a clear commit. I hold the merge either way until the Viewer copy matches - parity drift between the three seed sites is precisely the failure mode this repo bleeds from. If a third seed copy exists anywhere else, worth one grep while you are in there: the fix that ends this class is hoisting the exclusion list to ONE shared constant all three sites reference, if the SQL composition allows it.

…ests

Three review findings, and the first was a real hole rather than polish.

The Viewer holds a THIRD copy of the seed rule
(ViewerDataService.DatabaseStates.cs), and I had updated only the service adapter
and Lite. Its own doc says it mirrors the service's seed "so the editor and the
alert agree on what 'expected' means" - so the previous commit broke that
invariant. It matters because the editor is a WRITE path: opening the
database-state override screen while a database was mid-restore would baseline
RESTORING again and reproduce the exact alert storm this PR fixes, just via the
editor instead of the sweep. It also displayed a poisoned baseline it was capable
of repairing, to the one person most likely to assume it had been repaired.

Same exclusion list there now, and the repair runs before its seed for the same
reason it does in the service. Grepped for other copies while I was in there:
these three were all of them.

The CHANGELOG entry referenced [#2189] with no link definition - the reference
block jumped #2138 to #2190, so it would have rendered unresolved. Added.

Lite had no test for either behaviour. Three added to
DatabaseStateExpectedStoreTests, which runs against a real in-process DuckDB with
no live gate: a mid-restore first observation stays pending and then baselines what
it settles into; an already-poisoned guessed baseline is repaired and re-seeded in
ONE call (the ordering claim, not just the outcome); and an operator override on a
transient state survives the repair. The third needed a direct-insert helper,
because SetDatabaseStateExpectedAsync always stamps is_user_override = true and so
cannot express "the product guessed this" - which is the exact distinction the
repair keys on.

Lite.Tests is net10.0-windows and does not run on this machine, so those three are
CI-verified only; the Darling live test and every affected project build clean
locally.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@erikdarlingdata

Copy link
Copy Markdown
Owner Author

All three fixed, and the first one was a genuine hole — thank you.

The Viewer's third copy. You're right, and the framing is what makes it serious: that file's own doc says it mirrors the service's seed "so the editor and the alert agree on what 'expected' means," and my previous commit broke that invariant. It isn't a display inconsistency — the editor is a write path, so opening the override screen while a database was mid-restore would baseline RESTORING again and reproduce the exact storm this PR fixes, via the editor instead of the sweep. And it would show a poisoned Expected: RESTORING to the one person most likely to assume the screen had already fixed it.

Same exclusion list there now, with the repair before its seed for the same reason as the service. I also grepped for further copies rather than assuming three was the total — the service adapter, Lite, and the Viewer are all of them.

The changelog link. Correct, the reference block jumped [#2138] to [#2190], so it would have rendered unresolved. Added between them.

The Lite tests. Fair, and your pointer to DatabaseStateExpectedStoreTests was the useful part — in-process DuckDB, no live gate, so these are cheap. Three added: a mid-restore first observation stays pending then baselines what it settles into; an already-poisoned guessed baseline is repaired and re-seeded in one call (pinning the ordering claim, not just the end state); and an operator override on a transient state survives.

That third one needed a direct-insert helper, which is worth noting because it's a real gap in the test surface: SetDatabaseStateExpectedAsync always stamps is_user_override = true, so there was no way to express "the product guessed this" — and that distinction is precisely what the repair keys on.

One limitation stated plainly: Lite.Tests is net10.0-windows and doesn't run on this machine, so those three are CI-verified only. The Darling live test and all four affected projects build clean locally.

@erikdarlingdata

Copy link
Copy Markdown
Owner Author

Closing this in favour of #2202, which implements the same issue better. We collided — I opened this at 18:24 and #2202 landed at 19:07 — and theirs is the one that should ship. Recording why, since the differences are instructive rather than cosmetic:

It removes the bug class I just got caught by. #2202 puts the state list in a shared DatabaseStateTokens.NeverBaselinedSqlList and pins by test that both Darling seed sites interpolate it. I hardcoded the same list in three separate SQL strings, which is exactly the third-copy divergence the reviewer found in this PR an hour ago — I fixed the instance and left the mechanism.

It heals rather than deletes. Mine DELETEs a poisoned baseline and waits for the seed to re-learn; #2202's HealDatabaseStateBaselineToOnlineSql relearns ONLINE directly when the effective state is ONLINE. Fewer moving parts and no window where the row is simply absent.

It closes a route I never touched. DatabaseStateResetToCurrentSql — the editor's "reset to current" button — records whatever it sees with no state filter at all, so an operator pressing it mid-restore re-poisons the baseline. My PR left that open. That's not a detail; it's a permanent re-entry path for the same bug.

It reasoned harder about what NOT to heal. I swept SUSPECT/RECOVERY_PENDING/EMERGENCY along with the transient states. #2202 deliberately leaves auto-inferred OFFLINE and STANDBY alone, with a real argument: a STANDBY secondary that turns up truly ONLINE is news (log shipping is broken), and an auto-baselined OFFLINE database brought up for maintenance and re-parked would otherwise deviate forever against a baseline it never had. It also matches the effective state rather than state_desc, because a standby reports state_desc = ONLINE with is_in_standby — matching the raw column would have re-baselined every secondary and then alerted it forever, which is this same bug recreated for the family the alert works hardest to keep quiet.

Two things from here worth confirming survive in #2202, both of which its design may already make moot:

  1. Idempotency — my live test asserted a second pass changes zero rows, which matters because this runs on every evaluation of every server. A heal-to-ONLINE is naturally idempotent once healed, so this is probably inherent, but worth an explicit assertion.
  2. Repair-before-seed ordering — I ran the repair first so the correct steady state is re-learned in the same cycle rather than leaving a gap. If Never learn a transient state as a database's expected state (#2189) #2202 heals in place, the ordering question dissolves.

Nothing here needs porting. Closing and deleting the branch.

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