Docker Availability Group test fixture, and AG collector validation (#991) - #1689
Merged
Conversation
…991) The AG DMVs return nothing on a standalone instance, so AG collection had nothing to be validated against. tools/ag-fixture stands up two SQL Server 2022 containers as a clusterless (CLUSTER_TYPE = NONE) availability group in one idempotent setup.ps1: shared endpoint certificate, mirroring endpoints on 5022, automatically-seeded AgFixtureDb, plus a write-load script to move the queues and rates and a documented suspend/resume fault. Both AG collector queries were run against it verbatim and validated, including the fault. Evidence is in tools/ag-fixture/VALIDATION.md. Three behaviors it surfaced, all recorded there: - A secondary reports only itself. sys.availability_replicas holds every replica on both nodes, but sys.dm_hadr_availability_replica_states and sys.dm_hadr_database_replica_states hold only the local one on a secondary, so the collectors' join narrows a secondary to a one-row self-view. A complete AG picture requires collecting from the primary. - log_send_queue_size reads NULL while data movement is suspended rather than growing, and redo_queue_size freezes at its last value. A send-queue threshold is therefore blind to a suspended secondary; is_suspended and suspend_reason_desc are the signal for that condition. - secondary_lag_seconds accrues while suspended - 0, 15, 31, 46, 62 across a 60 second suspend, back to 0 on resume. That is the inverse of MS Learn, which documents "this value shows as 0 if the data movement is suspended". It reads 0 when movement is active and caught up. Measured on 16.0.4265.3, clusterless AG only; a WSFC AG was not tested. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Most of this machine's hardware belongs to VMs, so the fixture is sized to coexist with them rather than to perform. - mem_limit 2g and cpus 1.0 per container, engine held to 1536 MB with MSSQL_MEMORY_LIMIT_MB. 2 GB is a floor, not a preference: SQL Server on Linux refuses to start below ~2000 MB. 1536 MB proved stable through a clean build, a seeded AG, write load, and a suspend/resume fault, so it was not stepped up to 2048. - Dropped MSSQL_AGENT_ENABLED. The AG needs no Agent jobs and it is another resident process per node. - Seed database created with explicit 64 MB files and 32 MB growth instead of inheriting model's defaults, which automatic seeding would otherwise reproduce on the secondary too. RECOVERY FULL is unchanged - an AG will not accept a SIMPLE database, so "small" here is file sizes only. - Write load drops to 200-row batches, truncates the table past 250k rows at the start of a run, and backs the log up periodically to a single overwritten file. That last one matters: because the database must stay in FULL recovery it cannot truncate its own log, and one 90 second run had previously taken the log to 1632 MB used and the data file to 1376 MB. With the trim the log holds at its initial 64 MB. Re-validated end to end at the new limits: AG builds clean and reaches HEALTHY/SYNCHRONIZED, both collector queries still return real rows with non-NULL queues and rates, and the suspend/resume findings reproduce exactly. No OOM kill or restart at any point. teardown.ps1 exercised for the first time and removes both containers, the network, and both volumes. Measured usage recorded in VALIDATION.md, and README gains a resource footprint section. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
# Conflicts: # CHANGELOG.md
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>
3 tasks
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.
Adds
tools/ag-fixture, a two-node SQL Server 2022 Availability Group in Docker, and uses it to validate the AG collector queries from #991.The AG DMVs return nothing at all on a standalone instance, so AG collection has never had anything to be validated against. This is that thing. It belongs in the release checklist next to the other install validation: any change to AG collection, AG alerting, or the AG topology views should be run against it before it ships.
What it is
A clusterless AG (
CLUSTER_TYPE = NONE, read-scale) - real replicas, real synchronization state, real send/redo queues, real suspend/resume, no WSFC or Pacemaker. What it does not get is a listener or automatic failover, so it is not the fixture for those.docker-compose.yml-ag1/ag2, HADR enabled, pinned hostnames, errorlog healthchecks copied from the shape insql-validation.yml. Primary onlocalhost,14331, secondary onlocalhost,14332.setup.ps1- one idempotent orchestrator. Waits for health, then for logins, runs the numberedsql/scripts through each container's own sqlcmd, waits for the seed to land, prints the state. A full re-run against a live fixture is a no-op.sql/01-07- master key and shared endpoint certificate, certificate transport to ag2, mirroring endpoints on 5022,CREATE AVAILABILITY GROUP, join plusGRANT CREATE ANY DATABASE, seededAgFixtureDb, verification.write-load.ps1/sql/90_write_load.sql- insert loop so the queues and rates move off zero.teardown.ps1-compose down -v.README.md- purpose, start/stop, port map, pointing Lite/Darling at it, the suspend/resume fault recipe, a troubleshooting section, and the warning thatsaand the passwords are fixture-local.Passwords come from a gitignored
.env;.env.exampleships placeholders, so no working password lands in the repo.Validation
Both collector queries from
feature/991-ag-health-collector@530b3b66were run verbatim against both replicas, under write load, before and after a suspend fault. Full evidence with row output is intools/ag-fixture/VALIDATION.md.setup.ps1idempotent (full re-run against live fixture)is_suspended+SUSPEND_FROM_USER, resume clearsThe collector queries are correct and need no changes. But the fixture turned up three behaviors worth knowing about, and one of them contradicts the documentation.
1. A secondary reports only itself
The catalog view knows both replicas on both nodes; the two DMVs carry only the local replica on a secondary, so the collectors' inner join narrows a secondary to a one-row self-view. A clusterless AG gives a secondary no cluster metadata store from which to learn its partners' runtime state.
So a complete AG picture requires collecting from the primary, the replica that returns the full set changes after a failover, and anything assuming "one row per replica per AG from any monitored node" is wrong.
2.
log_send_queue_sizereads NULL while suspendedSampled on the primary's remote row across a 60-second suspend, write load running:
The send queue does not grow while movement is suspended - it reads NULL - and
redo_queue_sizefreezes rather than climbing. A "send queue over N KB" rule is blind to a suspended secondary, which is one of the most common ways a secondary falls behind;is_suspended/suspend_reason_descare the signal for that. NULL must not compare as zero-and-healthy. The 388,620 KB in the last row is legitimate post-resume catch-up that a single-sample redo threshold would fire on.3.
secondary_lag_secondsaccrues while suspended, contradicting MS LearnMS Learn: "This value shows as
0if the data movement is suspended. The data movement needs to be in a non-suspended state in order for this value to show active lag."Observed, same suspend:
It accrues monotonically with suspension time - the inverse of the documented behavior, reading 0 when movement is active and caught up. The blind spot the docs imply does not exist: a suspended secondary reports growing lag and a lag threshold fires on it unaided.
This one matters beyond the fixture: the
AgDatabaseReplicaStatesCollectordoc comment currently restates the MS Learn sentence as fact and reasons from it ("a suspended replica presents as zero lag ... or it will under-report the worst case"). Faithful to the docs, but contradicted by the hardware. Passed to the collector and alerts authors rather than edited here, since those files belong to other branches in flight.Scope of the claim: one build (
16.0.4265.3), clusterless AG only. A WSFC AG was not tested and this fixture cannot test one.Resource footprint
The fixture is capped so it can share a machine with a busy VM fleet: 1 CPU and 2 GB per container, engine held to 1536 MB via
MSSQL_MEMORY_LIMIT_MB, no SQL Agent, 64 MB database files.mem_limitcannot go below 2 GB - SQL Server on Linux refuses to start under ~2000 MB - soMSSQL_MEMORY_LIMIT_MBis the knob that actually lowers the engine's appetite inside it. 1536 MB proved stable through a clean build, seeding, write load, and a suspend/resume fault, so it was not stepped up to 2048. Measured, not asserted:No OOM kill or restart at any point. Memory settles near 90% of the cap and stays there, which is expected - SQL Server does not release the buffer pool, and
MSSQL_MEMORY_LIMIT_MBgoverns the engine's budget rather than the container's total RSS, so ~250 MB of process overhead sits on top of the 1536 MB.One thing this surfaced that is worth calling out: an AG database must stay in
RECOVERY FULL, so it cannot truncate its own log, and the write-load script was taking the log to 1632 MB used (and the data file to 1376 MB) in a single 90-second run. The script now backs the log up periodically to one overwritten file, and the log holds at its initial 64 MB across the same workload. That was not in the brief; unbounded log growth on a fixture meant to be left running defeats the point of capping anything else.Everything was re-validated at the new limits: AG builds clean to HEALTHY/SYNCHRONIZED, both collector queries still return real rows with non-NULL queues and rates, and findings 1-3 reproduce exactly (they are DMV semantics, not resource effects).
teardown.ps1is also now exercised - it was the one untested path before - and removes both containers, the network, and both volumes.Follow-on: finding 3 reconciled, and one comment corrected
The lag finding got sharper. @ag-alerts-builder reproduced the accrual independently on this fixture but measured it starting near 3993 where I measured 0. Their
redo_queue_sizewas 0 throughout - an idle AG, where mine was under write load. Re-testing the idle case explicitly reconciles both:While movement is active it reads 0 no matter how long the group has been idle. Once suspended it latches onto roughly how stale the secondary's last hardened log is (
now - last_hardened_time), not time since suspension - so it starts near zero under load and at thousands of seconds when idle. Same behavior, different starting point. It also does not latch immediately: at +15s suspended it still read 0 while alreadyNOT SYNCHRONIZING. VALIDATION.md now carries all of this.This was not academic. The alert family (#1692) had shipped a lag rule that abstained on a suspended row, written specifically to compensate for the MS Learn sentence - which, the docs being inverted, silenced the lag alert on suspended data movement. That is fixed in #1700.
Since #1688 merged to dev with the collector's doc comment unchanged, that comment was going to ship this week still asserting the false behavior and reasoning from it. I corrected it here (
5f7a20b8): it now states the measured behavior, the idle-vs-loaded base, the non-immediate latch, and the operative rule - a suspended row may raise a lag alarm but must never clear one, and the magnitude is staleness, not volume at risk. Comment only, no behavior change; the query and column set were already correct and validated verbatim. Builds clean, 0 warnings. @ag-collector-builder has been told so nobody duplicates it, and the two additive doc points from my review (the one-row self-view, the NULL send queue) were deliberately left to them.Erik merged
origin/devinto this branch mid-flight; CHANGELOG entries and link-refs from both sides survived intact, no conflict markers.Judgment calls
dboon both sides anddboin master maps tosa, so no separate endpoint login orGRANT CONNECT ON ENDPOINTis needed. This is the form Microsoft documents for read-scale AGs on Linux, and the extra login the brief mentioned would have been dead weight. Validated working end to end.SYNCHRONOUS_COMMIT. It is what drivesSYNCHRONIZED/HEALTHY, which is the state the collectors are being checked against.REQUIRED_SYNCHRONIZED_SECONDARIES_TO_COMMITstays at its default of 0 so stopping ag2 does not freeze writes on ag1./var/opt/mssql/data. SQL Server on Linux has no/var/opt/mssql/backupby default, and adding a volume for it would only be one more thing to clean up.setup.ps1andteardown.ps1prepend Docker's bin directory toPATH. A default Docker Desktop install puts none of it onPATH, anddockershells out todocker-credential-desktop.exeby name - without this,compose updies on "error getting credentials" before pulling anything. Hit this on the first run.docker cpoutput is chowned tomssql. Files landed bydocker cpare root-owned and SQL Server cannot read them;CREATE CERTIFICATEthen fails with a misleading "cannot find the file".Test plan
setup.ps1from nothing: containers healthy, AG built,AgFixtureDbseeded to ag2, verification shows PRIMARY + SECONDARY / CONNECTED / HEALTHY.setup.ps1 -SkipComposere-run against the live fixture: no errors, same output - idempotency guards hold.write-load.ps1: 5,270 batches / 2,635,000 rows in 130s; queues and rates non-NULL during it.is_suspended = 1,suspend_reason_desc = SUSPEND_FROM_USER,synchronization_state_desc = NOT SYNCHRONIZINGfrom both vantage points; replica grain flipsHEALTHY->NOT_HEALTHY. Resume clears all of it.secondary_lag_secondssampled across a 60s suspend to confirm finding 3 is monotonic accrual and not a stale read..envconfirmed gitignored and absent from the commit.teardown.ps1: removes both containers, the network, and both volumes, and nothing else - the compose project scope is exactlyag-fixture/ag1/ag2.Containers are up now: ag1
localhost,14331, ag2localhost,14332, sa password in the local.env.Closes nothing on its own; supports #991.
🤖 Generated with Claude Code