Skip to content

AG suspension semantics: one rule that holds whether or not the docs are right (#991) - #1702

Merged
erikdarlingdata merged 3 commits into
devfrom
feature/991-ag-suspension-semantics
Jul 26, 2026
Merged

AG suspension semantics: one rule that holds whether or not the docs are right (#991)#1702
erikdarlingdata merged 3 commits into
devfrom
feature/991-ag-suspension-semantics

Conversation

@erikdarlingdata

Copy link
Copy Markdown
Owner

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 *_time columns 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 SUSPEND on the secondary, sampled from the primary's view of the remote row every 15s:

t susp sync lag_s send_q redo_q commit_t hardened_t redone_t received_t est_redo_min est_send_min
0s 1 NOT SYNCHRONIZING 30 NULL 21896 19:08:26.687 19:08:26.930 19:08:26.687 NULL 0.014356 NULL
15s 1 NOT SYNCHRONIZING 45 NULL 21896 19:08:26.687 19:08:26.930 19:08:26.687 NULL 0.014356 NULL
30s 1 NOT SYNCHRONIZING 60 NULL 21896 19:08:26.687 19:08:26.930 19:08:26.687 NULL 0.014356 NULL
45s 1 NOT SYNCHRONIZING 75 NULL 21896 19:08:26.687 19:08:26.930 19:08:26.687 NULL 0.014356 NULL

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_seconds grows while suspended (over-reporting); the four *_time columns 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 reading is_suspended first, produces nonsense.

The sharpest edge is a measure this release just shipped. est_redo_completion_time_min is 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_min at least reads NULL, because its queue goes NULL, so the NULLIF guard 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:

A suspended row may RAISE an alarm but may never CLEAR one.

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_time read NULL in every sample, healthy and suspended alike, on this fixture. Treat it as optional, not expected.
  • last_commit_time / last_redone_time sit still on an IDLE database. They are "time of last commit", not a heartbeat. So now - last_commit_time is 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 reporting secondary_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 the dbo.ag_probe load table I created was dropped (OBJECT_ID confirms NULL). Left exactly as found.

Tests

Darling.Tests   3251 passed, 0 failed (156 skipped - the live-Postgres gated set)
Solution build  0 warnings, 0 errors

No test changes: this touches XML doc comments and one block comment only.

🤖 Generated with Claude Code

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 and others added 2 commits July 26, 2026 15:13
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>
@erikdarlingdata
erikdarlingdata merged commit 41a7a3b into dev Jul 26, 2026
4 checks passed
@erikdarlingdata
erikdarlingdata deleted the feature/991-ag-suspension-semantics branch July 26, 2026 19:29
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>
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