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
51 changes: 51 additions & 0 deletions Darling/Darling.Tests/AlertNotebookAuthoredTemplateTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -319,4 +319,55 @@ public void BlockingByDatabaseBreakdown_NeverBindsADatabaseFilter_EvenWithAMatch

Assert.Null(byDatabase["filters"]);
}

/* ═══════════════════════════ the registration table (#4223 lane 0) ═══════════════════════════ */

/// <summary>Every family in <see cref="AlertNotebookEndpoint.s_authoredTemplates"/> resolves to a
/// non-null entry for each of its own metric names, the ids are unique, and every entry's built cells
/// still pass <see cref="DarlingWebEndpoints.ValidateNotebookDefinition"/> against a fixed alert — the
/// pin the split into one-file-per-family (#4223) was ruled to require, so a future family can be added
/// to the table without anyone re-checking these by hand.</summary>
[Fact]
public void RegistrationTable_EveryFamilyResolvesAndValidates()
{
var table = AlertNotebookEndpoint.s_authoredTemplates;
Assert.NotEmpty(table);

var incident = IncidentWithDatabase("SalesDb");
var seenIds = new HashSet<string>(StringComparer.Ordinal);

foreach (var (metrics, entry) in table)
{
Assert.NotEmpty(metrics);
Assert.True(seenIds.Add(entry.Id), $"duplicate template id: {entry.Id}");

foreach (var metric in metrics)
{
var resolved = AlertNotebookEndpoint.AuthoredTemplate(metric);
Assert.NotNull(resolved);
Assert.Equal(entry.Id, resolved!.Value.Id);

var cells = resolved.Value.BuildCells(
metric, "SRV1", AsOf, WindowStart, WindowEnd, incident, null,
"Unknown (not collected since " + AsOf + ")");

var definition = new JsonObject { ["kind"] = "notebook", ["cells"] = cells };
var validation = DarlingWebEndpoints.ValidateNotebookDefinition(definition);

Assert.True(validation.IsValid, validation.Error);
}
}
}

/// <summary>The table is sorted case-insensitively by each row's first metric name — keeps the table an
/// easy-to-scan, mergeable list as families are added in parallel (#4223's own stated point).</summary>
[Fact]
public void RegistrationTable_IsSortedByFirstMetricName()
{
var table = AlertNotebookEndpoint.s_authoredTemplates;
var firstMetrics = table.Select(row => row.Metrics[0]).ToArray();
var sorted = firstMetrics.OrderBy(m => m, StringComparer.OrdinalIgnoreCase).ToArray();

Assert.Equal(sorted, firstMetrics);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
/*
* 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.Analysis;
using PerformanceMonitor.Darling.Service.Mcp;
using PerformanceMonitor.Notifications;

namespace PerformanceMonitor.Darling.Service;

internal static partial class AlertNotebookEndpoint
{
/// <summary>Blocking template version (#4222 slice b). Bumped only if this template's SHAPE changes.</summary>
internal const int BlockingTemplateVersion = 1;

/// <summary>Blocking Detected / Blocking Wait Time (spec §3): header, status, <c>get_blocking</c> (limit
/// 20), a blocked-process-reports timeline with a deadlock annotation, most-blocked objects / by database
/// / lock modes (the <c>blocking-rca</c> panel specs, ported), then <c>get_active_queries
/// blocking_only</c>.</summary>
private static JsonArray BuildBlockingCells(
string? metric, string? serverName, string? asOf, DateTime windowStart, DateTime windowEnd,
AlertIncident? incident, DarlingAlertReader.AlertHistoryReadRow? row, string status)
{
var cells = new JsonArray
{
HeaderCell(metric, serverName, incident, row),
StatusCell(status),
AuthoredReadCell("get_blocking", "Blocking chains", serverName, asOf,
("hours", "24"), ("limit", "20")),
TimelinePanel(
"Blocked-process reports over time", "blocked_process_reports", "bpr_wait_time_ms",
windowStart, windowEnd, annotation: "deadlocks", databaseFilter: incident?.Database),
RankedPanel(
"Most-blocked objects", "blocked_process_reports", "bpr_wait_time_ms",
windowStart, windowEnd, "contentious_object", databaseFilter: incident?.Database),
RankedPanel(
"Blocking by database", "blocked_process_reports", "bpr_wait_time_ms",
windowStart, windowEnd, "database_name", databaseFilter: null),
RankedPanel(
"Lock modes", "blocked_process_reports", "bpr_wait_time_ms",
windowStart, windowEnd, "lock_mode", databaseFilter: incident?.Database),
AuthoredReadCell("get_active_queries", "Active blocking queries", serverName, asOf,
("hours", "1"), ("blocking_only", "true"), ("limit", "25")),
};

return cells;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
/*
* 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.Analysis;
using PerformanceMonitor.Darling.Service.Mcp;
using PerformanceMonitor.Notifications;

namespace PerformanceMonitor.Darling.Service;

internal static partial class AlertNotebookEndpoint
{
/// <summary>Deadlocks template version (#4222 slice b). Bumped only if this template's SHAPE changes.</summary>
internal const int DeadlocksTemplateVersion = 1;

/// <summary>Deadlocks Detected (spec §3): header, status, <c>get_deadlock_detail</c> (limit 3), a
/// deadlocks timeline with a blocking annotation, deadlocks by database (the <c>deadlock-postmortem</c>
/// panel specs, ported), then <c>get_deadlock_trend</c> over 24h.</summary>
private static JsonArray BuildDeadlockCells(
string? metric, string? serverName, string? asOf, DateTime windowStart, DateTime windowEnd,
AlertIncident? incident, DarlingAlertReader.AlertHistoryReadRow? row, string status)
{
var cells = new JsonArray
{
HeaderCell(metric, serverName, incident, row),
StatusCell(status),
AuthoredReadCell("get_deadlock_detail", "Deadlock detail", serverName, asOf,
("hours", "24"), ("limit", "3")),
TimelinePanel(
"Deadlocks over time", "deadlocks", "deadlock_count",
windowStart, windowEnd, annotation: "blocked_process_reports", databaseFilter: incident?.Database),
RankedPanel(
"Deadlocks by database", "deadlocks", "deadlock_count",
windowStart, windowEnd, "database_name", databaseFilter: null),
AuthoredReadCell("get_deadlock_trend", "Deadlock trend", serverName, asOf,
("hours", "24")),
};

return cells;
}
}
106 changes: 27 additions & 79 deletions Darling/PerformanceMonitor.Darling.Service/AlertNotebookEndpoint.cs
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@
/// with data — still answers 200 with honest empty cells and a note, exactly like the triage page it sits
/// beside.</para>
/// </summary>
internal static class AlertNotebookEndpoint
internal static partial class AlertNotebookEndpoint
{
/// <summary>The template id/version pair every mechanical conversion carries. Bumped only if the SHAPE of
/// the mechanical conversion below changes; a new metric added to <see cref="DarlingTriageEndpoint.SectionsByMetric"/>
Expand Down Expand Up @@ -363,105 +363,53 @@
int Version,
Func<string?, string?, string?, DateTime, DateTime, AlertIncident?, DarlingAlertReader.AlertHistoryReadRow?, string, JsonArray> BuildCells);

/// <summary>Blocking template version (#4222 slice b). Bumped only if this template's SHAPE changes.</summary>
internal const int BlockingTemplateVersion = 1;

/// <summary>Deadlocks template version (#4222 slice b). Bumped only if this template's SHAPE changes.</summary>
internal const int DeadlocksTemplateVersion = 1;
/// <summary>The authored-template registration table, sorted case-insensitively by the metric name each
/// row matches on. One row per family, holding every alert-engine <c>MetricName</c> string that routes to
/// it. <see cref="AuthoredTemplate"/> is a lookup against this table — a new family is added here, not by
/// growing an if-chain.</summary>
internal static readonly (string[] Metrics, AuthoredTemplateEntry Entry)[] s_authoredTemplates =
{
(new[] { "Blocking Detected", "Blocking Wait Time" },
new AuthoredTemplateEntry("authored/blocking", BlockingTemplateVersion, BuildBlockingCells)),
(new[] { "Deadlocks Detected" },
new AuthoredTemplateEntry("authored/deadlocks", DeadlocksTemplateVersion, BuildDeadlockCells)),
};

/// <summary>The authored template for a metric, or null when the metric falls back to the mechanical
/// conversion — every metric NOT named here keeps the byte-identical mechanical path. Keyed on the EXACT
/// alert-engine <c>MetricName</c> strings (the same literals <see cref="DarlingTriageEndpoint.SectionsByMetric"/>
/// keys on), case-insensitively, matching every other metric lookup on this endpoint.
/// Made <c>internal</c> (not private) so <see cref="Darling.Tests.AlertNotebookAuthoredTemplateTests"/>
/// can call it directly via <c>InternalsVisibleTo</c> instead of reflection — the same visibility
/// <see cref="MatchAlert"/> and <see cref="StatusFromHistory"/> already use for their own pins.</summary>
/// conversion — every metric NOT named in <see cref="s_authoredTemplates"/> keeps the byte-identical
/// mechanical path. Keyed on the EXACT alert-engine <c>MetricName</c> strings (the same literals
/// <see cref="DarlingTriageEndpoint.SectionsByMetric"/> keys on), case-insensitively, matching every other
/// metric lookup on this endpoint. Made <c>internal</c> (not private) so
/// <see cref="Darling.Tests.AlertNotebookAuthoredTemplateTests"/> can call it directly via
/// <c>InternalsVisibleTo</c> instead of reflection — the same visibility <see cref="MatchAlert"/> and
/// <see cref="StatusFromHistory"/> already use for their own pins.</summary>
internal static AuthoredTemplateEntry? AuthoredTemplate(string? metric)
{
if (string.IsNullOrWhiteSpace(metric))
{
return null;
}

if (string.Equals(metric, "Blocking Detected", StringComparison.OrdinalIgnoreCase)
|| string.Equals(metric, "Blocking Wait Time", StringComparison.OrdinalIgnoreCase))
{
return new AuthoredTemplateEntry("authored/blocking", BlockingTemplateVersion, BuildBlockingCells);
}

if (string.Equals(metric, "Deadlocks Detected", StringComparison.OrdinalIgnoreCase))
foreach (var (metrics, entry) in s_authoredTemplates)
{
return new AuthoredTemplateEntry("authored/deadlocks", DeadlocksTemplateVersion, BuildDeadlockCells);
foreach (var candidate in metrics)
{
if (string.Equals(metric, candidate, StringComparison.OrdinalIgnoreCase))
{
return entry;
}
}
}

return null;
}

/// <summary>Blocking Detected / Blocking Wait Time (spec §3): header, status, <c>get_blocking</c> (limit
/// 20), a blocked-process-reports timeline with a deadlock annotation, most-blocked objects / by database
/// / lock modes (the <c>blocking-rca</c> panel specs, ported), then <c>get_active_queries
/// blocking_only</c>.</summary>
private static JsonArray BuildBlockingCells(
string? metric, string? serverName, string? asOf, DateTime windowStart, DateTime windowEnd,
AlertIncident? incident, DarlingAlertReader.AlertHistoryReadRow? row, string status)
{
var cells = new JsonArray
{
HeaderCell(metric, serverName, incident, row),
StatusCell(status),
AuthoredReadCell("get_blocking", "Blocking chains", serverName, asOf,
("hours", "24"), ("limit", "20")),
TimelinePanel(
"Blocked-process reports over time", "blocked_process_reports", "bpr_wait_time_ms",
windowStart, windowEnd, annotation: "deadlocks", databaseFilter: incident?.Database),
RankedPanel(
"Most-blocked objects", "blocked_process_reports", "bpr_wait_time_ms",
windowStart, windowEnd, "contentious_object", databaseFilter: incident?.Database),
RankedPanel(
"Blocking by database", "blocked_process_reports", "bpr_wait_time_ms",
windowStart, windowEnd, "database_name", databaseFilter: null),
RankedPanel(
"Lock modes", "blocked_process_reports", "bpr_wait_time_ms",
windowStart, windowEnd, "lock_mode", databaseFilter: incident?.Database),
AuthoredReadCell("get_active_queries", "Active blocking queries", serverName, asOf,
("hours", "1"), ("blocking_only", "true"), ("limit", "25")),
};

return cells;
}

/// <summary>Deadlocks Detected (spec §3): header, status, <c>get_deadlock_detail</c> (limit 3), a
/// deadlocks timeline with a blocking annotation, deadlocks by database (the <c>deadlock-postmortem</c>
/// panel specs, ported), then <c>get_deadlock_trend</c> over 24h.</summary>
private static JsonArray BuildDeadlockCells(
string? metric, string? serverName, string? asOf, DateTime windowStart, DateTime windowEnd,
AlertIncident? incident, DarlingAlertReader.AlertHistoryReadRow? row, string status)
{
var cells = new JsonArray
{
HeaderCell(metric, serverName, incident, row),
StatusCell(status),
AuthoredReadCell("get_deadlock_detail", "Deadlock detail", serverName, asOf,
("hours", "24"), ("limit", "3")),
TimelinePanel(
"Deadlocks over time", "deadlocks", "deadlock_count",
windowStart, windowEnd, annotation: "blocked_process_reports", databaseFilter: incident?.Database),
RankedPanel(
"Deadlocks by database", "deadlocks", "deadlock_count",
windowStart, windowEnd, "database_name", databaseFilter: null),
AuthoredReadCell("get_deadlock_trend", "Deadlock trend", serverName, asOf,
("hours", "24")),
};

return cells;
}

/// <summary>The reads this endpoint's authored templates call that declare no <c>limit</c> param at all
/// (<see cref="DarlingWebEndpoints.BuildReadDispatch"/>'s own catalog) — a chart/trend read whose budget
/// 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)

Check warning on line 412 in Darling/PerformanceMonitor.Darling.Service/AlertNotebookEndpoint.cs

View workflow job for this annotation

GitHub Actions / build

Change type of field 's_authoredLimitlessTrendReads' from 'System.Collections.Generic.IReadOnlySet<string>' to 'System.Collections.Generic.HashSet<string>' for improved performance (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1859)

Check warning on line 412 in Darling/PerformanceMonitor.Darling.Service/AlertNotebookEndpoint.cs

View workflow job for this annotation

GitHub Actions / build

Change type of field 's_authoredLimitlessTrendReads' from 'System.Collections.Generic.IReadOnlySet<string>' to 'System.Collections.Generic.HashSet<string>' for improved performance (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1859)

Check warning on line 412 in Darling/PerformanceMonitor.Darling.Service/AlertNotebookEndpoint.cs

View workflow job for this annotation

GitHub Actions / Darling PG tests (0)

Change type of field 's_authoredLimitlessTrendReads' from 'System.Collections.Generic.IReadOnlySet<string>' to 'System.Collections.Generic.HashSet<string>' for improved performance (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1859)

Check warning on line 412 in Darling/PerformanceMonitor.Darling.Service/AlertNotebookEndpoint.cs

View workflow job for this annotation

GitHub Actions / Darling PG tests (0)

Change type of field 's_authoredLimitlessTrendReads' from 'System.Collections.Generic.IReadOnlySet<string>' to 'System.Collections.Generic.HashSet<string>' for improved performance (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1859)

Check warning on line 412 in Darling/PerformanceMonitor.Darling.Service/AlertNotebookEndpoint.cs

View workflow job for this annotation

GitHub Actions / Darling PG tests (2)

Change type of field 's_authoredLimitlessTrendReads' from 'System.Collections.Generic.IReadOnlySet<string>' to 'System.Collections.Generic.HashSet<string>' for improved performance (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1859)

Check warning on line 412 in Darling/PerformanceMonitor.Darling.Service/AlertNotebookEndpoint.cs

View workflow job for this annotation

GitHub Actions / Darling PG tests (2)

Change type of field 's_authoredLimitlessTrendReads' from 'System.Collections.Generic.IReadOnlySet<string>' to 'System.Collections.Generic.HashSet<string>' for improved performance (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1859)

Check warning on line 412 in Darling/PerformanceMonitor.Darling.Service/AlertNotebookEndpoint.cs

View workflow job for this annotation

GitHub Actions / Darling PG tests (1)

Change type of field 's_authoredLimitlessTrendReads' from 'System.Collections.Generic.IReadOnlySet<string>' to 'System.Collections.Generic.HashSet<string>' for improved performance (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1859)

Check warning on line 412 in Darling/PerformanceMonitor.Darling.Service/AlertNotebookEndpoint.cs

View workflow job for this annotation

GitHub Actions / Darling PG tests (1)

Change type of field 's_authoredLimitlessTrendReads' from 'System.Collections.Generic.IReadOnlySet<string>' to 'System.Collections.Generic.HashSet<string>' for improved performance (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1859)

Check warning on line 412 in Darling/PerformanceMonitor.Darling.Service/AlertNotebookEndpoint.cs

View workflow job for this annotation

GitHub Actions / Darling Linux build

Change type of field 's_authoredLimitlessTrendReads' from 'System.Collections.Generic.IReadOnlySet<string>' to 'System.Collections.Generic.HashSet<string>' for improved performance (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1859)
{
"get_deadlock_trend",
};
Expand Down
Loading