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
4 changes: 2 additions & 2 deletions Darling/Darling.Tests/AlertNotebookAuthoredTemplateTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -237,9 +237,9 @@ public void Budget_ReadCellsHaveLimitsExceptTheTrendRead_ComposedWindowsAreAtMos
$"authored read cell names a barred read: {read}");

var parameters = Assert.IsType<JsonObject>(cell["params"]);
if (read == "get_deadlock_trend")
if (AlertNotebookEndpoint.s_authoredLimitlessTrendReads.Contains(read))
{
Assert.False(parameters.ContainsKey("limit"), "get_deadlock_trend must not carry a limit param");
Assert.False(parameters.ContainsKey("limit"), $"{read} must not carry a limit param");
}
else
{
Expand Down
157 changes: 157 additions & 0 deletions Darling/Darling.Tests/AlertNotebookTemplatePostgresTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
/*
* 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.Text.Json.Nodes;
using PerformanceMonitor.Darling.Service;
using PerformanceMonitor.Notifications;
using Xunit;

namespace Darling.Tests;

/// <summary>
/// Pins for #4223 — the authored <c>authored/pg-wraparound</c>, <c>authored/pg-xmin-horizon</c> and
/// <c>authored/pg-replication-slot</c> alert-notebook templates (<c>BuildPgWraparoundCells</c> /
/// <c>BuildPgXminHorizonCells</c> / <c>BuildPgReplicationSlotCells</c>). The shared theories in
/// <see cref="AlertNotebookAuthoredTemplateTests"/> already cover validation, dispatch, compose-catalog and
/// budget/binding/viz for every registered metric, including these three (its <c>AllAuthoredMetrics</c> is
/// data-driven off <see cref="AlertNotebookEndpoint.s_authoredTemplates"/>). This file pins what's specific to
/// this family: routing, the exact cell shape, and the no-matched-incident degrade.
/// </summary>
public sealed class AlertNotebookTemplatePostgresTests
{
private static readonly DateTime WindowEnd = new(2026, 1, 1, 12, 0, 0, DateTimeKind.Utc);
private static readonly DateTime WindowStart = WindowEnd - TimeSpan.FromHours(24);
private const string AsOf = "2026-01-01T12:00:00Z";

private static AlertIncident IncidentWithDatabase(string database) =>
new("dedup-key", new[] { "obj1" }, Database: database);

/* ═══════════════════════════ routing ═══════════════════════════ */

[Theory]
[InlineData("PostgreSQL Wraparound Risk", "authored/pg-wraparound")]
[InlineData("PostgreSQL Vacuum Horizon Blocked", "authored/pg-xmin-horizon")]
[InlineData("PostgreSQL Replication Slot Retention", "authored/pg-replication-slot")]
public void AuthoredTemplate_RoutesEachPostgresFamily_ToItsOwnTemplate(string metric, string expectedId)
{
var template = AlertNotebookEndpoint.AuthoredTemplate(metric);

Assert.NotNull(template);
Assert.Equal(expectedId, template!.Value.Id);
}

/* ═══════════════════════════ exact cell shape ═══════════════════════════ */

[Fact]
public void WraparoundRisk_BuildsHeaderStatusTwoReadsAndOneTrendPanel()
{
var template = AlertNotebookEndpoint.AuthoredTemplate("PostgreSQL Wraparound Risk");
Assert.NotNull(template);

var cells = template!.Value.BuildCells(
"PostgreSQL Wraparound Risk", "SRV1", AsOf, WindowStart, WindowEnd,
IncidentWithDatabase("SalesDb"), null, "Unknown");

Assert.Equal(5, cells.Count);
Assert.Equal("header", ((JsonObject)cells[0]!)["type"]!.GetValue<string>());
Assert.Equal("status", ((JsonObject)cells[1]!)["type"]!.GetValue<string>());

var firstRead = (JsonObject)cells[2]!;
Assert.Equal("read", firstRead["type"]!.GetValue<string>());
Assert.Equal("get_pg_wraparound_risk", firstRead["read"]!.GetValue<string>());

var secondRead = (JsonObject)cells[3]!;
Assert.Equal("read", secondRead["type"]!.GetValue<string>());
Assert.Equal("get_pg_autovacuum_health", secondRead["read"]!.GetValue<string>());
Assert.Equal("20", secondRead["params"]!["limit"]!.GetValue<string>());

var trend = (JsonObject)cells[4]!;
Assert.Equal("panel", trend["type"]!.GetValue<string>());
Assert.Equal("pg_wraparound_stats", trend["source"]!.GetValue<string>());
Assert.Equal("pg_xids_remaining", trend["measure"]!.GetValue<string>());
Assert.Equal("min", trend["aggregate"]!.GetValue<string>());
Assert.Equal("SalesDb", trend["filters"]![0]!["value"]!.GetValue<string>());
}

[Fact]
public void VacuumHorizonBlocked_BuildsHeaderStatusTwoReadsAndOneTrendPanel_NoDatabaseFilter()
{
var template = AlertNotebookEndpoint.AuthoredTemplate("PostgreSQL Vacuum Horizon Blocked");
Assert.NotNull(template);

var cells = template!.Value.BuildCells(
"PostgreSQL Vacuum Horizon Blocked", "SRV1", AsOf, WindowStart, WindowEnd,
IncidentWithDatabase("SalesDb"), null, "Unknown");

Assert.Equal(5, cells.Count);

var firstRead = (JsonObject)cells[2]!;
Assert.Equal("get_pg_xmin_horizon", firstRead["read"]!.GetValue<string>());

var secondRead = (JsonObject)cells[3]!;
Assert.Equal("get_pg_session_states", secondRead["read"]!.GetValue<string>());
Assert.Equal("25", secondRead["params"]!["limit"]!.GetValue<string>());

var trend = (JsonObject)cells[4]!;
Assert.Equal("panel", trend["type"]!.GetValue<string>());
Assert.Equal("pg_xmin_horizon", trend["source"]!.GetValue<string>());
Assert.Equal("pg_xmin_age", trend["measure"]!.GetValue<string>());
Assert.Equal("max", trend["aggregate"]!.GetValue<string>());

// pg_xmin_horizon has no database_name dimension (its own dimension is "source") -- no filter, even
// with a matched incident that carries a database.
Assert.Null(trend["filters"]);
}

[Fact]
public void ReplicationSlotRetention_BuildsHeaderStatusTwoReadsAndOneTrendPanel()
{
var template = AlertNotebookEndpoint.AuthoredTemplate("PostgreSQL Replication Slot Retention");
Assert.NotNull(template);

var cells = template!.Value.BuildCells(
"PostgreSQL Replication Slot Retention", "SRV1", AsOf, WindowStart, WindowEnd,
IncidentWithDatabase("SalesDb"), null, "Unknown");

Assert.Equal(5, cells.Count);

var firstRead = (JsonObject)cells[2]!;
Assert.Equal("get_pg_replication_slots", firstRead["read"]!.GetValue<string>());

var secondRead = (JsonObject)cells[3]!;
Assert.Equal("get_pg_replication_stats", secondRead["read"]!.GetValue<string>());
Assert.Equal("25", secondRead["params"]!["limit"]!.GetValue<string>());

var trend = (JsonObject)cells[4]!;
Assert.Equal("panel", trend["type"]!.GetValue<string>());
Assert.Equal("pg_replication_slot_stats", trend["source"]!.GetValue<string>());
Assert.Equal("pg_slot_retained_wal_bytes", trend["measure"]!.GetValue<string>());
Assert.Equal("max", trend["aggregate"]!.GetValue<string>());
Assert.Equal("SalesDb", trend["filters"]![0]!["value"]!.GetValue<string>());
}

/* ═══════════════════════════ no-matched-incident degrade ═══════════════════════════ */

[Theory]
[InlineData("PostgreSQL Wraparound Risk")]
[InlineData("PostgreSQL Vacuum Horizon Blocked")]
[InlineData("PostgreSQL Replication Slot Retention")]
public void EachFamily_WithNoMatchedIncident_KeepsTheSameCellCount(string metric)
{
var template = AlertNotebookEndpoint.AuthoredTemplate(metric);
Assert.NotNull(template);

var withoutIncident = template!.Value.BuildCells(
metric, "SRV1", AsOf, WindowStart, WindowEnd, null, null, "Unknown");
var withIncident = template.Value.BuildCells(
metric, "SRV1", AsOf, WindowStart, WindowEnd, IncidentWithDatabase("SalesDb"), null, "Unknown");

Assert.Equal(withIncident.Count, withoutIncident.Count);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
/*
* 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.Globalization;
using System.Text.Json.Nodes;
using PerformanceMonitor.Darling.Analysis;
using PerformanceMonitor.Darling.Service.Mcp;
using PerformanceMonitor.Notifications;

namespace PerformanceMonitor.Darling.Service;

internal static partial class AlertNotebookEndpoint
{
/// <summary>PostgreSQL Wraparound Risk template version (#4223). Bumped only if this template's SHAPE
/// changes.</summary>
internal const int PgWraparoundTemplateVersion = 1;

/// <summary>PostgreSQL Vacuum Horizon Blocked template version (#4223).</summary>
internal const int PgXminHorizonTemplateVersion = 1;

/// <summary>PostgreSQL Replication Slot Retention template version (#4223).</summary>
internal const int PgReplicationSlotTemplateVersion = 1;

/// <summary>PostgreSQL Wraparound Risk: header, status, <c>get_pg_wraparound_risk</c>, <c>get_pg_autovacuum_health</c>
/// (the mechanical map's second read for this family), then a 24h trend of the LEAST XID headroom remaining
/// (<c>pg_xids_remaining</c>, aggregated Min — the least runway to a write outage is the signal, same
/// as the measure's own default).</summary>
private static JsonArray BuildPgWraparoundCells(
string? metric, string? serverName, string? asOf, DateTime windowStart, DateTime windowEnd,
AlertIncident? incident, DarlingAlertReader.AlertHistoryReadRow? row, string status)
{
return new JsonArray
{
HeaderCell(metric, serverName, incident, row),
StatusCell(status),
AuthoredReadCell("get_pg_wraparound_risk", "Wraparound headroom", serverName, asOf,
("hours", "24")),
AuthoredReadCell("get_pg_autovacuum_health", "Autovacuum health", serverName, asOf,
("hours", "24"), ("limit", "20")),
PgGaugeTimelinePanel(
"XIDs remaining to write outage", "pg_wraparound_stats", "pg_xids_remaining", "min",
windowStart, windowEnd, databaseFilter: incident?.Database),
};
}

/// <summary>PostgreSQL Vacuum Horizon Blocked: header, status, <c>get_pg_xmin_horizon</c> (what is
/// holding the horizon back, by cause), <c>get_pg_session_states</c> (whether a held-open session
/// actually pins the horizon), then a 24h trend of the horizon's own age (<c>pg_xmin_age</c>).</summary>
private static JsonArray BuildPgXminHorizonCells(
string? metric, string? serverName, string? asOf, DateTime windowStart, DateTime windowEnd,
AlertIncident? incident, DarlingAlertReader.AlertHistoryReadRow? row, string status)
{
return new JsonArray
{
HeaderCell(metric, serverName, incident, row),
StatusCell(status),
AuthoredReadCell("get_pg_xmin_horizon", "xmin horizon holders", serverName, asOf,
("hours", "24")),
AuthoredReadCell("get_pg_session_states", "Session states", serverName, asOf,
("hours", "24"), ("limit", "25")),
PgGaugeTimelinePanel(
"Xmin horizon age", "pg_xmin_horizon", "pg_xmin_age", "max",
windowStart, windowEnd, databaseFilter: null),
};
}

/// <summary>PostgreSQL Replication Slot Retention: header, status, <c>get_pg_replication_slots</c>,
/// <c>get_pg_replication_stats</c> (the mechanical map's second read — the connected-replica counterpart
/// of the slot read), then a 24h trend of retained WAL per slot (<c>pg_slot_retained_wal_bytes</c>).</summary>
private static JsonArray BuildPgReplicationSlotCells(
string? metric, string? serverName, string? asOf, DateTime windowStart, DateTime windowEnd,
AlertIncident? incident, DarlingAlertReader.AlertHistoryReadRow? row, string status)
{
return new JsonArray
{
HeaderCell(metric, serverName, incident, row),
StatusCell(status),
AuthoredReadCell("get_pg_replication_slots", "Replication slots", serverName, asOf,
("hours", "24")),
AuthoredReadCell("get_pg_replication_stats", "Replication stats", serverName, asOf,
("hours", "24"), ("limit", "25")),
PgGaugeTimelinePanel(
"Slot retained WAL", "pg_replication_slot_stats", "pg_slot_retained_wal_bytes", "max",
windowStart, windowEnd, databaseFilter: incident?.Database),
};
}

/// <summary>A 24h trend panel for a GAUGE measure (spec §1 binding: absolute <c>range</c> equal to the
/// computed window, and — where the incident carries a database and the source has that dimension — a
/// bound <c>database_name</c> filter). Distinct from <see cref="TimelinePanel"/>, which hard-codes
/// <c>aggregate: "count"</c> for a per-event measure and always carries an annotation overlay; none of
/// the three PostgreSQL gauge sources here have a per-event count or a catalog annotation source to
/// overlay, so this carries the caller's own aggregate and no <c>annotations</c> key.</summary>
private static JsonObject PgGaugeTimelinePanel(
string title, string source, string measure, string aggregate, DateTime windowStart, DateTime windowEnd,
string? databaseFilter)
{
var cell = new JsonObject
{
["type"] = "panel",
["title"] = title,
["source"] = source,
["measure"] = measure,
["aggregate"] = aggregate,
["viz"] = "line",
["timeBucket"] = "hour",
["range"] = new JsonObject
{
["windowStart"] = windowStart.ToString("o", CultureInfo.InvariantCulture),
["windowEnd"] = windowEnd.ToString("o", CultureInfo.InvariantCulture),
},
};

if (!string.IsNullOrWhiteSpace(databaseFilter))
{
cell["filters"] = new JsonArray
{
new JsonObject
{
["dimension"] = "database_name",
["op"] = "eq",
["value"] = databaseFilter,
},
};
}

return cell;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -378,6 +378,12 @@ internal static readonly (string[] Metrics, AuthoredTemplateEntry Entry)[] s_aut
new AuthoredTemplateEntry("authored/forced-plan-failing", ForcedPlanFailingTemplateVersion, BuildForcedPlanFailingCells)),
(new[] { "Long-Running Query" },
new AuthoredTemplateEntry("authored/long-running-query", LongRunningQueryTemplateVersion, BuildLongRunningQueryCells)),
(new[] { "PostgreSQL Replication Slot Retention" },
new AuthoredTemplateEntry("authored/pg-replication-slot", PgReplicationSlotTemplateVersion, BuildPgReplicationSlotCells)),
(new[] { "PostgreSQL Vacuum Horizon Blocked" },
new AuthoredTemplateEntry("authored/pg-xmin-horizon", PgXminHorizonTemplateVersion, BuildPgXminHorizonCells)),
(new[] { "PostgreSQL Wraparound Risk" },
new AuthoredTemplateEntry("authored/pg-wraparound", PgWraparoundTemplateVersion, BuildPgWraparoundCells)),
};

/// <summary>The authored template for a metric, or null when the metric falls back to the mechanical
Expand Down Expand Up @@ -414,9 +420,14 @@ internal static readonly (string[] Metrics, AuthoredTemplateEntry Entry)[] s_aut
/// is the bucket count, not a row cap (spec §3's own budget rule: "every read cell has an explicit limit,
/// OR its trend goes through the chart bucket budget"). <see cref="AuthoredReadCell"/> must not force a
/// 'limit' onto one of these, or <c>ValidateReadPanelSpec</c>'s undeclared-param check reds it.</summary>
private static readonly IReadOnlySet<string> s_authoredLimitlessTrendReads = new HashSet<string>(StringComparer.Ordinal)
internal static readonly IReadOnlySet<string> s_authoredLimitlessTrendReads = new HashSet<string>(StringComparer.Ordinal)
{
"get_deadlock_trend",
/* #4223: none of the three PostgreSQL primary reads declare a 'limit' param (PServer/PHours/PAsOf
only), so the same no-forced-limit exemption applies here even though they aren't trend reads. */
"get_pg_wraparound_risk",
"get_pg_xmin_horizon",
"get_pg_replication_slots",
};

/// <summary>An authored template's read cell (spec §1 binding): <c>server</c>, <c>as_of = window_end</c>
Expand Down
Loading