Repository navigation
Fix #2716: none of the 5 Postgres alerts' restart watermarks survive a service restart - #2723
Conversation
…a service restart AlertEngine seeds its SQL Server Deadlocks/Blocking watermarks from IAlertStateStore on each server's first post-restart sweep so a restart doesn't re-fire an event still sitting in the rolling window (#1145). Nothing equivalent existed for any of Darling's five Postgres alerts: - PgDeadlocks/PgBlocking (#2711/#2713) share AlertEngine's exact rolling-count watermark shape and the exact same metric names ("Deadlocks Detected"/ "Blocking Detected") for parity, so they now seed from and persist to the SAME config_edge_trigger_watermarks row AlertEngine already reads/writes - no new schema. Seeded once per (server, metric) key on first evaluation, persisted on watermark change, mirroring AlertEngine's own shape exactly. - The three Tier 0 outage predictors (wraparound/xmin/replication slot) have no count watermark at all - just a plain last-alerted TIME per subject (database/slot), which IAlertStateStore's int-watermark contract doesn't fit. Rather than add new schema for a 3-part (server, metric, subject) key, this reuses IAlertHistoryStore.GetLastAlertTimeAsync's existing #1154 dedup-key filter: finding.Subject IS the #1140 dedup fingerprint these alerts already fire with, so the filter already reconstructs exactly the per-subject seed needed. GetLastAlertTimeAsync gained an optional dedupKey parameter (mirroring its GetLastEmailSentUtcAsync/GetLastWebhookSentUtcAsync siblings) across every IAlertHistoryStore implementer: Lite's DuckDB store, Darling's Postgres store, Dashboard's deprecated JSON store, and every test fake. Guarded to one history read per (server, metric, subject) for the life of the process, not one per sweep. Verified against a real local Postgres 17 (a throwaway console harness referencing the shipped PgAlertHistoryStore/PgAlertStateStore directly, not a retyped copy): the dedupKey filter correctly isolates two subjects alerted at different times on the same (server, metric), the unfiltered call still returns the pre-fix metric-level max, a subject with no history seeds nothing, and the watermark store round-trips. Added PgAlertHistoryStore_GetLastAlertTimeAsync_FiltersByDedupKey_ForRestartSeeding to DarlingAlertingTests.cs, gated on DARLING_TEST_PG for CI. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
| && _postgresAlertHistorySeeded.TryAdd(cooldownKey, true)) | ||
| { | ||
| var seeded = await _historyStore.GetLastAlertTimeAsync( | ||
| snapshot.ServerKey, finding.MetricName, dedupKey: finding.Subject); |
There was a problem hiding this comment.
GetLastAlertTimeAsync's dedupKey filter was built (and is documented) as hex-fingerprint-only. AlertContext.cs's ContextJsonContainsDedupKey says the anchored match is "safe because the key is lowercase SHA-256 hex (no JSON escaping, no collision with any other serialized field)", and the DuckDB/Postgres stores' LIKE "%\"DedupKey\":\"" + dedupKey + "\"%" construction relies on the same assumption ("the hex key has no LIKE wildcards").
This call passes finding.Subject as dedupKey — but finding.Subject is a raw Postgres database name or replication slot name (db.DatabaseName/slot.SlotName in PostgresAlertEvaluator.cs), not a SHA-256 hash. That breaks both assumptions the matcher relies on:
- JSON-escaping mismatch (false negative):
AlertContextSerializer.SerializeusesJsonSerializer.Serializewith no custom encoder, so the defaultJavaScriptEncoderescapes non-ASCII characters and JSON-special characters (",\) as\uXXXX/\\/\". A database named e.g.caféor one containing a quote gets serialized as"DedupKey":"café", but the seed lookup builds its pattern from the raw, unescapedfinding.Subject(café), so the match silently fails. The restart-seed this PR is adding then returns null for that subject, and the exact bug being fixed here (re-firing on restart) resurfaces for any non-ASCII/quoted database or slot name. - LIKE-wildcard mismatch (potential false positive, Postgres/DuckDB stores only):
_and%in a database name are literal in the JSON but wildcards in theLIKEpattern, so a name likeorders_dbcould coincidentally match a differently-named subject on the same server/metric, seeding from the wrong row.
Since this is the first caller to feed a non-hex value into this hex-only-safe matcher, worth either hashing finding.Subject the same way AlertFingerprint does before using it as DedupKey/dedupKey, or giving this call path an exact-match lookup instead of reusing the hex-oriented one.
|
Reviewed. The watermark-seeding shape faithfully mirrors One correctness issue worth a look: left an inline comment on No SQL injection, secrets, or missing-index concerns; this is a pure C#/EF-style change with no T-SQL touched. |
GetLastAlertTimeAsync's dedupKey filter was built and documented as safe only for lowercase SHA-256 hex fingerprints: an anchored substring/LIKE match against "DedupKey":"<value>" in the serialized ContextJson, relying on hex having no JSON-escaping and no LIKE wildcards. #2716's Postgres Tier-0-predictor watermark seed passes finding.Subject - a raw database/slot name - as dedupKey, breaking both assumptions: a quote, backslash, or non-ASCII character in the name doesn't match its own JSON-escaped serialized form (false negative, reintroducing the restart re-fire bug for that subset of names), and an underscore or percent sign is a live SQL LIKE wildcard in the Postgres/DuckDB stores (false positive, matching a same-shaped different subject). Fixed at the root: AlertContextSerializer gains BuildDedupKeyJsonFragment (round-trips the key through JsonSerializer itself, so it can never drift from what Serialize() actually writes) and BuildDedupKeyLikePattern (the same fragment with LIKE's three special characters escaped, for use with ESCAPE '\'). ContextJsonContainsDedupKey and all three DuckDB/Postgres call sites now go through these instead of hand-concatenating the pattern, so the filter is correct for any dedupKey, not just hashes. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
ReviewWent through the full diff (C# only — no T-SQL/install changes here, so the T-SQL style rules don't apply). Traced the fix end-to-end against the pieces it claims parity with: Correctness
Lite/Darling/Dashboard parity
Security
Performance
Nothing else stood out. This is a tightly-scoped, well-tested fix with the parity work (Lite/Darling/Dashboard signature + escaping) done consistently rather than just patched at the new call site. |
The Postgres analogue of SQL Server's Poison Wait alert, watching the IPC:BtreePage / IPC:BufferIo pair identified by the issue's fleet research: near-zero on a healthy server, a structural inter-backend contention signal no other alert covers when it accumulates. Fires on total accumulated wait time normalized to a 10-minute window (Warning at an average of one backend continuously stuck, Critical at ten), per event, because an avg-ms-per-wait bar can never see 1-2 ms waits at six-figure volumes. Follows the engine-gated sibling path (PostgresAlertEvaluator + DarlingPostgresAlertReadAdapter, delivered from DarlingWorker), the shared PoisonWaitEnabled switch (erikdarlingdata#2722's precedent), per-subject cooldown keying (erikdarlingdata#1140), the unrefreshed-source-row guard (erikdarlingdata#2704), and history-seeded restart survival (erikdarlingdata#2716/erikdarlingdata#2723). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JgLaaxFenZFZs1SntKQDEg
Closes #2716.
Problem
AlertEngineseeds its SQL Server Deadlocks/Blocking watermarks fromIAlertStateStoreon each server's first post-restart sweep (EnsureWatermarksSeededAsync), so a restart doesn't re-fire an event still sitting in the rolling window (#1145). Nothing equivalent existed for any of Darling's five Postgres alerts — filed as a follow-up from review on #2713.Fix
PgDeadlocks/PgBlocking (#2711/#2713) share
AlertEngine's exact rolling-count watermark shape, and #2713 deliberately used the exact same metric names ("Deadlocks Detected"/"Blocking Detected") for parity — so they now seed from and persist to the sameconfig_edge_trigger_watermarksrowAlertEnginealready reads/writes. No new schema. Seeded once per (server, metric) key on first evaluation, persisted on watermark change, mirroringAlertEngine's own shape exactly.The three Tier 0 outage predictors (wraparound/xmin/replication slot) have no count watermark at all — just a plain last-alerted TIME per subject (database/slot), which
IAlertStateStore's int-watermark contract doesn't fit. Rather than add new schema for a 3-part (server, metric, subject) key, this reusesIAlertHistoryStore.GetLastAlertTimeAsync's existing #1154 dedup-key filter:finding.Subjectis the #1140 dedup fingerprint these alerts already fire with, so the filter already reconstructs exactly the per-subject seed needed.GetLastAlertTimeAsyncgained an optionaldedupKeyparameter (mirroring itsGetLastEmailSentUtcAsync/GetLastWebhookSentUtcAsyncsiblings) across everyIAlertHistoryStoreimplementer: Lite's DuckDB store, Darling's Postgres store, Dashboard's deprecated JSON store, and every test fake. Guarded to one history read per (server, metric, subject) for the life of the process, not one per sweep — most subjects (most databases) never alert at all, so an unconditional per-sweep read would be a real, avoidable cost against every server, every cycle.Verification
Built the project and the full solution clean. Since macOS can't run
Darling.Tests(net10.0-windows), wrote a throwaway console harness referencing the shippedPgAlertHistoryStore/PgAlertStateStoredirectly (not a retyped copy) against a real local Postgres 17:All 7 checks passed. Added
PgAlertHistoryStore_GetLastAlertTimeAsync_FiltersByDedupKey_ForRestartSeedingtoDarlingAlertingTests.cs, gated onDARLING_TEST_PGfor CI to run for real.Test plan
dotnet build(project + full solution) clean, 0 warnings, 0 errorsDarling PostgreSQL testsjob runs the new live test for real🤖 Generated with Claude Code