Skip to content

Forced Plan Failing alert — page when Query Store can't reproduce a forced plan (#2157) - #2175

Merged
erikdarlingdata merged 4 commits into
devfrom
forceplan-alert-2157
Aug 11, 2026
Merged

Forced Plan Failing alert — page when Query Store can't reproduce a forced plan (#2157)#2175
erikdarlingdata merged 4 commits into
devfrom
forceplan-alert-2157

Conversation

@erikdarlingdata

Copy link
Copy Markdown
Owner

What it catches

When a forced Query Store plan can't be reproduced, the query keeps running — on whatever plan the optimizer picks. Nothing in the product witnessed that: the operator's mitigation was silently not in effect, and the only trace was force_failure_count climbing inside Query Store.

The new Forced Plan Failing alert fires per plan on a rise in that counter, carrying database, query/plan ids, MANUAL vs AUTO forcing, the engine's own failure reason, and how many failures are new.

The design decisions worth reviewing

A rise, never a level. The counter is cumulative and it travels with a restored database — restore one elsewhere and its Query Store arrives carrying every historical failure. Level-based firing would page forever about failures that happened on hardware the operator may not own anymore. So the STORE computes the delta (mirroring how the database-state adapter returns only deviating rows) and the engine never sees a level.

Corollaries, both pinned: a counter that drops is an unforce/re-force reset and is silence, not a negative delta. A plan with only one sample is omitted — one cycle of delay, because "new" isn't knowable from a single observation, and firing on first sight is exactly how you'd alert on every restored database's imported history.

Warning for every rise, no Critical tier. A failing force isn't an outage. Grading one Critical would need evidence about which reasons or rates correlate with harm, and I don't have it; the token doc says what evidence would justify it later.

Per-PLAN keys. Two failing plans in one database are independent conditions that resolve independently — a server- or database-keyed cooldown would swallow the second entirely. Pinned by a test with two plans in one database.

The resolution says "no longer failing", not "fixed", because the counter also stops when the force is removed or the query stops running, and the alert can't distinguish those.

Enabled with no toggle. The alert is silent by construction on a healthy fleet (it needs a rise), so there's nothing for a switch to protect anyone from. Notably a darling.json-only flag would NOT have worked: the store reload swaps the whole Alerts object, so it would silently reset on the first config_version bump — if this ever needs to be configurable it belongs in the store, with the ladder that implies.

Field motivation

2026-08-10 tenant migrations: six databases restored onto new hosts arrived carrying 51 forced plans between them. Verifying they still reproduced took three hand-run baseline/delta passes across the wave. This alert is that check on a collection cadence.

Testing

AlertEngineTests: the disabled gate skips the READ (not just the fire), two plans in one database both alert with reason/forcing/delta in the body, cooldown suppresses the second fire and a per-plan resolution follows when the counter stops, and an excluded database never alerts (case-insensitive, parity with the other database-scoped families). Four IAlertReadAdapter fakes and four IAlertEngineSettings fakes updated; Alerting, Darling service, Lite, and both test projects build clean.

Store SQL is shape-for-shape across Postgres and DuckDB so the apps can't disagree about what counts as new. Remaining for a follow-up: SQL-shape pins on the two adapter queries (the delta comparison, the forced filter, the two-hour bound).

Fixes #2157.

erikdarlingdata and others added 3 commits August 11, 2026 03:21
The alerting unit is one forced PLAN whose force_failure_count rose
between the two most recent collections — a delta, never a level,
because the counter is cumulative and travels with a restored database:
level-based firing would alert forever about failures that happened on
a machine the operator may not own anymore. A counter that DROPS is an
unforce/re-force cycle and re-arms silently.

Severity is Warning for every rise, deliberately with no Critical tier:
a failing force is not an outage (the query runs on the optimizer's
plan), and inventing urgency thresholds without field evidence about
which reasons correlate with harm is how alert streams stop being read.
The doc says what evidence would justify grading one.

Model + tokens only; adapters, engine block, settings, and tests follow.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds IAlertReadAdapter.GetForcePlanFailuresAsync plus both
implementations in one commit, since the interface method breaks every
implementor until they all have it (two adapters and four test fakes).

The SQL is deliberately shape-for-shape across Postgres and DuckDB so
the apps cannot disagree about what counts as a new failure:
query_store_stats carries one row per plan per interval per collection
with the forcing columns repeated, so each (plan, collection_time)
collapses via MAX before any comparison; the newest two samples are
then compared and only a RISE is returned. Equal counters are silence.
A LOWER counter — an unforce/re-force reset — is silence too, not a
negative delta. A plan with only one sample is omitted: 'new' is
unknowable from a single observation, which costs one cycle of delay.

The two-hour window bounds the scan; a plan not collected inside it is
not failing now, and Query Store's 900s flush means an active plan
appears several times within it.

Engine block, settings gate, and tests follow.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
CheckForcePlanFailuresAsync follows the database-state family exactly:
settings gate before the read, log-and-skip on a failed fetch (never
resolve on absent evidence — that fabricates a recovery), the shared
excluded-databases filter case-insensitively over ORDINAL object keys,
a per-PLAN active set and cooldown, mute stamped even when muted, and a
per-plan resolution when the counter stops rising.

Per-PLAN keys matter: two failing plans in one database are independent
conditions, and a server- or database-keyed cooldown would swallow the
second one entirely. Pinned.

The resolution says 'no longer failing' rather than 'fixed', because
the counter also stops when the force is removed or the query stops
running, and the alert cannot tell those apart.

Enabled with no user-facing toggle, deliberately: the alert is silent
by construction on a healthy fleet (it needs a RISE), and a
darling.json-only flag would be silently reset on the first store
reload since ApplyToConfig swaps the whole Alerts object — so if this
ever needs to be configurable it belongs in the store.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment on lines +1450 to +1461
{
var recovered = active.Where(p => !current.ContainsKey(p)).ToList();
foreach (var planKey in recovered)
{
active.Remove(planKey);
_lastForcePlanAlert.TryRemove(key + "|" + planKey, out _);
if (!suppressed)
{
await NotifyResolutionAsync(new AlertResolution(
key, serverName, ForcePlanTokens.MetricName,
"Forced Plan Failing Resolved",
$"{serverName}: {planKey} no longer failing to force"), ct);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Resolution message leaks the internal alert key instead of a human-readable identity.

var recovered = active.Where(p => !current.ContainsKey(p)).ToList();
foreach (var planKey in recovered)
{
    active.Remove(planKey);
    _lastForcePlanAlert.TryRemove(key + "|" + planKey, out _);
    if (!suppressed)
    {
        await NotifyResolutionAsync(new AlertResolution(
            key, serverName, ForcePlanTokens.MetricName,
            "Forced Plan Failing Resolved",
            $"{serverName}: {planKey} no longer failing to force"), ct);
    }
}

active is a HashSet<string> of ForcePlanTokens.PlanKey(...) values, i.e. strings shaped like forceplan:Sales:11:22. The recovery message interpolates that raw key directly, so the resolution an operator actually sees (in the tray toast, email, webhook, and history) reads:

SQLPROD01: forceplan:Sales:11:22 no longer failing to force

instead of something legible like SQLPROD01: Sales query 11 plan 22 no longer failing to force. Compare with the firing side a few lines up, which builds a proper human-readable string from the individual fields (failure.DatabaseName, failure.QueryId, failure.PlanId), and with CheckDatabaseStateAsync's recovery message, which this code explicitly says it mirrors — there active holds the raw database name itself, so $"{serverName}: {dbName} back to expected state" is already readable. For force-plan, the active set holds the encoded key, not the display identity, so the same pattern leaks the encoding.

ForcePlanFailure_CooldownSuppressesSecondFire_ThenResolvesWhenTheCounterStops doesn't catch this because it only asserts r.Message.Contains("22"), which is true either way.

Fix: track (database, queryId, planId) alongside the key in _activeForcePlanAlerts (e.g. ConcurrentDictionary<string, Dictionary<string, ForcePlanFailureInfo>> instead of HashSet<string>), or at minimum parse the pieces back out of planKey before building the message.

MAX(COALESCE(qs.last_force_failure_reason, '')) AS reason
FROM v_query_store_stats AS qs
WHERE qs.server_id = $1
AND qs.collection_time > now() - INTERVAL 2 HOUR

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: now() - INTERVAL 2 HOUR (unquoted numeral) is the only interval literal written this way anywhere in the codebase — every other DuckDB query in Lite/Services/*.cs spells this INTERVAL '2 hours' / INTERVAL '1 day' (e.g. LocalDataService.QueryStore.cs:110, LocalDataService.WaitStats.cs:627). DuckDB is generally tolerant of the bare form, so this likely runs fine, but it's worth calling out because nothing actually exercises this SQL text: LiteAlertForwardingTests.cs's fake adapter returns canned ForcePlanFailureInfo rows and never runs ForcePlanFailuresSql against a real DuckDB connection (same gap the PR description already flags for the Postgres side — "SQL-shape pins on the two adapter queries" are called out as follow-up work). If this string ever fails to parse, CheckForcePlanFailuresAsync's catch-and-log swallows it silently every sweep — Lite users would never get Forced Plan Failing alerts and nothing would signal that. Worth a quick manual run against DuckDB (or matching the quoted-interval convention) before merge, given the whole point of this alert is to catch things that otherwise fail silently.

@claude

claude Bot commented Aug 11, 2026

Copy link
Copy Markdown

Reviewed this PR (correctness, Lite/Darling parity, security, performance). Overall the design is careful and well-reasoned — rise-not-level semantics, per-plan cooldown keys, the shape-for-shape Postgres/DuckDB SQL, and the no-toggle rationale all hold up. Left two inline comments:

  1. Bug (PerformanceMonitor.Alerting/AlertEngine.cs:1450-1461): the resolution message for a recovered plan interpolates the raw internal alert key (forceplan:Sales:11:22) instead of a human-readable identity, so operators would see e.g. SQLPROD01: forceplan:Sales:11:22 no longer failing to force in the toast/email/webhook/history. The firing-side message builds proper text from the individual fields; the recovery path only has the encoded HashSet<string> key to work with. The existing test doesn't catch this because it only asserts the message contains "22".

  2. Worth verifying before merge (Lite/Services/LocalDataService.ForcePlanFailures.cs:49): the DuckDB query uses unquoted INTERVAL 2 HOUR, which is the only interval literal written that way anywhere in the codebase (everywhere else uses INTERVAL '2 hours'). Nothing exercises this SQL against a real DuckDB connection yet (the PR description already flags SQL-shape pins as follow-up work for both adapters), so if the syntax doesn't parse, the alert fails silently every sweep via the catch-and-log path — ironic given this alert exists specifically to catch silent failures.

No SQL injection concerns (both queries are fully parameterized), no missing-index suggestions offered per repo convention, and Lite/Darling parity otherwise looks solid — settings, adapters, and test fakes were updated in lockstep on both sides.

1. The resolution leaked the internal key — operators would have read
   'forceplan:Sales:11:22 no longer failing to force' in every toast,
   email and history row. The active set now carries each plan's
   identity rather than just its key, so the recovery names the plan the
   way the firing message does. My test could not catch this: it
   asserted the message contained '22', which the leaked key satisfies.
   It now asserts the key prefix is ABSENT and the readable parts are
   present.

2. Lite's query used the unquoted INTERVAL 2 HOUR form, which appears
   nowhere else in the codebase. Switched to INTERVAL '2 hours', the
   form every other DuckDB query here already proves parses. The failure
   mode if it hadn't was ugly: catch-and-log every sweep, i.e. this
   alert failing silently — the exact thing it exists to catch.

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

Copy link
Copy Markdown
Owner Author

Both taken, and the first one is the more embarrassing of the two:

  1. Leaked internal key in the recovery text. Real bug — operators would have read forceplan:Sales:11:22 no longer failing to force in toasts, emails, webhooks and history rows. The active set is now keyed by the plan key but VALUED with the plan's identity, so the resolution names it the way the firing message does. Worth stating plainly why my test missed it: I asserted Message.Contains("22"), which the leaked key satisfies — the assertion was compatible with the bug. It now asserts the key prefix is absent and that Sales, query 11, plan 22 are present.

  2. The unquoted INTERVAL 2 HOUR. Good catch on the reasoning as much as the syntax: nothing exercises that SQL against a real DuckDB yet, so a parse failure would have been swallowed by the catch-and-log path and this alert would have failed silently every sweep — which is precisely the failure class it was built to surface. Switched to INTERVAL '2 hours', the form every other DuckDB query in the repo already proves parses. I'm treating 'match the form the codebase demonstrably runs' as the rule here rather than trusting my read of DuckDB's grammar.

The follow-up SQL-shape pins noted in the PR body would have caught #2 mechanically; that stays on the list.

@claude

claude Bot commented Aug 11, 2026

Copy link
Copy Markdown

Review summary

Went through the diff (AlertEngine.cs, ForcePlanFailureInfo.cs, ForcePlanTokens.cs, both read adapters, both settings classes, and all four test-fake updates). No CLAUDE.md exists in this repo (checked history and current tree), so I reviewed against CONTRIBUTING.md's style guide and this PR's own established precedent (CheckDatabaseStateAsync), which CheckForcePlanFailuresAsync mirrors closely.

Correctness

  • Delta computation is sound: n.failures > p.failures correctly excludes drops (unforce/re-force resets) and equal counters; the rn = 2 self-join correctly omits plans with only one sample in the 2-hour window (first-observation delay is intentional, per the PR description).
  • Per-plan cooldown/active-set keying (forceplan:{db}:{query}:{plan}) correctly resolves the earlier "leaked internal key in resolution text" bug (there's a regression test for it: ForcePlanFailure_CooldownSuppressesSecondFire_ThenResolvesWhenTheCounterStops).
  • Excluded-databases filtering, mute-context wiring, and cooldown-stamped-even-when-muted semantics all match the sibling CheckDatabaseStateAsync path.
  • Log-and-skip on adapter failure never fabricates a recovery — correct per the "never resolve on absent evidence" invariant already used elsewhere in this engine.

Lite/Darling parity — no drift found:

  • ForcePlanFailuresSql in DarlingAlertReadAdapter.cs and LocalDataService.ForcePlanFailures.cs are shape-for-shape identical (only query_store_stats vs v_query_store_stats, as expected for table vs. archive-unioned view).
  • IAlertReadAdapter/IAlertEngineSettings additions are implemented in both real adapters (DarlingAlertReadAdapter, LiteAlertReadAdapter/LocalDataService) and all four test fakes (AlertEngineTests, AlertStoredValueTests, DarlingSelfAlertTests, StoreSelfMetricsTests, LiteAlertForwardingTests) — verified no other IAlertReadAdapter/IAlertEngineSettings implementers were missed.
  • ForcePlanFailureEnabled defaults to true identically in both DarlingAlertSettings and AppAlertEngineSettings, with matching rationale comments about why it isn't a store/config column yet.

Security

  • Both SQL paths are fully parameterized ($1/DuckDBParameter), serverKey is parsed through the existing int.Parse ParseServerKey — no injection surface.
  • No secrets, file, or process handling introduced.

Performance

  • Query is bounded to a 2-hour window per server and runs on collection cadence, not hot-path; no missing-index concerns raised (per instructions, not the place for that folklore anyway).

Nothing blocking found. Nice test coverage on the "recovery message names the plan, not the internal key" regression.

@erikdarlingdata
erikdarlingdata merged commit 95c84cc into dev Aug 11, 2026
5 checks passed
@erikdarlingdata
erikdarlingdata deleted the forceplan-alert-2157 branch August 11, 2026 02:43
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