AG suspension semantics: one rule that holds whether or not the docs are right (#991) - #1702
Merged
Merged
Conversation
The collector's doc comments treated the SUSPENDED state as a single quirk about secondary_lag_seconds. Measuring the rest of the surface on the live Docker AG fixture showed it is broader, and that the columns disagree with each other in OPPOSITE directions. While a replica is suspended: - secondary_lag_seconds ACCRUES (measured twice: 0/15/31/46/62s and 30/45/60/75s), though MS Learn claims it reads 0. - log_send_queue_size goes NULL. - redo_queue_size FREEZES at its last value. - All four *_time columns FREEZE at their last pre-suspension instant. So a cross-replica commit-time delta stops growing exactly when replication has stopped - understating the problem at its worst - while the lag column overstates it. Averaging or cross-checking the two without reading is_suspended first gives nonsense. The sharpest edge is a measure this release shipped: est_redo_completion_time_min is queue / rate, and with both frozen it holds a small, static, reassuring value (0.0144 min, flat across a 45s suspension) when the honest answer is "never, movement is stopped". A suspended replica makes a drain panel look HEALTHIER than reality. All of it reduces to one rule, now stated on the collector and mirrored on the compose measures: a suspended row may RAISE an alarm but may never CLEAR one. That holds under both the documented and the measured behavior, so nothing downstream has to bet on which is true - WSFC and other builds remain untested, so a rule beats a new absolute. Framing credit to ag-alerts-builder, who landed the same asymmetry in the alert evaluator. Also documented: last_received_time read NULL in every sample, healthy or suspended; and last_commit_time / last_redone_time sit still on an IDLE database, so `now - last_commit_time` is not a lag measure - on a quiet, healthy replica it grows without bound. Doc-only; no behavior change. Fixture left as found (both replicas SYNCHRONIZED, probe table dropped). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
erikdarlingdata
enabled auto-merge
July 26, 2026 19:13
…ion-semantics # Conflicts: # CHANGELOG.md
Their correction (feature/991-ag-fixture, 5f7a20b) explains the lag mechanism better than mine did, and it is NOT on dev: PR #1689 merged an earlier state of that branch, so 5f7a20b is not an ancestor of dev and would not have shipped. Their branch also predates #1695, so merging it now would revert the six V36 columns along with the comment. Adopted their paragraph essentially verbatim here instead, on a branch that is current with dev: - While suspended, secondary_lag_seconds reports roughly how STALE the secondary's last hardened log is (now - last_hardened_time), NOT time since suspension. That reconciles the two runs that looked contradictory: near-zero start under write load, thousands of seconds immediately on an idle group. - It does not latch the moment movement stops - a suspended row can still report 0 for the first sample or two. - The magnitude is staleness, not volume at risk; log_send_queue_size would be the volume measure and it is NULL while suspended, which dovetails with the freeze findings already in this block. - Points at tools/ag-fixture/VALIDATION.md for the numbers. My own measurements stay: the freeze of all four *_time columns, the drain-estimate edge, the idle-database commit-time trap, and the last_received_time NULL observation. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
erikdarlingdata
added a commit
that referenced
this pull request
Jul 26, 2026
#991) ag-collector-builder measured the four new *_time columns under suspension (#1702) and the result generalizes the rule rather than adding a footnote to it. All four freeze at their last pre-suspension instant. So a cross-replica commit-time delta STOPS GROWING exactly when replication stops - it reads as though the secondary is catching up at the moment it has stopped receiving anything. The sharpest case is any drain-time measure: queue divided by rate with both frozen holds a small, static, healthy-looking number (measured at a flat 0.0144 minutes across an entire suspension) when the true answer is "never, movement is stopped". That is the same failure as the lag zero and the frozen redo queue, but arrived at three different ways, and every one of them errs in the REASSURING direction. A drain-time or commit-delta trigger wired in here would fail SILENT rather than loud, which is the worse half. So the doc block now states the invariant as a property of suspended ROWS - anything new that judges one must route through the same may-fire-never-clear gate - instead of reading as two column-specific carve-outs that a future trigger would not obviously be covered by. No logic change: the gate already sits at the single exit point, so a new trigger inherits it by construction as long as nobody adds an early return above it. Also on record from that run, neither affecting this evaluator: last_commit_time is not a heartbeat (it sits still on an idle database, and 7 minutes of apparent "lag" was measured on a caught-up secondary reporting secondary_lag_seconds = 0), so now - last_commit_time is not a lag measure and would page on quiet databases; and last_received_time read NULL in every sample, healthy or suspended. Their run is also an independent third confirmation of the lag behavior (30/45/60/75 under load on a separate run). Darling suite green: 3252 passed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
erikdarlingdata
added a commit
that referenced
this pull request
Jul 26, 2026
References #1702 without a link-ref on purpose - that PR will add its own, and two branches adding the same link-ref is a guaranteed CHANGELOG conflict. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
pull Bot
pushed a commit
to ehtick/PerformanceMonitor
that referenced
this pull request
Jul 29, 2026
…ag claim erikdarlingdata#1702 and erikdarlingdata#1703 both cite tools/ag-fixture/VALIDATION.md for how a suspended row behaves, and the file did not contain the measurements behind several of those claims. This lands them. It also corrects something I got wrong in erikdarlingdata#1704. I wrote that secondary_lag_seconds "only latched at the +30s sample", which implied a short bounded window. ag-alerts-builder measured it never latching across a whole 60s suspension on a quiet group, and I reproduced that independently here: 0 at every sample through a full 60-second suspension, already NOT SYNCHRONIZING, with the last hardened log ~29 minutes stale. One earlier idle run latched at +30s and these did not, so the timing is not dependable in either direction. The file now says so, and states the consequence plainly: a lag threshold alone cannot detect suspended data movement on a quiet group. Also added, with sample tables from a loaded run and an idle one: - All four *_time columns freeze at their last pre-suspension instant, so a cross-replica commit-time delta stops growing exactly when replication stops - the opposite direction from secondary_lag_seconds. - redo_queue_size freezes rather than growing. - est_redo_completion_time_min is the sharpest edge: queue over rate with both frozen holds a small static reassuring number for the whole suspension (0.0144 min loaded, 0 idle), so a suspended replica looks healthier than a working one. - last_received_time read NULL in every sample on both runs. - last_commit_time is not a heartbeat - measured 1757 seconds behind wall clock on a SYNCHRONIZED, non-suspended, zero-lag replica, purely because the database was quiet. Loaded-run table is ag-collector-builder's; the idle run and the never-latch reproduction are mine. Evidence file and CHANGELOG only, no code. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Doc-only, no behavior change — but it corrects an under-description of the SUSPENDED state that affects how every AG panel and threshold should be read, including one measure #1695 shipped an hour ago.
@ag-alerts-builder suggested capturing how the four new
*_timecolumns behave under suspension "while the fixture is still up, because the pattern here was that the vendor docs were wrong about exactly this state." They were right to ask.What I measured
On the live Docker AG fixture (SQL Server 2022,
CLUSTER_TYPE = NONE), write load applied,ALTER DATABASE ... SET HADR SUSPENDon the secondary, sampled from the primary's view of the remote row every 15s:Everything freezes except
secondary_lag_seconds, which accrues — an independent second confirmation of @ag-fixture-builder's finding, on a separate run.Why it matters more than "one more quirk"
The columns disagree in opposite directions.
secondary_lag_secondsgrows while suspended (over-reporting); the four*_timecolumns freeze, so a cross-replica commit-time delta stops growing exactly when replication has stopped — understating the problem at the moment it is worst. Averaging them, or cross-checking one against the other without readingis_suspendedfirst, produces nonsense.The sharpest edge is a measure this release just shipped.
est_redo_completion_time_minis queue ÷ rate, and with both operands frozen it holds a small, static, reassuring value — 0.0144 min, flat across the whole suspension — when the honest answer is "never, movement is stopped." A suspended replica makes a drain panel look healthier than reality.est_send_drain_time_minat least reads NULL, because its queue goes NULL, so theNULLIFguard saves that one by accident rather than by design.The fix: a rule instead of a new absolute
I could have flipped "reads 0" to "accrues" and moved on. @ag-alerts-builder proposed a better shape, which I've adopted verbatim in spirit:
That is correct under both the documented and the measured behavior, so nothing downstream has to bet on which is true — which matters, because my evidence is one build on a clusterless AG with WSFC untested. Stating an absolute I've verified twice on one configuration is how the original MS Learn sentence became wrong in the first place. The rule now leads the collector's doc block, and the compose-measure comment mirrors it with the drain-estimate specifics.
Two more things worth writing down
last_received_timeread NULL in every sample, healthy and suspended alike, on this fixture. Treat it as optional, not expected.last_commit_time/last_redone_timesit still on an IDLE database. They are "time of last commit", not a heartbeat. Sonow - last_commit_timeis not a lag measure: on a quiet, perfectly healthy replica it grows without bound. I saw 7 minutes of apparent "lag" on a caught-up secondary reportingsecondary_lag_seconds = 0. That is a trap someone will otherwise walk into the first time they use these columns.Fixture hygiene
Borrowed, restored, verified: both replicas back to
SYNCHRONIZED/is_suspended = 0/ lag 0, and thedbo.ag_probeload table I created was dropped (OBJECT_IDconfirms NULL). Left exactly as found.Tests
No test changes: this touches XML doc comments and one block comment only.
🤖 Generated with Claude Code