Skip to content

Never learn a transient state as a database's expected state (#2189) - #2202

Merged
erikdarlingdata merged 2 commits into
devfrom
fix/2189-restoring-baseline
Aug 11, 2026
Merged

erikdarlingdata merged 2 commits into
devfrom
fix/2189-restoring-baseline

Conversation

@erikdarlingdata

@erikdarlingdata erikdarlingdata commented Aug 11, 2026 •

Copy link
Copy Markdown
Owner

Fixes #2189.

The bug

The database-state alert baselines each database's first observed state as its accepted normal. A database swept into monitoring mid-restore learned RESTORING as expected, and then deviated forever by being healthy.

On the 52-server production box that was 636 alerts in 24 hours from 5 databases, every one reading Expected: RESTORING, Current: ONLINE, with no escape but an operator re-baselining by hand.

This builds on #2182 (issue #2166), merged one day ago. That fix is untouched: RepeatsAreNoise keys on the current state, which is ONLINE here, so its edge trigger never engaged for this shape. The alert engine is not modified at all -- the whole fix is in the two stores' SQL plus one shared constant.

The fix

Half one, the seed. Its refusal list widens from the integrity states to include the transient ones (RESTORING, RECOVERING), via a shared DatabaseStateTokens.NeverBaselinedSqlList. A database mid-operation stays pending and silent until it settles into a state worth learning; when the restore completes, ONLINE is what gets learned.

Half two, the heal. The seed only governs rows that do not exist yet, so the same rule is applied after the fact: an AUTO-seeded baseline recording a state the seed would refuse is not a baseline anyone chose, and once that database's effective state reaches ONLINE the steady state is learned instead. Already-poisoned rows repair themselves on the next sweep -- no migration, no manual step -- and the route that stays open forever is covered too, since "reset to current" pressed during a restore records whatever it sees with no state filter at all.

What the heal deliberately does NOT touch

Being wrong on this side means silence, so the exclusions are the load-bearing part. A first pass used the broader rule "ONLINE overwrites anything the machine inferred"; review found two regressions it caused, both now pinned watched-red:

  • A user override. [FEATURE] Database State alert: per-state notify mode - fire OFFLINE/RESTORING once instead of re-firing every cooldown #2166's composition contract depends on it: a database parked at expected OFFLINE stays quiet while parked and still alerts the moment it comes back ONLINE.
  • An OFFLINE or STANDBY baseline, even though both are inferred. They are steady states, and leaving one is real news. A STANDBY secondary that turns up truly ONLINE has stopped being a secondary -- somebody recovered it, log shipping is broken -- and healing it would swap that alert for silence and then fire when the operator fixed it. An auto-baselined OFFLINE database brought up for an hour of maintenance and re-parked would come back deviating forever against a baseline it never had (in Lite, with no persisted edge memory, that is an alert every cooldown for good -- this bug inverted).
  • The raw state_desc. A standby secondary reports state_desc = ONLINE with is_in_standby set, so matching that column would re-baseline every log-shipping secondary and then alert it forever for being STANDBY.

The #1986 property survives: a NORECOVERY secondary, permanently RESTORING, stays silent forever. It now gets there by never being baselined rather than by baselining RESTORING, and an operator who wants deviation coverage on one sets its expected state explicitly (which is an override, and therefore honoured).

Test plan

Full transition matrix, each watched-red before being made green:

  • Onboard during a restore, then ONLINE -- silent throughout, learns ONLINE, never fires
  • Already-poisoned RESTORING baseline (planted directly, as no code path can write one now) heals to ONLINE
  • "Reset to current" pressed mid-restore, and during a SUSPECT outage, both self-correct on recovery
  • NORECOVERY log-shipping secondary, permanently RESTORING -- silent forever, plus the explicit opt-in escape hatch
  • Operator-overridden expected state wins, and still alerts on return to ONLINE
  • ONLINE to OFFLINE still fires
  • STANDBY secondary recovered out of standby still alerts (regression guard)
  • Auto-baselined OFFLINE is never healed, so re-parking stays quiet (regression guard)
  • STANDBY secondary is never healed to ONLINE -- caught by mutation, and by the pre-existing standby test
  • Chosen database states alert once, not forever — edge-trigger OFFLINE/RESTORING (#2166) #2182 edge semantics preserved: all 16 AlertEngineTests.DatabaseState* unchanged and green

Suites: Lite 2184/2184, Darling 4456 passed / 0 failed (10 pre-existing env-gated skips). Both apps build with 0 new warnings.

The seed and the heal are pinned by gated-live tests against a real PostgreSQL 18.4 + TimescaleDB cluster (the darling-pg rig), not just harness stubs -- these statements decide which rows exist, which no amount of engine stubbing observes. A source-text guard pins that both Darling copies of the SQL (service and viewer, which cannot reference each other) share the one state list, for the seed and the heal.

No migration; highest remains V60.

🤖 Generated with Claude Code


Filed while working this, so it is tracked rather than living only in a changelog caveat: #2203 - Lite still has no persisted alerted-state memory, so every edge-triggered database state repeats forever there. That is #2166's deferred parity, but it gained teeth here: because this PR deliberately does NOT heal an auto-baselined OFFLINE row, the "park, bring up for maintenance, re-park" path alerts once in Darling and every cooldown forever in Lite.

erikdarlingdata and others added 2 commits August 11, 2026 15:06
The database-state alert baselines each database's first observed state as
its accepted normal. A database swept into monitoring mid-restore therefore
learned RESTORING as expected and deviated forever by being ONLINE: 636
alerts in 24 hours from 5 databases on the 52-server production fleet, with
no escape but an operator re-baselining by hand.

Two halves, both stores.

The seed's refusal list widens from the integrity states to include the
transient ones (RESTORING, RECOVERING) via a shared DatabaseStateTokens
constant, so a database mid-operation stays pending and silent until it
settles into a state worth learning. That governs rows that do not exist
yet, so the second half applies the same rule after the fact: an AUTO-seeded
baseline recording a state the seed would refuse is not a baseline anyone
chose, and once the database's effective state reaches ONLINE the steady
state is learned instead. Already-poisoned rows repair themselves on the
next sweep with no migration, and "reset to current" pressed mid-restore --
which records whatever it sees with no filter, and always will -- now
un-writes itself.

What the heal does NOT touch is as load-bearing as what it does, since
being wrong here means silence:

- A user override. #2166's composition contract depends on it: a database
  parked at expected OFFLINE stays quiet while parked and still alerts when
  it comes back ONLINE.
- An OFFLINE or STANDBY baseline, even though both are inferred. They are
  steady states, and leaving one is real news -- a STANDBY secondary that
  turns up truly ONLINE has stopped being a secondary, so log shipping is
  broken, and healing it would swap that alert for silence and then fire
  when the operator FIXED it. An auto-OFFLINE database brought up for an
  hour and re-parked would come back deviating forever.
- The raw state_desc. A standby secondary reports state_desc = ONLINE with
  is_in_standby set, so matching that column would re-baseline every
  log-shipping secondary and alert it forever for being STANDBY.

A NORECOVERY secondary, permanently RESTORING, stays silent as before; it
now gets there by never being baselined rather than by baselining RESTORING.

Tested watched-red across the transition matrix: onboard-during-restore then
ONLINE, permanent RESTORING secondary, operator override wins, ONLINE to
OFFLINE still fires, standby not healed, re-park stays quiet. The seed and
heal are pinned live against real Postgres, and the two Darling copies of
the SQL are pinned to share the one state list.

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

Copy link
Copy Markdown
Owner Author

I had a competing implementation of this in #2196 and have closed it — yours is better and I've said why over there. Two specifics worth calling out, in case they're useful to whoever reviews this:

  • The shared NeverBaselinedSqlList plus the test that both Darling seed sites interpolate it is the right shape. My version hardcoded the list in three SQL strings and a reviewer caught me having missed the Viewer copy; this makes that class of miss structurally impossible rather than caught-by-luck.
  • DatabaseStateResetToCurrentSql is the find. I hadn't touched it, and it records whatever it sees with no state filter — so "reset to current" pressed mid-restore re-poisons the baseline indefinitely. Covering it is the difference between fixing the instance and closing the door.

Two carry-overs from my live test that may already be implied by heal-in-place, offered rather than requested: an explicit assertion that a second pass changes zero rows (this runs on every evaluation of every server), and — if any DELETE path remains — that the heal is ordered so the steady state is re-learned in the same cycle rather than leaving the row absent for one.

One factual note for the changelog line: the 636 figure was measured at 15:13 UTC; the box has since drifted to 721/24h, so if you want the number to age well it's worth phrasing as "636 in 24 hours when measured" rather than a standing rate.

@claude

claude Bot commented Aug 11, 2026

Copy link
Copy Markdown

Reviewed the diff. This is a tightly-scoped, well-tested fix — no correctness bugs found, and Lite/Darling parity looks solid.

What I checked:

  • Lite/Darling parity: All three write sites (Darling service DarlingAlertReadAdapter, Darling viewer ViewerDataService.DatabaseStates, Lite LocalDataService.DatabaseStates) got matching seed-exclusion and heal logic, all driven off the one shared DatabaseStateTokens.NeverBaselinedSqlList/CriticalSqlList constants instead of hand-duplicated literals. The new DatabaseState_BothDarlingSeedSites_ShareTheOneStateList source-text guard in AlertEngineTests.cs is a good belt-and-suspenders check against the two Darling copies drifting (since the viewer project can't reference the service project).
  • Correctness of the heal predicate: is_user_override = false + expected_state IN (NeverBaselinedSqlList) + effective-current-state = ONLINE is exactly right — it only touches inferred baselines that the seed itself would have refused, never a user override, and never a legitimate steady-state baseline (OFFLINE/STANDBY). The regression tests (AutoBaselinedOffline_IsNeverHealed..., StandbySecondaryRecoveredOutOfStandby_StillAlerts..., OperatorParkedOffline_StillAlertsWhenTheDatabaseComesBackOnline...) cover exactly the failure modes called out in the PR description (the "ONLINE overwrites anything inferred" broader rule that was rejected).
  • Effective- vs raw-state matching: heal correctly matches on the same CASE WHEN is_in_standby THEN 'STANDBY' ELSE state_desc END expression as the seed/deviation read, avoiding the trap where a standby secondary's raw state_desc = 'ONLINE' would get it wrongly healed off STANDBY.
  • SQL injection: none of the interpolated {DatabaseStateTokens.NeverBaselinedSqlList} fragments touch user input — they're compile-time const string literals, so this is safe string composition, not a boundary risk.
  • Ordering: seed → heal → prune → clear-recovered → read, with heal placed so a poisoned row self-corrects on the same sweep that notices it, before the deviation read runs. Looks right in both the Darling service and Lite paths.
  • Lite parity note (expected, not a gap): Lite's heal doesn't clear last_alerted_state/last_alerted_at — Lite's schema has no such columns since it has no persisted alerted-state memory yet (pre-existing [FEATURE] Database State alert: per-state notify mode - fire OFFLINE/RESTORING once instead of re-firing every cooldown #2166 parity gap, called out explicitly in the PR description and CHANGELOG, not something this PR should be fixing).
  • No migration needed: no schema changes, confirmed no new PgMigrations version bump was required, and CHANGELOG entry is filed correctly under [Unreleased].

No blocking issues found. Nice work isolating the heal to only the machine-inferred, seed-would-have-refused states — that exclusion list is doing the real work here.

@erikdarlingdata

Copy link
Copy Markdown
Owner Author

Green and mergeable. Not merging, per instruction.

mergeable: MERGEABLE, mergeStateStatus: CLEAN, base dev.

check result
build SUCCESS (11m36s)
Darling PostgreSQL tests SUCCESS (4m19s)
Darling Linux build SUCCESS (1m32s)
check-branches SUCCESS
review SUCCESS

The Darling PostgreSQL job is the one that matters most here: the seed and the heal are pinned by gated-live tests, and those statements decide which rows exist, which no engine stub observes.

Local, against a real PostgreSQL 18.4 + TimescaleDB 2.28.1 cluster: Lite 2184/2184, Darling 4471 passed / 0 failed (10 pre-existing env-gated skips). Both apps build with 0 new warnings.

One thing worth a reviewer's eye, since it changed mid-PR. The first implementation used the broad rule "ONLINE overwrites any baseline the machine inferred," which is the direction the issue gestures at. Review caught two regressions it caused, and both are now pinned watched-red:

  1. A STANDBY secondary recovered out of standby (is_in_standby goes 0, genuinely ONLINE) got healed, so the "your log shipping is broken" alert became silence - and then fired when the operator re-established standby, announcing the repair instead of the break.
  2. An auto-baselined OFFLINE database brought up for maintenance and re-parked came back deviating forever against a baseline it never had. In Lite, with no persisted edge memory, that is an alert every cooldown for good - this bug inverted, caused by its own fix.

The rule that shipped is narrower and symmetric with the seed: a baseline recording a state the seed would refuse to learn is not a baseline anyone chose, so those and only those are re-learned on ONLINE. It uses the same shared constant as the seed, which also means it picked up a case the broad rule and I both missed - "reset to current" pressed during a SUSPECT outage plants an auto-baseline of SUSPECT with no state filter, and that now self-corrects on recovery too.

No migration; highest remains V60. Related deferral filed as #2203.

@erikdarlingdata

Copy link
Copy Markdown
Owner Author

Coordination note, no action needed from you: #2204 (mine, the #2203 Lite alerted-state memory) is green and CLEAN but I'm holding it behind this PR.

We overlap in three files — CHANGELOG.md, Lite.Tests/DatabaseStateExpectedStoreTests.cs, and Lite/Services/LocalDataService.DatabaseStates.cs. If I merged first you'd be pushed into conflict in all three, on a PR you're holding per instruction, for a fix that's addressing a live flood. You should go first; I'll rebase.

The overlap looks adjacent rather than semantic — you change the seed's exclusion list and add the heal; I add the memory columns to the deviation read plus a store-derived clear that runs before the seed. Both want to be before the seed, so that's the one spot worth a glance when I rebase. I'll take the merge conflict rather than hand it to you.

Flagging one thing in case it's useful while you're in there: my #2204 adds Lite schema v53. If your PR ends up needing a schema change too, we'd collide on the version number, and the initializer's ascent rule means a stale number silently skips rather than failing loudly. Doesn't look like it does from the file list, just naming it.

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