Skip to content

Alert on a suspended secondary that is falling behind (#991) - #1700

Merged
erikdarlingdata merged 2 commits into
devfrom
feature/991-ag-suspended-lag
Jul 26, 2026
Merged

Alert on a suspended secondary that is falling behind (#991)#1700
erikdarlingdata merged 2 commits into
devfrom
feature/991-ag-suspended-lag

Conversation

@erikdarlingdata

Copy link
Copy Markdown
Owner

Corrects a rule I shipped in #1692 that silenced the alert on the most common way a secondary falls behind.

What was wrong

The lag trigger made the seconds check abstain on a suspended row. That was written to MS Learn, which states secondary_lag_seconds "shows as 0 if the data movement is suspended" -- so abstaining looked like the careful choice, since a zero would otherwise read as "caught up" on the database that is furthest behind.

The documentation is wrong. ag-fixture-builder measured the inverse on the live Docker AG fixture, ag-collector-builder confirmed it, and I re-ran the suspend/resume cycle myself before changing shipped logic (SQL Server 2022 16.0.4265.3, clusterless AG, write load, sampled across a SUSPEND_FROM_USER on the secondary):

is_suspended=1  lag=3993  redo=0  send=NULL  NOT SYNCHRONIZING
is_suspended=1  lag=4005  redo=0  send=NULL  NOT SYNCHRONIZING
is_suspended=1  lag=4017  redo=0  send=NULL  NOT SYNCHRONIZING
is_suspended=1  lag=4029  redo=0  send=NULL  NOT SYNCHRONIZING
is_suspended=1  lag=4041  redo=0  send=NULL  NOT SYNCHRONIZING
after RESUME :  lag=0     redo=0  send=0     SYNCHRONIZED

+48 seconds of lag across four 12-second intervals -- it accrues at wall-clock rate and returns to 0 on resume. So the abstention was not being careful, it was silencing the alert on suspended data movement. A suspended secondary could drift arbitrarily far behind while only AG Database Suspended fired once, on the edge.

The fix

A suspended row may raise an alarm but may never clear one.

This is deliberately correct under both behaviors rather than betting on the measurement:

  • if lag accrues (measured), a suspended secondary crosses the threshold and fires;
  • if it ever did read 0 (documented), that 0 is under the threshold and yields NotMeasurable rather than CaughtUp, so it still cannot resolve a standing alert -- which is the false-recovery bug the three-state judgement was introduced for in the first place.

The same rule now also protects the redo 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 redo queue could resolve a standing alert.

Documented, not coded

  • log_send_queue_size reads NULL while suspended rather than growing, so it is useless as a fell-behind signal. This evaluator never used it -- noted so nobody reaches for it later.
  • On resume the secondary has a real backlog to drain (388,620 KB after a 60-second suspend under load), so a single-sample redo threshold fires during legitimate catch-up. That is not wrong -- the data-loss window is genuinely open until the queue drains -- but it is why the redo trigger ships off, and why a consecutive-sample qualifier is the natural follow-up if it proves noisy.
  • Vantage: sys.dm_hadr_* on a secondary carries only that replica's own rows, so a monitored secondary sees a one-row self-view of its AG. Nothing here assumed otherwise (the rules judge whatever rows arrive, keyed per ag+replica), but full AG coverage needs the primary monitored.

Not taken

The fixture also validated that replica-grain synchronization_health_desc flips HEALTHY -> NOT_HEALTHY during a suspend, which is a usable signal. I did not add a fifth alert for it -- it would largely duplicate AG Database Suspended, and the family was scoped to four. Worth considering as its own follow-up.

Testing

Darling suite green: 3252 passed, 0 failed. The pure judgement gains a case for every quadrant (suspended over/under threshold, for both triggers, plus the resumed measurement that does resolve), and there is a new end-to-end test for the exact case the old rule silenced: a suspended secondary drifting past the threshold now fires, stays quiet inside the cooldown, and resolves only once movement is running again.

The fixture was left SYNCHRONIZED and healthy.

🤖 Generated with Claude Code

erikdarlingdata and others added 2 commits July 26, 2026 15:04
The lag trigger made the seconds check ABSTAIN on a suspended row. That was
written to MS Learn, which says secondary_lag_seconds "shows as 0 if the data
movement is suspended" - so abstaining looked like the careful choice, since a
zero would otherwise read as "caught up" on the database that is furthest
behind.

The documentation is wrong. Measured against the live Docker AG fixture
(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 and returns to 0 on resume:

  is_suspended=1  lag=3993  redo=0  send=NULL  NOT SYNCHRONIZING
  is_suspended=1  lag=4005  redo=0  send=NULL  NOT SYNCHRONIZING
  is_suspended=1  lag=4017  redo=0  send=NULL  NOT SYNCHRONIZING
  is_suspended=1  lag=4029  redo=0  send=NULL  NOT SYNCHRONIZING
  is_suspended=1  lag=4041  redo=0  send=NULL  NOT SYNCHRONIZING
  after RESUME :  lag=0     redo=0  send=0     SYNCHRONIZED

+48 seconds of lag across four 12-second intervals. So the abstention was not
being careful, 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 to be paged for. A suspended secondary would have drifted 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.

This is deliberately correct under BOTH behaviors rather than betting on the
measurement. If lag accrues (measured), a suspended secondary crosses the
threshold and fires. If it ever did read 0 (documented), that 0 is under the
threshold and yields NotMeasurable rather than CaughtUp, so it still cannot
resolve a standing alert - which is the false-recovery bug the three-state
judgement was introduced for. The same rule now also protects the redo 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 redo queue
could resolve a standing alert.

Also documented, both measured on the fixture and neither a code change:
log_send_queue_size reads NULL while suspended rather than growing, so it is
useless as a fell-behind signal (this evaluator never used it); and on RESUME
the secondary has a real 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 is genuinely open until it drains, but
it is why that trigger ships OFF.

Plus a vantage note on the evaluator: sys.dm_hadr_* on a SECONDARY carries only
that replica's own rows, so a monitored secondary sees a one-row self-view of
its AG. Nothing here assumed otherwise, but full coverage needs the primary
monitored.

Field evidence from ag-fixture-builder and ag-collector-builder
(tools/ag-fixture/VALIDATION.md); I re-ran the suspend/resume cycle against the
running fixture myself before changing shipped logic, and left it SYNCHRONIZED.

Darling suite green: 3252 passed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
erikdarlingdata added a commit that referenced this pull request Jul 26, 2026
The comment restated MS Learn's "shows as 0 if the data movement is suspended"
as fact and reasoned from it. The sentence is wrong on 16.0.4265.3, and this
is not academic: the AG alert family shipped a rule that ABSTAINED on a
suspended row specifically to compensate for the documented behavior, which
silenced the lag alert on suspended data movement - the most common way a
secondary falls behind. That is fixed separately in #1700.

Replaced with what the column was measured doing, twice, by two parties:
reads 0 while movement is ACTIVE and caught up, accrues once suspended, and on
a suspended row reports roughly how stale the secondary's last hardened log is
rather than time since suspension - so it starts near zero under load but at
thousands of seconds on an idle group. It also does not latch immediately; a
suspended row can still read 0 for a sample or two, which is why a suspended
row may raise a lag alarm but must never clear one.

Comment only, no behavior change. The query and the collected columns were
already correct - both were validated verbatim against the fixture.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@erikdarlingdata
erikdarlingdata merged commit f8e8372 into dev Jul 26, 2026
4 checks passed
@erikdarlingdata
erikdarlingdata deleted the feature/991-ag-suspended-lag branch July 26, 2026 19:20
pull Bot pushed a commit to ehtick/PerformanceMonitor that referenced this pull request Jul 29, 2026
Follow-up to erikdarlingdata#1700. That PR got the RULE right but the MECHANISM wrong, and the
mechanism is what an operator needs to tune a threshold.

ag-fixture-builder reconciled why their measurement started near 0 and mine
started near 3993: theirs was under write load, mine was idle. While suspended
the column reports the staleness of the last hardened log (roughly
now - last_hardened_time), which then grows at wall-clock rate. It is NOT time
since suspension. Under load the last hardening is near-now so it starts around
0 and climbs; on an idle group it starts at however long since the last write,
so it can jump straight to a large number. Same behavior, different starting
points - neither measurement was wrong.

Chasing that turned up something sharper, which I measured myself on the idle
fixture rather than taking second-hand. Sampled every 15 seconds across a
60-second suspend with no write load:

  elapsed  susp  lag  redo  send  state              secs_since_hardened
  0        1     0    0     NULL  NOT SYNCHRONIZING  262
  15       1     0    0     NULL  NOT SYNCHRONIZING  277
  30       1     0    0     NULL  NOT SYNCHRONIZING  292
  45       1     0    0     NULL  NOT SYNCHRONIZING  307
  60       1     0    0     NULL  NOT SYNCHRONIZING  322

Lag read 0 at EVERY sample while the replica was already NOT SYNCHRONIZING and
its last hardened log aged past five minutes. It did not latch late, it never
latched. (ag-fixture-builder saw it latch at +30s on their idle run, so the
timing varies; the operational fact does not.)

Two things follow, and erikdarlingdata#1700's asymmetry already survives both - this commit is
documentation and alert text, no logic change:

1. A suspended replica can report zero lag for an entire outage. "A sub-threshold
   reading on a suspended row yields NotMeasurable, never CaughtUp" is what stops
   that zero from clearing a standing alarm on a replica receiving nothing. The
   rule was built for the inverted-docs case and turns out to be load-bearing for
   a completely different and more common one.

2. THE LAG TRIGGER ALONE CANNOT DETECT SUSPENDED DATA MOVEMENT on a quiet group.
   "AG Database Suspended" is the alert that owns that case. Worth stating
   plainly, because the obvious assumption is that a lag threshold covers it.

The alert detail text now also says what the number is: staleness of the last
hardened log, not volume of queued data. On a quiet group a large lag can simply
mean nothing has been written recently. The volume measure would be
log_send_queue_size, and that reads NULL while suspended - so there is no
queued-bytes figure available for a suspended replica at all.

Darling suite green: 3252 passed. Fixture left SYNCHRONIZED.

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