Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- **Availability Groups tab in the WPF viewer** ([#1722]) - the AG topology on the client's primary surface, completing #991's reader half. The desktop twin of the web dashboard's Availability Groups page: one card per AG with per-replica chips (role, connected / operational state, synchronization health, availability + failover mode, endpoint on hover) over a database grid carrying synchronization state, log-send and redo queue sizes, send / redo rates, secondary lag, derived drain estimates, and the suspend reason when data movement has stopped. Every severity is computed in the reader and the XAML only binds the brush it produced, so the tab cannot drift from the web page's verdict. **One card per (reporting server, AG), deliberately not merged** - the same rule the web page follows, and the live AG fixture shows exactly why: the primary reports both replicas and names the primary, while the secondary reports ONE replica and no primary at all, because `sys.dm_hadr_availability_replica_states` returns only local information when queried on a secondary. Collapsing those two views would let the blind one overwrite the complete one, so the header states groups, reporting servers and views as three separate counts rather than leaving a reader to wonder why one AG name appears twice. The tab ships hidden and reveals itself once a sweep finds AG rows (Always On is opt-in; most fleets would otherwise carry a permanently empty tab), converging without a restart and never vanishing again mid-look if a later sweep reads zero. The reader is a COPY of the service's `DarlingAgReader` rather than a reference - the viewer has no ProjectReference to the headless service - so the banding rules are duplicated deliberately and pinned by tests on both sides to keep them honest; the viewer's copy reads bare table names (search_path resolves `collect`) and joins the enabled registry so a disabled server's AGs leave with it. This also retires the last two entries in the viewer-coverage ratchet: `ag_replica_states` and `ag_database_replica_states` were carried as tracked debt when #991 shipped collection-only, and the pin that tracked them is the same pin that now certifies the surface exists. Lite gets no AG tab in this pass - it is a single-server app and the fleet view is Darling's job.
- **The bundled PostgreSQL runtime now proves its own version instead of being taken at its word** ([#1713]) - `fetch-pg-runtime.ps1` has pinned PostgreSQL 18.4 + TimescaleDB 2.28.1-for-PG18 since #1690, but the `pg-runtime.zip` sitting beside it was assembled from the 17.10 download **before** those pins were bumped, and nothing anywhere compared the two - so a bundle everyone described as PG 18 booted `starting PostgreSQL 17.10` in the field, and inspection could never have caught it (same filename, same layout, same size to the nearest tens of MB). Two new guards close that gap from both sides: an **ungated** test parses the real fetch script (delivered beside the test binary by Link+copy, so it can never read a stale second copy) and pins that `$pgVersion`'s major is the intended 18, that `$pgUrl` actually names `$pgVersion` (they drifted apart once already - a "18.4" script fed a 17.10 bundle), and that `$tsUrl` targets the matching PostgreSQL major and TimescaleDB version, catching a half-finished pin bump on the pull request that introduces it; and a **DARLING_TEST_PGRUNTIME-gated** test asks `pg_ctl.exe --version` what the *assembled* runtime really is and requires it to equal both the script's pin and the intended major, plus requires `timescaledb--<pinned>.sql` and `timescaledb-<pinned>.dll` to be present so a stale TimescaleDB payload (the #1705 class of drift) fails the same way. The gated half runs wherever the runtime is already extracted - CI's darling-pg job and nightly both set the variable - and it is `--version`, not a boot, so it costs milliseconds. Verified in both directions: green against a freshly assembled 18.4 tree, and red (`Expected: 18, Actual: 17`) against the exact stale artifact that shipped. **No runtime, packaging, or configuration behavior changes** - this is the pin that makes the version claim self-verifying, not the version move itself. Also recorded for the record, because the issue's premise did not survive contact: **zstd is not a TOAST compression method in PostgreSQL 18 or 19** (`default_toast_compression` accepts `pglz` and `lz4` only; the server rejects `zstd` outright), so the "better blob compression" driver behind the 18 move does not exist - and `default_toast_compression = lz4`, the strongest codec PostgreSQL does offer, has been in the v1 conf block all along, measuring 10.84% smaller than pglz on 85 real plan-XML documents

- **AG fixture evidence: state the commit-time conclusion the guidance rests on** ([#1709]) - [#1708] corrected the AG lag guidance to "do not derive lag from commit times at all", and cites `tools/ag-fixture/VALIDATION.md`. The file carried both halves of the argument but in separate sections, never drawn together - so the reader who found one half could reasonably conclude the other case was safe to guard against. It now states them side by side: on a **suspended** replica `last_commit_time` freezes, so `now - last_commit_time` stops growing exactly when replication stops (**silent**, understating at the moment it is worst); on a **healthy but quiet** database nothing commits, so the same delta grows without bound (**loud**, measured 1757 seconds at zero real lag). Guarding only the suspended case does not make a commit-delta trigger safe, it converts a silent failure into a noisy one - and the loud half is the likelier to ship, because it appears the first time anyone points it at a database nobody is writing to, whereas the silent half needs someone to suspend something. Evidence only; no behavior change.
Expand Down Expand Up @@ -1694,6 +1695,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
[#1713]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1713
[#1711]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1711
[#1719]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1719
[#1722]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1722
[#1709]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1709
[#1702]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1702
[#1697]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1697
Expand Down
283 changes: 283 additions & 0 deletions Darling/Darling.Tests/ViewerAvailabilityGroupsTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,283 @@
/*
* Copyright (c) 2026 Erik Darling, Darling Data LLC
*
* This file is part of the SQL Server Performance Monitor.
*
* Licensed under the MIT License. See LICENSE file in the project root for full license information.
*/

using System;
using System.Linq;
using PerformanceMonitor.Common;
using PerformanceMonitor.Darling.Viewer;
using Xunit;

namespace Darling.Tests;

/// <summary>
/// The viewer's Availability Groups tab (#991): the read SQL's dialect and latest-snapshot shape, the banding
/// rules, and the pure card projection. Ungated — the projection half is separated from the Postgres half exactly
/// so the rules test without a store, mirroring FleetViewTests.
///
/// <para>These deliberately duplicate <c>DarlingAgReaderTests</c>' expectations rather than sharing them: the
/// viewer reader is a COPY of the service reader (no ProjectReference exists), so the pins are what keep the two
/// from drifting into disagreeing about whether an AG is healthy.</para>
/// </summary>
public sealed class ViewerAvailabilityGroupsTests
{
/* ─────────────────────────── SQL pins ─────────────────────────── */

[Fact]
public void AgSql_IsPostgresDialect_AndReadsTheBareCollectTables()
{
foreach (var sql in new[] { ViewerDataService.AgReplicaStatesSql, ViewerDataService.AgDatabaseReplicaStatesSql })
{
/* The viewer's store connection resolves `collect` through search_path, so these must NOT be
schema-qualified the way the service reader's copies are. */
Assert.DoesNotContain("collect.", sql, StringComparison.Ordinal);
Assert.DoesNotContain("@", sql, StringComparison.Ordinal);
Assert.DoesNotContain("N'", sql, StringComparison.Ordinal);
}

Assert.Contains("FROM ag_replica_states", ViewerDataService.AgReplicaStatesSql, StringComparison.Ordinal);
Assert.Contains("FROM ag_database_replica_states", ViewerDataService.AgDatabaseReplicaStatesSql, StringComparison.Ordinal);
}

[Fact]
public void AgSql_KeepsEveryRowAtEachServersNewestCollection()
{
/* DISTINCT ON would keep ONE row per server, which is exactly wrong: a snapshot is many rows (one per
replica / per database). The join against MAX(collection_time) keeps them all. */
foreach (var sql in new[] { ViewerDataService.AgReplicaStatesSql, ViewerDataService.AgDatabaseReplicaStatesSql })
{
Assert.Contains("MAX(collection_time)", sql, StringComparison.Ordinal);
Assert.Contains("GROUP BY server_id", sql, StringComparison.Ordinal);
Assert.DoesNotContain("DISTINCT ON", sql, StringComparison.Ordinal);
}
}

[Fact]
public void AgSql_RestrictsToTheEnabledServerRegistry()
{
/* A server disabled in the control plane leaves the fleet surfaces at once; its AG cards go with it
rather than lingering until retention expires the rows. */
foreach (var sql in new[] { ViewerDataService.AgReplicaStatesSql, ViewerDataService.AgDatabaseReplicaStatesSql })
{
Assert.Contains("JOIN servers AS s", sql, StringComparison.Ordinal);
Assert.Contains("s.is_enabled", sql, StringComparison.Ordinal);
}
}

/* ─────────────────────────── banding ─────────────────────────── */

[Theory]
[InlineData("HEALTHY", HealthSeverity.Healthy)]
[InlineData("PARTIALLY_HEALTHY", HealthSeverity.Warning)]
[InlineData("NOT_HEALTHY", HealthSeverity.Critical)]
[InlineData(null, HealthSeverity.Unknown)]
public void SynchronizationHealth_BandsHealthyPartiallyNot(string? desc, HealthSeverity expected)
{
Assert.Equal(expected, ViewerDataService.AgSynchronizationHealthSeverity(desc));
}

[Fact]
public void OperationalState_NullBandsUnknown_AndDoesNotClaimRecoveryHealthsValue()
{
/* operational_state_desc is reported for the LOCAL replica only, so every remote replica's row carries
NULL — banding that Critical would paint a healthy AG red from every secondary's perspective. And
ONLINE_IN_PROGRESS belongs to recovery_health_desc, not this column. */
Assert.Equal(HealthSeverity.Unknown, ViewerDataService.AgOperationalStateSeverity(null));
Assert.Equal(HealthSeverity.Unknown, ViewerDataService.AgOperationalStateSeverity("ONLINE_IN_PROGRESS"));
Assert.Equal(HealthSeverity.Warning, ViewerDataService.AgRecoveryHealthSeverity("ONLINE_IN_PROGRESS"));
}

[Fact]
public void DatabaseSync_SynchronizingIsHealthyOnAsync_WarningOnSync()
{
/* The load-bearing distinction: an ASYNCHRONOUS_COMMIT replica never reaches SYNCHRONIZED, so
SYNCHRONIZING is its correct steady state; on SYNCHRONOUS_COMMIT the same text means the replica is
not currently protecting a commit. A flat state map necessarily gets one of these wrong. */
Assert.Equal(HealthSeverity.Healthy, ViewerDataService.AgDatabaseSyncSeverity("SYNCHRONIZING", "ASYNCHRONOUS_COMMIT", false));
Assert.Equal(HealthSeverity.Warning, ViewerDataService.AgDatabaseSyncSeverity("SYNCHRONIZING", "SYNCHRONOUS_COMMIT", false));
}

[Theory]
[InlineData("NOT SYNCHRONIZING")]
[InlineData("NOT_SYNCHRONIZING")]
public void DatabaseSync_AcceptsBothSpellingsOfNotSynchronizing(string state)
{
/* The database grain reports states space-separated where the replica grain uses underscores; both are
stored verbatim, so the banding must normalize. */
Assert.Equal(HealthSeverity.Critical, ViewerDataService.AgDatabaseSyncSeverity(state, "SYNCHRONOUS_COMMIT", false));
}

[Fact]
public void DatabaseSync_SuspendedIsCritical_EvenWhenTheStateReadsSynchronized()
{
/* secondary_lag_seconds reads 0 — not null — while data movement is suspended, so a suspended replica
otherwise presents as perfectly caught up. Suspension outranks the state text. */
Assert.Equal(HealthSeverity.Critical, ViewerDataService.AgDatabaseSyncSeverity("SYNCHRONIZED", "SYNCHRONOUS_COMMIT", true));
}

[Fact]
public void DrainMinutes_EmptyQueueIsZero_StalledQueueHasNoEstimate()
{
Assert.Equal<double?>(0, ViewerDataService.AgDrainMinutes(0, 0));
Assert.Equal<double?>(1.0, ViewerDataService.AgDrainMinutes(6000, 100));
Assert.Null(ViewerDataService.AgDrainMinutes(5000, 0));
Assert.Null(ViewerDataService.AgDrainMinutes(null, 100));
}

/* ─────────────────────────── card projection ─────────────────────────── */

private static ViewerAgReplicaRow Replica(
int serverId, string serverName, string agName, string replicaName, string role,
string syncHealth = "HEALTHY", string? connected = "CONNECTED", string? operational = "ONLINE") =>
new()
{
ServerId = serverId,
ServerName = serverName,
CollectionTime = new DateTime(2026, 7, 26, 12, 0, 0),
AgName = agName,
ReplicaServerName = replicaName,
RoleDesc = role,
OperationalStateDesc = operational,
ConnectedStateDesc = connected,
RecoveryHealthDesc = "ONLINE",
SynchronizationHealthDesc = syncHealth,
AvailabilityModeDesc = "SYNCHRONOUS_COMMIT",
FailoverModeDesc = "AUTOMATIC",
};

private static ViewerAgDatabaseRow Database(
int serverId, string serverName, string agName, string db, string replicaName,
string state = "SYNCHRONIZED", bool suspended = false) =>
new()
{
ServerId = serverId,
ServerName = serverName,
CollectionTime = new DateTime(2026, 7, 26, 12, 0, 0),
AgName = agName,
DatabaseName = db,
ReplicaServerName = replicaName,
SynchronizationStateDesc = state,
AvailabilityModeDesc = "SYNCHRONOUS_COMMIT",
IsSuspended = suspended,
SuspendReasonDesc = suspended ? "SUSPEND_FROM_USER" : null,
SecondaryLagSeconds = 0,
};

[Fact]
public void Build_OneAgSeenFromTwoServers_StaysTwoCardsEachNamingItsReporter()
{
/* The behavior the whole surface hangs on, and the one observed live on the AG fixture: the primary
reports both replicas, the secondary reports only itself and no primary. Merging them would let the
secondary's blind view overwrite the primary's complete one. */
var replicas = new[]
{
Replica(1, "NODE1", "AG1", "NODE1", "PRIMARY"),
Replica(1, "NODE1", "AG1", "NODE2", "SECONDARY", operational: null),
Replica(2, "NODE2", "AG1", "NODE2", "SECONDARY"),
};

var cards = ViewerDataService.BuildAvailabilityGroups(replicas, Array.Empty<ViewerAgDatabaseRow>());

Assert.Equal(2, cards.Count);
var fromPrimary = Assert.Single(cards, c => c.ServerName == "NODE1");
var fromSecondary = Assert.Single(cards, c => c.ServerName == "NODE2");

Assert.Equal(2, fromPrimary.Replicas.Count);
Assert.Equal("NODE1", fromPrimary.PrimaryReplica);

Assert.Single(fromSecondary.Replicas);
Assert.Null(fromSecondary.PrimaryReplica);
Assert.Equal("No primary reported", fromSecondary.PrimaryDisplay);
}

[Fact]
public void Build_UnknownDoesNotMaskHealthy_InTheCardRollUp()
{
var replicas = new[]
{
Replica(1, "NODE1", "AG1", "NODE1", "PRIMARY"),
Replica(1, "NODE1", "AG1", "NODE2", "SECONDARY", connected: null, operational: null),
};

var card = Assert.Single(ViewerDataService.BuildAvailabilityGroups(replicas, Array.Empty<ViewerAgDatabaseRow>()));
Assert.Equal(HealthSeverity.Healthy, card.Severity);
Assert.Equal("Healthy", card.SeverityLabel);
}

[Fact]
public void Build_SuspendedDatabaseRedensTheCard_EvenWhenEveryReplicaIsHealthy()
{
var replicas = new[] { Replica(1, "NODE1", "AG1", "NODE1", "PRIMARY") };
var databases = new[] { Database(1, "NODE1", "AG1", "Sales", "NODE2", suspended: true) };

var card = Assert.Single(ViewerDataService.BuildAvailabilityGroups(replicas, databases));

Assert.Equal(HealthSeverity.Critical, card.Severity);
Assert.Equal(HealthSeverity.Healthy, card.Replicas[0].Severity);
Assert.Contains("Suspended: SUSPEND_FROM_USER", Assert.Single(card.Databases).DataMovementDisplay, StringComparison.Ordinal);
}

[Fact]
public void Build_DatabasesAttachToTheirOwnReportingServersCard()
{
var replicas = new[]
{
Replica(1, "NODE1", "AG1", "NODE1", "PRIMARY"),
Replica(2, "NODE2", "AG1", "NODE2", "PRIMARY"),
};
var databases = new[] { Database(1, "NODE1", "AG1", "Sales", "NODE2") };

var cards = ViewerDataService.BuildAvailabilityGroups(replicas, databases);

Assert.Single(Assert.Single(cards, c => c.ServerName == "NODE1").Databases);
Assert.Empty(Assert.Single(cards, c => c.ServerName == "NODE2").Databases);
}

[Fact]
public void Build_SortsWorstFirst()
{
var replicas = new[]
{
Replica(1, "NODE1", "AG_CALM", "NODE1", "PRIMARY"),
Replica(2, "NODE2", "AG_BROKEN", "NODE2", "PRIMARY", syncHealth: "NOT_HEALTHY"),
Replica(3, "NODE3", "AG_SHAKY", "NODE3", "PRIMARY", syncHealth: "PARTIALLY_HEALTHY"),
};

Assert.Equal(
new[] { "AG_BROKEN", "AG_SHAKY", "AG_CALM" },
ViewerDataService.BuildAvailabilityGroups(replicas, Array.Empty<ViewerAgDatabaseRow>()).Select(c => c.AgName).ToArray());
}

[Fact]
public void Build_EmptyInputYieldsNoCards()
{
Assert.Empty(ViewerDataService.BuildAvailabilityGroups(Array.Empty<ViewerAgReplicaRow>(), Array.Empty<ViewerAgDatabaseRow>()));
}

/* ─────────────────────────── header summary ─────────────────────────── */

[Fact]
public void Summary_DistinguishesDistinctGroupsFromViews()
{
/* A reader seeing the same AG name on two cards needs the difference stated, not inferred. */
var replicas = new[]
{
Replica(1, "NODE1", "AG1", "NODE1", "PRIMARY"),
Replica(2, "NODE2", "AG1", "NODE2", "SECONDARY"),
};

var summary = AvailabilityGroupsTab.BuildSummary(ViewerDataService.BuildAvailabilityGroups(replicas, Array.Empty<ViewerAgDatabaseRow>()));

Assert.Equal("1 group · 2 reporting servers · 2 views", summary);
}

[Fact]
public void Summary_EmptyFleetSaysSo()
{
Assert.Equal("none observed", AvailabilityGroupsTab.BuildSummary(Array.Empty<ViewerAvailabilityGroup>()));
}
}
Loading
Loading