Skip to content

Availability Group health collection in Lite + Darling (#991) - #1688

Merged
erikdarlingdata merged 4 commits into
devfrom
feature/991-ag-health-collector
Jul 26, 2026
Merged

Availability Group health collection in Lite + Darling (#991)#1688
erikdarlingdata merged 4 commits into
devfrom
feature/991-ag-health-collector

Conversation

@erikdarlingdata

Copy link
Copy Markdown
Owner

Closes #991, and the AG item of #1606 (the one real coverage gap the Datadog DBM comparison turned up).

Scope is collection only, per the design: two tables, both apps, compose measures. No viewer tab and no alerts in this cut.

What lands

Two shared collectors in PerformanceMonitor.Collectors, both server-scope, one query per sweep:

Collector Grain Source
ag_replica_states one row per replica per AG sys.availability_replicas + sys.availability_groups + sys.dm_hadr_availability_replica_states
ag_database_replica_states one row per database per replica sys.dm_hadr_database_replica_states + the two catalog views + sys.databases

The metric surface follows Hannah Vernon's SqlServerAgMonitor (MIT, (c) 2026 Hannah Vernon), attributed in both collector header comments. WHERE COALESCE(ag.is_distributed, 0) = 0 keeps distributed-AG container rows out while member AGs still appear; DAG member drill-through needs a connection per member and is out of scope.

Two collector classes rather than one, because the catalog idiom is strictly one table per collector (TargetTable is a single string, PayloadColumns a single list). Git history backs that reading: latch_stats/spinlock_stats and cpu_scheduler_stats/plan_cache_stats each shipped as a related pair of classes in one commit.

Gating

AppliesTo is !target.IsAzureSqlDb -- the same idiom server_config and trace_flags use -- which gives on-prem + Managed Instance + RDS and excludes Azure SQL DB. An AG-less server is deliberately not gated off: it collects and stores zero rows, so turning Always On on later starts producing data with no configuration change.

One thing worth flagging: MS Learn stamps sys.dm_hadr_database_replica_states as SQL Server + Managed Instance, but stamps the two catalog views as SQL Server only. I followed the instructed gate (MI included) since MI does expose the AG objects; if an MI target ever errors on the catalog views, that gate is the place to narrow.

Three traps, each pinned by test

  • The queues, rates and lag are instantaneous gauges, not counters. The DMV recomputes each from the current backlog, so neither collector touches the delta framework, and the compose measures are Gauge (avg/min/max, never SUM). WritePayload_EmitsPayloadOrder_AndTakesNoDeltas asserts the delta calculator is never called.
  • last_hardened_lsn / last_commit_lsn are numeric(25, 0) -- wider than BIGINT -- so they convert server-side and store as text. Storing them numerically would silently overflow. (Only last_commit_lsn is a real LSN; the other is a zero-padded log-block id, so neither is safe for arithmetic. Deriving a byte distance is analysis, left to a reader.)
  • Every column is read null-tolerantly. Under WSFC quorum loss sys.availability_replicas serves only locally cached metadata, and endpoint_url is documented NULL in that state. Both ToleratesNulls tests cover an all-NULL row.

A fourth worth recording for whoever builds the alerts: secondary_lag_seconds reads 0, not NULL, while data movement is suspended, so a suspended replica charts as zero lag. is_suspended is collected alongside it precisely so a reader can tell the two apart. Documented in the collector.

Registrations

  • Catalog: both in CollectorCatalog.All + CollectorScheduleDefaults (1 min / 30 days, default ON, in all three cadence presets in both apps).
  • Lite: dispatch case + a RemoteCollectorService.AvailabilityGroups.cs partial, ScheduleManager defaults, bundled collection_schedule.json. The "three registrations" (Schema.cs / DuckDbInitializer / ArchiveService) no longer exist as separate steps -- all three now enumerate CollectorCatalog.All, so catalog registration is the whole storage wiring. Verified against long_query_completions, the newest collector, which touches none of those files.
  • Darling: DarlingWorker dispatch, store migration V34 + StorageVersion bump, viewer schedule presets.

The V34 migration needed a viewer change too

ViewerDataService.MapProbedSchemaVersion is a newest-first sentinel ladder, and the connect-time gate refuses a store below RequiredStoreSchemaVersion. Bumping StorageVersion without adding a matching sentinel + arm would have made a fully-migrated V34 store probe as v33 and permanently refuse to open the viewer. Added both (table-existence sentinel on the database-grain table); the existing pinning test caught it, which is what it is there for.

Compose

Five Gauge measures on ag_database_replica_states under a new Availability Groups category: send queue and redo queue (bytes family, native KB, default Max -- the worst backlog in the bucket is the signal), send rate and redo rate (bytes family, native KB, default Avg), and secondary lag (duration family, native s, default Max). Dimensions: ag_name, database_name, replica_server_name, plus the universal server.

The rates are KB/second but the catalog has no per-second unit family, so per the instructions they ride the bytes family -- correct under conversion, since kb/s to mb/s scales by the same factor -- with the per-second nature stated in the display name rather than implied by a unit the picker renders as a plain size.

The replica-grain table contributes no measures: it is all state strings with nothing numeric to aggregate.

A coverage-pin bug found and fixed along the way

Both apps' collector-coverage ratchets text-scan the viewer's reader layer for a table name. Naming a collector table as a schema-version probe sentinel (as V34 does, and as V29 already did for long_query_completions) made that table read as "already covered" and silently exempted it from the ratchet. The Darling scan now strips the probe's information_schema lines before matching -- those are DDL existence checks, never data reads, and information_schema appears nowhere else in that layer. That retroactively hardens the pin for long_query_completions too.

Both tables are listed as tracked debt in both ratchets, with the follow-up named.

Tests

Extended every pin the change touches (catalog counts, golden DuckDB schema + indexes, Lite/Darling preset sets, migration ladder, PG generator counts, schema-probe map) and added:

  • Lite.Tests/AgCollectorDefinitionTests.cs -- 13 tests: verbatim query text for both, DAG filter, target gate across all four platforms, no per-database run, payload order + declared types, ReadAsync mapping, null tolerance, zero-rows-is-not-an-error, payload emission with no delta calls, catalog + schedule registration, and by-name gate agreement.
  • DarlingComposeTests -- 4 new tests (all five measures Gauge/non-summable/right category, native units + default aggregates, the three dimensions, the lag panel compiling and grouping by replica) plus an InlineData row proving SUM on an AG gauge is rejected.
Lite.Tests      1494 passed, 0 failed
Darling.Tests   3152 passed, 0 failed (149 skipped - the live-Postgres gated set)
Solution build  0 warnings, 0 errors

Installer.Tests was not run (live-DB categories clobber real Agent jobs on SQL2022).

Live smoke -- SQL2022, no AGs

Ran both collector queries verbatim against sql2022 (SQL2022, 16.0.4255.1, IsHadrEnabled = 0). Both parse, execute, and return zero rows with the full expected column set -- the normal AG-less path, proving it is a clean empty read and not an error:

=== ag_replica_states ===
ag_name  replica_server_name  role_desc  operational_state_desc  connected_state_desc  recovery_health_desc
synchronization_health_desc  availability_mode_desc  failover_mode_desc  endpoint_url
(0 rows affected)
EXIT=0

=== ag_database_replica_states ===
ag_name  database_name  replica_server_name  is_local  synchronization_state_desc  last_hardened_lsn
last_commit_lsn  log_send_queue_size  redo_queue_size  log_send_rate  redo_rate  is_suspended
suspend_reason_desc  availability_mode_desc  secondary_lag_seconds
(0 rows affected)
EXIT=0

The smoked SQL was diffed against the QueryText constants in both collector sources and is byte-identical, so this exercised exactly what ships -- not a hand-retyped approximation.

Follow-ups (deliberately not here)

An Availability Groups viewer tab, and the failover-detected / sync-fell-behind alerts. Both ratchets will go red when the tab ships, forcing the allow-list entries out.

🤖 Generated with Claude Code

erikdarlingdata and others added 2 commits July 26, 2026 12:52
Two new shared collectors, both server-scope, both zero-cost on a server
without Always On:

- ag_replica_states (replica grain): role, operational/connected state,
  recovery and synchronization health, availability and failover mode,
  endpoint URL.
- ag_database_replica_states (database grain): synchronization state,
  log send + redo queue sizes, send + redo rates, secondary lag,
  suspension state/reason, and both LSNs.

Metric surface follows Hannah Vernon's SqlServerAgMonitor (MIT),
attributed in both collector headers. The is_distributed = 0 filter
keeps distributed-AG container rows out while member AGs still appear.

Runs everywhere except Azure SQL DB (no AG surface there). An AG-less
on-prem server is deliberately NOT gated off - it collects and stores
zero rows, so enabling Always On later starts producing data with no
configuration change.

Three traps, each pinned by test:
- The queues, rates and lag are instantaneous gauges, not counters, so
  neither collector touches the delta framework and the compose measures
  are Gauge (avg/min/max, never SUM).
- last_hardened_lsn / last_commit_lsn are numeric(25,0) - wider than
  BIGINT - so they convert server-side and store as text.
- Every column is read null-tolerantly: under WSFC quorum loss
  sys.availability_replicas serves only cached metadata and endpoint_url
  is documented NULL.

Per-minute cadence, 30-day retention, in all three presets in both apps.
Custom Views v2 gets five Gauge measures on the database grain under an
Availability Groups category, sliceable by AG / database / replica plus
the universal server dimension. Darling store migration V34 (with the
matching viewer schema-probe sentinel and map arm, without which the
connect-time gate would refuse a fully-migrated store); Lite's storage
registers itself off the collector catalog.

Collection only in this cut - no viewer tab, no failover / sync alerts -
so both apps' viewer-coverage ratchets carry the two tables as tracked
debt. That ratchet also gets a real fix: it text-scans the reader layer
for a table name, so naming a collector table as a schema-probe sentinel
made it read as "already covered". The scan now strips the probe's
information_schema lines, which retroactively hardens the same pin for
long_query_completions.

Closes #991

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
erikdarlingdata and others added 2 commits July 26, 2026 12:55
The main README's Lite collector table now lists ag_replica_states and
ag_database_replica_states, and its count goes 36 -> 38 (verified against
CollectorCatalog.All: both are 38).

Darling's README said 32 collector definitions, already stale before this
change; corrected to 38. Its example store-migrate log line still showed
schema v20, now v34.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@erikdarlingdata
erikdarlingdata merged commit 3b37bad into dev Jul 26, 2026
4 checks passed
erikdarlingdata added a commit that referenced this pull request Jul 26, 2026
Lite.Tests/CrossAppMcpToolInventoryPinTests scans BOTH apps' Mcp/*.cs and asserts
Darling's tool set is a superset of Lite's, so a new Darling tool with no Lite twin
fails the CI build job. A Darling-only local build never runs that project, so this
would only have surfaced in CI.

Flagged as a real "not ported yet", unlike the Custom Views / alert-tuning /
onboarding entries that are Darling-only by architecture: the AG collectors landed
in BOTH apps (#1688), so Lite already has the two tables in its local DuckDB and a
twin is a DuckDB reader over the same banding rules.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@erikdarlingdata
erikdarlingdata deleted the feature/991-ag-health-collector branch July 26, 2026 17:50
erikdarlingdata added a commit that referenced this pull request Jul 26, 2026
One Added entry for the Availability Group alert family, three Fixed entries
for the adjacent drift (the resolution-suffix misclassification in both apps,
the MCP alert-settings under-report, and the parallel-collection test flake),
plus the link-ref. Also corrects the clause #1688 left behind saying the
failover and sync-fell-behind alerts were still follow-ups.

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
…rap filterable, pin V34 for real

Review follow-ups to erikdarlingdata#1688.

The grant is the finding that matters in the field. Both AG collectors join
the sys.availability_groups / sys.availability_replicas CATALOG VIEWS to the
sys.dm_hadr_* DMVs. The DMVs are covered by the VIEW SERVER STATE the product
documents, but the catalog views require VIEW ANY DEFINITION - and catalog
views enforce that by HIDING ROWS, not by raising an error. So on a real AG
cluster a login with only the documented grant returned zero rows, which is
byte-identical to what an AG-less server returns: the collectors would look
healthy forever while collecting nothing, with no error to notice.

Documented in both READMEs (grant script + the Darling permission table's
If-missing column) and in both collector headers, together with the
fingerprint that identifies it if it is ever worth auto-detecting: the DMV
returning rows while the catalog view returns none is unambiguous, and
SERVERPROPERTY('IsHadrEnabled') is readable by every login. Verified against
MS Learn's own AG monitoring page, which states the split directly.

The documented lag trap is now actionable rather than merely documented.
secondary_lag_seconds reads 0 (not NULL) while data movement is suspended, so
a suspended replica charts as healthy - but nothing exposed the suspension
state to a panel, making the misread unavoidable. synchronization_state_desc
and suspend_reason_desc are now compose dimensions, so a lag panel can filter
suspended replicas out or group by suspend reason. is_suspended itself cannot
be a dimension: the compiler binds filter values as text, which would not
match a boolean column.

And V34 is now genuinely pinned. Its test asserted only that each column NAME
appeared somewhere in the DDL, so a V34 with columns reordered, is_local typed
text, or a spurious NOT NULL passed - while a comment claimed the shape was
pinned elsewhere. It now compares the whole generated CREATE TABLE via the
existing Migrations_JobHistoryAndAgentStatus_MatchGeneratedFreshShape idiom;
detection power confirmed by planting is_local text and watching it go red.

That test's rationale was also wrong and is corrected: it blamed a "positional
binary COPY", but PgCollectorRowWriter.CopyCommandFor emits a named column
list, so Postgres binds by name. Names and types are the real hazard there;
the positional appender is Lite's.

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
…erfmon counters (erikdarlingdata#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 erikdarlingdata#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>
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>
pull Bot pushed a commit to ehtick/PerformanceMonitor that referenced this pull request Jul 29, 2026
…arlingdata#1696)

Lite has collected both AG grains since erikdarlingdata#1688 but could not tell you a replica
had failed over, disconnected, fallen behind, or had data movement suspended -
only Darling could. This closes that, and does it by making the RULES shared
rather than by writing a second implementation that would drift.

PerformanceMonitor.Common.AgAlertPolicy is the new single definition, on the
ConnectionAlertPolicy pattern: the readings, the four metric-name consts, and
every pure decision (IsFailover, DecideConnection, DecideSuspension, JudgeSync)
live there; each app owns only its own edge STATE and its own delivery. The
metric names are webhook automation keys, so an operator's webhook keyed on
"AG Failover" now matches whether the alert came from Lite or from Darling.

Darling's evaluator is refactored onto it with no behavior change - its full
suite passes untouched, which is the point of doing the lift as its own step.

Lite side:
- LocalDataService reads the latest snapshot of each grain from DuckDB, each
  gated on its OWN collection time, because the two AG collectors are scheduled
  independently and a stale database-grain snapshot must not be vouched for by a
  healthy replica-grain one.
- AgAlertEvaluator holds the per-grain edge state and returns alert descriptors.
  It is WPF-free so it pins directly; MainWindow does the sending through the
  same mute-check + TrySendAlertEmailAsync path every other Lite alert uses, so
  AG alerts inherit muting, silencing, the combined history row, and the
  email/webhook fan-out for free.
- Three settings mirroring Darling's V35 knobs (notify_ag_health default on,
  ag_lag_alert_seconds 300, ag_redo_queue_alert_kb 0 = off), clamped to the same
  ranges on load AND on save so the stored value and the effective value cannot
  disagree, with the twin controls in Lite's Settings window.
- Forget on server removal, or a remove-then-re-add would compare the new first
  sighting against the OLD role and page a phantom failover. The storage name
  hashes deterministically, so a re-added server really does get the same id.

Every rule the earlier AG work paid for is carried over rather than reinvented:
first sighting is a silent baseline, NULL is never a transition, and a SUSPENDED
row may raise an alarm but may never clear one - including the case that a
suspended secondary drifting past the threshold still fires, which is the single
most common way a secondary falls behind.

The Lite collector-coverage ratchet went red on this, exactly as designed: both
AG tables were allow-listed as collect-only, adding a reader made them "actually
read", and the entries had to be drained. The allow-list is now EMPTY - left in
place rather than deleted, so re-adding an entry stays a deliberate visible act.

Twin AgAlertPolicyTests in Lite.Tests and Darling.Tests (duplicated verbatim,
the ConnectionAlertPolicyTests arrangement) pin the shared matrix from both
suites; AgAlertEvaluatorTests pins Lite's state, cooldown, recovery and Forget,
including that server 42 and server 4242 cannot collide on the key prefix.

Suites green: Lite 1534, Darling 3286, Dashboard 768.

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