AG latency: commit-time columns, drain-time estimates, primary-side perfmon counters (#991) - #1695
Merged
Merged
Conversation
# Conflicts: # CHANGELOG.md
…erfmon counters (#991) ag_database_replica_states gains six appended columns: - last_commit_time / last_hardened_time / last_redone_time / last_received_time, the four DMV timestamps the reference query skips. Unlike secondary_lag_seconds these are directly comparable across replicas, which the primary-vs-secondary commit-time lag math needs. - est_redo_completion_time_min / est_send_drain_time_min, computed server-side as queue / rate / 60 with both guards the raw expression needs: `* 1.0` stops BIGINT/BIGINT integer division flooring a sub-minute drain to zero, NULLIF(rate, 0) stops the divide-by-zero an idle or suspended replica raises (which fails the whole cycle, not one column). NULL means "no drain rate" and is never coerced to 0, which would read as "drains instantly". The estimates are computed per row at the sample's own instant rather than composed later, because a ratio of two window aggregates is not the average of the per-sample ratios and the two diverge worst exactly when rates swing. Both are Gauge compose measures, duration family, minutes. PerfmonStatsCollector's whitelist gains Transaction Delay and Mirrored Write Transactions/sec - the primary side of commit latency, their ratio being the average delay per mirrored transaction. Zero new schema. Both verified live on SQL2022: they exist with no AGs configured, sit on SQLServer:Database Replica, and occur exactly once server-wide, so the counter_name-only filter cannot collide. Corrects a doc claim shipped in #1688. It restated MS Learn's assertion that secondary_lag_seconds reads 0 while data movement is suspended; a Docker AG fixture measured the inverse on SQL Server 2022 in a CLUSTER_TYPE = NONE group - 0 while movement is ACTIVE and caught up, accruing monotonically once suspended (0 to 62s across a 60s SUSPEND_FROM_USER, back to 0 on resume). A suspended replica does not hide as zero lag. Also documents two further measured quirks: log_send_queue_size goes NULL while suspended while redo_queue_size freezes at its last value, and collecting from a secondary yields a one-row self-view because sys.dm_hadr_* carries only the local replica there. Store migration V36 appends the columns additively instead of widening V34, because V34's CREATE TABLE IF NOT EXISTS is a no-op on an already-migrated store - editing it would leave every existing store six columns short while fresh installs got them. The schema pin now reconstructs the current shape from V34 + V36 and compares it to the generator; both failure modes confirmed by planted defects. Version 36 because 35 is claimed by the concurrent AG-alerts work. THIRD_PARTY_NOTICES.md now credits Hannah Vernon's SqlServerAgMonitor alongside the existing collector-header attribution. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
erikdarlingdata
enabled auto-merge
July 26, 2026 18:32
The end-to-end Postgres test asserted COUNT(*) FROM darling_schema_version == StorageVersion.SchemaVersion. That identity only holds while migration versions are DENSE from 1, so the first concurrently-developed pair of migrations broke it: V35 (AG alerts) and V36 (AG latency) are being built on separate branches, and V36 alone leaves a temporary gap. The gap is inert to the applier - MigrateAsync applies every script whose version exceeds MAX(version) and never assumes contiguity - so the proxy was the only thing that cared. Replaced with the two invariants it was conflating, which together are strictly stronger: MAX(version) == SchemaVersion (the store reached this build's version, the same expression MigrateAsync itself reads) and COUNT(*) == PgMigrations.Scripts.Count (every script ran, one stamped row apiece, so a silently skipped script still fails). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
# Conflicts: # CHANGELOG.md # Darling/Darling.Tests/DarlingObservabilityTests.cs # Darling/Darling.Tests/DarlingServerTagsTests.cs # Darling/PerformanceMonitor.Darling.Storage/PgMigrations.cs # Darling/PerformanceMonitor.Darling.Storage/StorageVersion.cs # Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.cs
# Conflicts: # CHANGELOG.md
# Conflicts: # CHANGELOG.md
# Conflicts: # CHANGELOG.md
…ngdata/PerformanceMonitor into feature/991-ag-latency
erikdarlingdata
added a commit
that referenced
this pull request
Jul 26, 2026
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>
pull Bot
pushed a commit
to ehtick/PerformanceMonitor
that referenced
this pull request
Jul 29, 2026
…ver every template (erikdarlingdata#991) The fifth Custom Views v2 seed, built on the AG measures from erikdarlingdata#1688/erikdarlingdata#1695. Five panels in diagnostic order: 1. Secondary lag over time, grouped by replica. 2. Dual-axis line: log send rate vs redo rate on their own axes - the panel that answers which side is the bottleneck. 3. Stacked redo-queue series by database. 4. Stat tile: worst estimated redo drain. 5. Top-10 bar of send-queue backlog by database. The prose between panels carries the two things that make the numbers readable: point the view at the PRIMARY (sys.dm_hadr_* on a secondary only carries the local replica, so a secondary-scoped view is a one-row self-view), and a blank drain estimate means no drain rate - idle, caught up, or suspended - not that it drains instantly. Adds the two template helpers the existing seeds never needed: overlay support on tsPanel (dual-axis, legal only on an ungrouped line/area) and a scalar statPanel. This is the first seed exercising dual-axis, stacked and stat modes end to end. The brief asked for span 2 on the lag panel; notebooks are single-column documents and the renderer strips span, so it is omitted rather than stored as a field nothing honors. Also closes a hole in the templates drift guard. It hand-mirrors each template's panels so a MeasureCatalog change that breaks a seed fails in CI, but nothing checked the mirror COVERED every template - a sixth template added without a mirror entry would have gone silently unvalidated until it 400'd in a browser. It now reads the template keys out of notebook.js and requires the mirror to match exactly. Verified by renaming a key and watching it go red, and the new mirror was checked against the real module output by executing notebook.js under node and diffing the emitted panel JSON. 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.
Implements the three plan addenda on top of #1688, and corrects a doc claim that #1688 shipped and a live AG has since disproved.
Stacked on #1691 (still open at time of writing), so the diff shown here includes that PR's commits until it merges.
1. Four commit-time columns
ag_database_replica_statesgainslast_commit_time,last_hardened_time,last_redone_time,last_received_time— the four timestamps from the same DMV that the reference project's query skips. They matter because, unlikesecondary_lag_seconds, they are directly comparable across replicas: primary-vs-secondary commit-time lag is a subtraction between two rows rather than a number one replica self-reports.2. Two drain-time estimates, computed server-side
Three deliberate choices, each pinned by test:
* 1.0— without it this is BIGINT ÷ BIGINT, so a 0.4-minute drain floors to0, i.e. "already drained". Silent and wrong.NULLIF(rate, 0)— an idle, caught-up or suspended replica has rate 0. Without the guard that is a divide-by-zero that fails the entire collection cycle, not one column.CONVERT(float, ...)— the unconverted expression comes backnumeric, andGetDoublethrows on a decimal. This is the difference between a column that works and one that throws on every row with data.NULL means "no drain rate" (idle / suspended / caught up) and is never coerced to 0 — 0 would read as "drains instantly", the exact opposite of the truth.
Computed per row at the sample's own instant rather than composed later as a queue/rate ratio, because a ratio of two window aggregates (avg queue ÷ avg rate) is not the average of the per-sample ratios, and the two diverge worst precisely when rates swing — which is when someone is looking. Both are registered as Gauge compose measures (duration family, native minutes) so they chart directly.
3. Two primary-side perfmon counters
Transaction DelayandMirrored Write Transactions/secjoinPerfmonStatsCollector's existing whitelist. Their ratio is the average primary-side commit delay per mirrored transaction — the primary half of a picture theag_database_replica_statesgauges only cover from the secondary side. Zero new schema; they rideperfmon_stats.Verified live rather than assumed (SQL2022, no AGs configured):
Three things that check out: both counters exist even with Always On unconfigured, both sit on
SQLServer:Database Replica, and each occurs exactly once server-wide — so the collector'scounter_name-only filter genuinely cannot collide with another object's counter, which is the assumption the no-object_name-predicate design rests on.Per the addendum, no action needed on
HADR_SYNC_COMMIT— it already flows throughwait_stats. With these three pieces, sync-commit pressure is now composable end to end: primary-side delay, secondary-side queues and rates, drain estimates, and the wait itself.4. A doc claim from #1688, corrected against a live AG
@ag-fixture-builder validated both queries against a Docker AG fixture and found one of my doc comments contradicted by the instance. #1688 restated MS Learn faithfully:
The live instance (SQL Server 2022 16.0.4265.3,
CLUSTER_TYPE = NONE) does the inverse, measured across a 60-secondSUSPEND_FROM_USERon the primary's remote row under write load:It reads 0 when movement is active and caught up, and accrues monotonically once suspended. So the risk #1688 documented — a suspended replica hiding as zero lag and under-reporting the worst case — does not exist: a lag threshold fires on its own. Reading
is_suspendedalongside is still right, but to explain why lag is climbing, not to catch lag that is masked.The comment now describes measured behavior and flags the doc as unreliable, with the caveat that this is one build and a clusterless AG (WSFC untested). Two further measured facts are now documented in the collector:
log_send_queue_sizegoes NULL while suspended, whileredo_queue_sizefreezes at its last value — so a redo-queue reading on a suspended replica is stale, not current. (TheWritePayloadtest now models exactly this: a 62-second-suspended replica with a null send-drain estimate.)sys.dm_hadr_*carries only the local replica's rows there, so the INNER JOIN narrows to one row even thoughsys.availability_replicasholds every replica. A complete AG picture requires collecting from the primary.The compose-measure comment claiming the same doc behavior is corrected too. The suspension-state dimensions #1691 added stay — they are still the right call, now for the corrected reason.
Migration: V36, appended, not a V34 edit
The obvious shortcut — widen V34's
CREATE TABLEsince it is unreleased — is a trap.CREATE TABLE IF NOT EXISTSis a no-op on a store that already ran V34, so editing it would leave every already-migrated store (the field box included) six columns short while fresh installs got them. V36 doesALTER TABLE ... ADD COLUMN IF NOT EXISTS, which appends physically in the same orderPgSchemaGeneratoremits, so both provenances end up identical.That broke the shape pin I added in #1691 (V34's CREATE no longer equals the generator's output, correctly). Rather than weaken it, the pin now reconstructs: it generates the historical 15-column shape via a
TruncatedSchemawrapper and asserts V34 matches that exactly, then asserts V36 appends the remaining columns in order with the generator's own types. Together those prove fresh == upgraded.Both failure modes confirmed by planted defects, not assumed:
est_send_drain_time_min realinstead ofdouble precisionin V36 → red.last_commit_time→ red.Version 36 rather than 35 because 35 is claimed by the concurrent AG-alerts work (
pm-991a); I confirmed that directly and messaged @ag-alerts-builder so neither of us stalls on the other.THIRD_PARTY_NOTICES.md
Hannah Vernon's SqlServerAgMonitor now has its own section, following the file's existing per-component format (Author / Repository / License, usage sentence,
### License Textwith the MIT body pulled verbatim from the clone's LICENSE, and a full-license link), plus a line in Acknowledgments. The collector-header attribution from the original brief stays — both belong.Tests
New/extended: the drain-estimate guard pin (both expressions verbatim + the
CONVERT(float,wrapper), the widened payload-order and type pins,ReadAsyncmapping and null-tolerance for all six columns, the V36 migration identity pin, the V34+V36 reconstruction pin, the perfmon counter-list pin (59 → 61), and the AG measure count (5 → 7).Installer.Tests not run (live-DB categories clobber real Agent jobs on SQL2022).
Live smoke — SQL2022, no AGs
The widened query, extracted programmatically from the collector's
QueryTextconstant so it is provably what ships, run verbatim:All 21 columns resolve — including the two computed expressions, which is the real thing being checked here since a malformed
CONVERT/NULLIFwould fail at parse.🤖 Generated with Claude Code