diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f21191d..2794a392 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,6 +42,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **Darling: a suspended secondary that is falling behind now raises the sync alert** ([#1700]) - the "AG Sync Fell Behind" lag trigger shipped in [#1692] made its seconds check ABSTAIN on a suspended row, written to MS Learn's statement that `secondary_lag_seconds` "shows as 0 if the data movement is suspended" - abstaining looked like the careful reading, since a zero would otherwise report the database that is furthest behind as caught up. **The documentation is wrong.** Measured against a live Availability Group (SQL Server 2022 16.0.4265.3, clusterless AG, write load, sampled across a `SUSPEND_FROM_USER` on the secondary), lag ACCRUES monotonically at wall-clock rate while suspended - 3993, 4005, 4017, 4029, 4041 across four 12-second intervals - and returns to 0 on resume. So the abstention was not caution, it was silencing the alert on suspended data movement: the single most common way a secondary falls behind, and the case an operator most needs paging for. A suspended secondary could drift arbitrarily far behind while only "AG Database Suspended" fired once, on the edge. Replaced with an asymmetry - **a suspended row may raise an alarm but may never clear one** - which is deliberately correct under BOTH behaviors rather than betting on the measurement: if lag accrues it crosses the threshold and fires, and if it ever did read 0 that zero is under the threshold and yields "not measurable" rather than "caught up", so it still cannot resolve a standing alert. The same rule now protects the redo-queue trigger, whose value FREEZES at its last reading while suspended (also measured): frozen and over the threshold is a real backlog worth firing on, frozen and under it is stale data that must not clear anything - previously a small frozen queue could resolve a live alert. Two more measured behaviors are documented rather than coded around: `log_send_queue_size` reads NULL while suspended instead of growing, so it is useless as a fell-behind signal (this evaluator never used it), and on RESUME the secondary has a genuine backlog to drain (388,620 KB after a 60-second suspend under load), so a single-sample redo threshold fires during legitimate catch-up - not wrong, since the data-loss window really is open until it drains, but it is why that trigger ships off. Evidence from the Docker AG fixture; the suspend/resume cycle was re-run independently before the shipped logic was changed. - **Darling: the Availability Group store reads are now executed against a real Postgres in CI** ([#1697]) - the two AG reads added in [#1692] had no test that ran their SQL, and that was the one gap that mattered: the defect they were corrected for during review is invisible to a unit test. Both grains were briefly read as two statements over a SINGLE command to save a round trip, which PostgreSQL rejects - Npgsql only splits multi-statement text into a batch when it parses the SQL for NAMED placeholders, so with the positional (`$1`) parameters those reads use it sends one extended-protocol `Parse` and the server answers `cannot insert multiple commands into a prepared statement`. Every AG path is failure-isolated, so it would not have crashed anything; it would have logged one error per server per sweep with the whole alert family silently dead, which is the worst failure mode a monitoring product has - the thing that is broken is the thing that tells you something is broken. The new `DARLING_TEST_PG`-gated test seeds both collector tables and asserts what only real SQL can prove: that each query executes, that the newest-snapshot predicate excludes an older one, that a row with a NULL identity column is dropped rather than keyed under a placeholder, that NULL lag and suspend-reason columns round-trip, and that the path reaches the sweep entry point and fires exactly one alert off freshly seeded rows. - **Lite + Darling: recovery notices were being styled and counted as live alerts in Alert History and the Daily Summary** ([#1692]) - `AlertMetricClassifier.IsResolution` is the shared source of truth for "this row is good news, not something to act on", and it recognized only the `Cleared` / `Resolved` / `Restored` suffixes. Darling's self-alert recoveries have been emitting `Collection Resumed`, `Agent Restarted` and `Compression Job Recovered` - written by the very same recovery path as the recognized `Capture Restored` - and every one of them landed in BOTH apps' Alert History grids styled as an actionable alert, and was counted as one in the Daily Summary's per-day alert totals. This is the same drift #1225 fixed one layer up, recurring one layer down. Widened the suffix set to `Resumed` / `Restarted` / `Recovered` / `Reconnected` (no actionable metric name in either app contains those words, so nothing real turns green), and widened both hand-maintained SQL copies of that list - Darling's `DailySummarySql` and Lite's `LocalDataService.DailySummary` - which is where the miscount came from. @@ -1668,6 +1669,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 [#1691]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1691 [#1692]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1692 [#1697]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1697 +[#1700]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1700 [#1690]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1690 [#1693]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1693 [#1694]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1694 diff --git a/Darling/Darling.Tests/DarlingSelfAlertTests.cs b/Darling/Darling.Tests/DarlingSelfAlertTests.cs index 2f87c0c6..7d411995 100644 --- a/Darling/Darling.Tests/DarlingSelfAlertTests.cs +++ b/Darling/Darling.Tests/DarlingSelfAlertTests.cs @@ -1000,32 +1000,39 @@ distinction matters because only a measured CaughtUp resolves a standing alert. } [Fact] - public void JudgeAgSync_SuspendedDatabase_AbstainsOnSeconds_ButStillJudgesTheRedoQueue() + public void JudgeAgSync_SuspendedDatabase_MayFire_ButMayNeverResolve() { - /* THE TRAP: MS Learn documents secondary_lag_seconds reading 0 — not NULL — while movement is - SUSPENDED, so the database that is furthest behind reports as caught up. The seconds trigger - therefore abstains, and with only that trigger enabled the row is NOT MEASURABLE — emphatically not - CaughtUp, which would resolve the standing alert at the exact moment things got worse. */ + /* Measured against a live AG: secondary_lag_seconds ACCRUES while suspended, it does not read 0 the + way MS Learn documents. A suspended secondary drifting past the threshold is the single most common + way a secondary falls behind, so it MUST fire — an earlier rule that abstained on suspended rows + silenced exactly that case. */ Assert.Equal( - DarlingSelfAlertEvaluator.AgSyncJudgement.NotMeasurable, - Judge(DatabaseRow(lagSeconds: 0, suspended: true), 300, 0)); + DarlingSelfAlertEvaluator.AgSyncJudgement.Behind, + Judge(DatabaseRow(lagSeconds: 9999, suspended: true), 300, 0)); - /* Even a NON-zero lag reading is ignored while suspended — the column is not trustworthy in that - state at all, so the rule is "suspended means the seconds trigger abstains", not "suspended plus - zero". */ + /* The other half of the asymmetry, and the reason this is not simply "judge suspended rows normally": + a suspended reading UNDER the threshold is not evidence of recovery. Were the documented zero-lag + behavior ever real, this is the row it would produce, and calling it CaughtUp would resolve a + standing alert at the exact moment things got worse. NotMeasurable is correct under both behaviors. */ Assert.Equal( DarlingSelfAlertEvaluator.AgSyncJudgement.NotMeasurable, - Judge(DatabaseRow(lagSeconds: 9999, suspended: true), 300, 0)); + Judge(DatabaseRow(lagSeconds: 0, suspended: true), 300, 0)); - /* The redo queue keeps judging while suspended: that backlog is real and still growing. */ + /* The redo queue fires while suspended too — a frozen queue over the threshold is a real backlog. */ Assert.Equal( DarlingSelfAlertEvaluator.AgSyncJudgement.Behind, Judge(DatabaseRow(lagSeconds: 0, redoKb: 8192, suspended: true), 300, 4096)); - /* ...and a suspended row with a SMALL redo queue is genuinely measured, so it may resolve. */ + /* ...but a frozen queue UNDER the threshold is stale data, not a recovery: redo_queue_size freezes at + its last value while suspended (measured), so it cannot clear anything either. */ Assert.Equal( - DarlingSelfAlertEvaluator.AgSyncJudgement.CaughtUp, + DarlingSelfAlertEvaluator.AgSyncJudgement.NotMeasurable, Judge(DatabaseRow(lagSeconds: 0, redoKb: 8, suspended: true), 300, 4096)); + + /* Once movement resumes, the same small readings are a real measurement and DO resolve. */ + Assert.Equal( + DarlingSelfAlertEvaluator.AgSyncJudgement.CaughtUp, + Judge(DatabaseRow(lagSeconds: 0, redoKb: 8, suspended: false), 300, 4096)); } [Fact] @@ -1279,6 +1286,42 @@ than starting a fresh episode from a phantom recovery. The resume itself is a hi Assert.Equal("AG Data Movement Resumed", Assert.Single(h.History.Records).MetricName); } + [Fact] + public async Task AgSyncFellBehind_SuspendedSecondaryDriftingPastTheThreshold_Fires() + { + var h = new Harness(); + var e = h.Build(); + + /* Healthy baseline, then movement is suspended. The suspend edge fires on its own. */ + await e.ApplyAgDatabaseHealthAsync(ServerId, Name, new[] { DatabaseRow(lagSeconds: 0) }, Ct); + await e.ApplyAgDatabaseHealthAsync( + ServerId, Name, new[] { DatabaseRow(lagSeconds: 12, suspended: true, suspendReason: "SUSPEND_FROM_USER") }, Ct); + Assert.Single(h.Deliverer.Outcomes); + Assert.Equal("AG Database Suspended", h.Deliverer.Outcomes[0].MetricName); + + /* Time passes and the lag accrues past the threshold while still suspended. This is the case the + product exists to catch, and the rule this test guards used to silence it: lag really does climb + while suspended (measured on a live AG), so the sync alert has to fire on its own rather than + trusting the suspend alert to have said everything. */ + await e.ApplyAgDatabaseHealthAsync( + ServerId, Name, new[] { DatabaseRow(lagSeconds: 600, suspended: true, suspendReason: "SUSPEND_FROM_USER") }, Ct); + + Assert.Equal(2, h.Deliverer.Outcomes.Count); + Assert.Equal("AG Sync Fell Behind", h.Deliverer.Outcomes[1].MetricName); + Assert.Contains("600 seconds behind", h.Deliverer.Outcomes[1].DetailText, StringComparison.Ordinal); + + /* Still suspended, still behind, inside the cooldown: no spam. */ + await e.ApplyAgDatabaseHealthAsync( + ServerId, Name, new[] { DatabaseRow(lagSeconds: 700, suspended: true) }, Ct); + Assert.Equal(2, h.Deliverer.Outcomes.Count); + + /* Resumed and genuinely caught up: NOW it resolves, off a measurement taken while movement was + running. Both the resume and the sync recovery are history rows. */ + await e.ApplyAgDatabaseHealthAsync(ServerId, Name, new[] { DatabaseRow(lagSeconds: 0) }, Ct); + Assert.Contains(h.History.Records, r => r.MetricName == "AG Sync Recovered"); + Assert.Contains(h.History.Records, r => r.MetricName == "AG Data Movement Resumed"); + } + [Fact] public async Task AgSyncFellBehind_NullReadingsUnderQuorumLoss_DoNotResolveTheStandingAlert() { diff --git a/Darling/PerformanceMonitor.Darling.Service/DarlingSelfAlertEvaluator.cs b/Darling/PerformanceMonitor.Darling.Service/DarlingSelfAlertEvaluator.cs index 6092de56..e30b3661 100644 --- a/Darling/PerformanceMonitor.Darling.Service/DarlingSelfAlertEvaluator.cs +++ b/Darling/PerformanceMonitor.Darling.Service/DarlingSelfAlertEvaluator.cs @@ -54,6 +54,10 @@ namespace PerformanceMonitor.Darling.Service; /// a replica changed role ("AG Failover"), a replica lost or regained its connection to the primary /// ("AG Replica Disconnected"/"AG Replica Reconnected"), a secondary fell behind by lag seconds or redo /// queue ("AG Sync Fell Behind"), and data movement for a database was suspended ("AG Database Suspended"). +/// VANTAGE MATTERS: sys.dm_hadr_* on a SECONDARY carries only that replica's own rows, so a +/// monitored secondary yields a one-row self-view of its AG rather than the whole topology (measured on a +/// clusterless AG). Nothing here assumes it can see every replica from any node — the rules simply judge +/// whatever rows arrive, keyed per ag+replica — but full AG coverage requires monitoring the primary. /// Gated on the V35 notify_ag_health master switch, with the two thresholds /// (ag_lag_alert_seconds, ag_redo_queue_alert_kb) store-backed alongside it. Keyed per AG /// grain rather than per server, because a server hosts many replicas and databases. @@ -666,10 +670,10 @@ internal readonly record struct AgDatabaseReading( /// very event that made it worse. internal enum AgSyncJudgement { - /// No enabled trigger had a usable reading — both thresholds off, the columns NULL (the - /// primary's own row, or WSFC quorum loss), or the only enabled trigger is the seconds one on a - /// SUSPENDED row. No signal: neither fire nor clear, the discipline - /// uses for a stale agent_status reading. + /// Nothing trustworthy to judge on — both thresholds off, the columns NULL (the primary's own + /// row, or WSFC quorum loss), or a SUSPENDED row that is not over any threshold. No signal: neither + /// fire nor clear, the discipline uses for a stale + /// agent_status reading. NotMeasurable, /// Measured, and within threshold. @@ -687,23 +691,34 @@ internal enum AgSyncJudgement /// Lag secondssecondary_lag_seconds >= ag_lag_alert_seconds. /// Redo queueredo_queue_size >= ag_redo_queue_alert_kb (KB). /// - /// THE TRAP this method exists to encode: MS Learn documents secondary_lag_seconds reading - /// 0 — not NULL — while data movement is SUSPENDED. So the database that is furthest behind, the - /// suspended one, reports as perfectly caught up, and a naive seconds check would clear right when it - /// should page. The seconds trigger therefore ABSTAINS on a suspended row and "AG Database Suspended" owns - /// that state. The redo-queue trigger keeps judging while suspended, because that backlog is real and keeps - /// growing. - /// Abstaining has to be its OWN answer rather than a false, or the caller reads it as recovery: - /// a lagging database that becomes suspended (or whose columns go NULL under quorum loss) would emit - /// "AG Sync Recovered — has caught up with the primary" in the same sweep that reports it suspended. That - /// is why this returns three states and not a bool. + /// SUSPENDED ROWS MAY RAISE AN ALARM BUT MAY NEVER CLEAR ONE. That asymmetry replaces an earlier rule + /// that made the seconds trigger abstain entirely while suspended, which was written to MS Learn's claim + /// that secondary_lag_seconds "shows as 0 if the data movement is suspended". THE DOCUMENTATION IS + /// WRONG. Measured against a live AG (SQL Server 2022 16.0.4265.3, clusterless AG, write load, sampled + /// across a SUSPEND_FROM_USER): lag ACCRUES monotonically at wall-clock rate while suspended + /// (…3993 → 4005 → 4017 → 4029 → 4041 over four 12-second intervals) and returns to 0 on resume. Abstaining + /// therefore silenced the lag alert on a suspended secondary — the single most common way a secondary falls + /// behind, and the case an operator most needs paged for. + /// The alarm/clear asymmetry is deliberately correct under BOTH behaviors, so this does not have to + /// bet on one: if lag accrues (measured), a suspended secondary crosses the threshold and fires; if it ever + /// did read 0 (documented), that 0 is below threshold and yields NotMeasurable rather than + /// CaughtUp, so it still cannot resolve a standing alert. The same rule protects the redo trigger, whose + /// value FREEZES at its last reading while suspended (also measured) — a frozen value over the threshold is + /// a real backlog worth firing on, while a frozen value under it is stale data that must not clear + /// anything. + /// Not clearing has to be its OWN answer rather than a false, or the caller reads it as + /// recovery: a lagging database that becomes suspended (or whose columns go NULL under quorum loss) would + /// emit "AG Sync Recovered — has caught up with the primary" in the same sweep that reports it suspended. + /// That is why this returns three states and not a bool. + /// One consequence worth knowing when tuning the redo trigger: on RESUME the secondary has a real, + /// large backlog to drain (measured at 388,620 KB after a 60-second suspend under load), so a single-sample + /// redo threshold will fire during legitimate catch-up. That firing is not wrong — the data-loss window is + /// genuinely open until the queue drains — but it is why the redo trigger ships OFF. /// internal static AgSyncJudgement JudgeAgSync( AgDatabaseReading reading, int lagThresholdSeconds, long redoThresholdKb, out string reason) { - bool secondsUsable = lagThresholdSeconds > 0 - && reading.IsSuspended != true - && reading.SecondaryLagSeconds.HasValue; + bool secondsUsable = lagThresholdSeconds > 0 && reading.SecondaryLagSeconds.HasValue; bool redoUsable = redoThresholdKb > 0 && reading.RedoQueueSizeKb.HasValue; if (secondsUsable && reading.SecondaryLagSeconds!.Value >= lagThresholdSeconds) @@ -725,6 +740,16 @@ internal static AgSyncJudgement JudgeAgSync( } reason = ""; + + /* Under no threshold. A SUSPENDED row stops here rather than clearing: while movement is suspended the + lag reading cannot be trusted downward and the redo reading is frozen, so "not over the line" is not + evidence of recovery. "AG Database Suspended" is the alert that owns that state, and the sync alert + resolves only once movement is running again and a real measurement says so. */ + if (reading.IsSuspended == true) + { + return AgSyncJudgement.NotMeasurable; + } + return secondsUsable || redoUsable ? AgSyncJudgement.CaughtUp : AgSyncJudgement.NotMeasurable; }