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
192 changes: 192 additions & 0 deletions Darling/Darling.Tests/AlertNotebookTemplateQueryPlansTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
/*
* 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 System.Text.Json.Nodes;
using PerformanceMonitor.Darling.Service;
using PerformanceMonitor.Notifications;
using Xunit;

namespace Darling.Tests;

/// <summary>
/// Pins for #4223's "Long-Running Query" and "Forced Plan Failing" authored alert-notebook templates
/// (<c>AlertNotebookEndpoint.BuildLongRunningQueryCells</c> / <c>BuildForcedPlanFailingCells</c>). The shared
/// theories in <see cref="AlertNotebookAuthoredTemplateTests"/> already cover both metrics' validation,
/// dispatch names and compose catalog once their row is registered; this file pins the two families' own
/// shapes: routing, the exact cell list, the <c>query_hash</c> non-binding note, and that
/// <c>get_query_store_regressions</c> never appears as an auto-run cell.
/// </summary>
public sealed class AlertNotebookTemplateQueryPlansTests
{
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 ═══════════════════════════ */

[Fact]
public void AuthoredTemplate_LongRunningQuery_RoutesToItsOwnTemplate()
{
var template = AlertNotebookEndpoint.AuthoredTemplate("Long-Running Query");

Assert.NotNull(template);
Assert.Equal("authored/long-running-query", template!.Value.Id);
}

[Fact]
public void AuthoredTemplate_ForcedPlanFailing_RoutesToItsOwnTemplate()
{
var template = AlertNotebookEndpoint.AuthoredTemplate("Forced Plan Failing");

Assert.NotNull(template);
Assert.Equal("authored/forced-plan-failing", template!.Value.Id);
}

/* ═══════════════════════════ Long-Running Query: exact cells ═══════════════════════════ */

[Fact]
public void LongRunningQuery_BuildsHeaderStatusActiveQueriesNoteAndCompletions()
{
var template = AlertNotebookEndpoint.AuthoredTemplate("Long-Running Query");
Assert.NotNull(template);

var cells = template!.Value.BuildCells(
"Long-Running Query", "SRV1", AsOf, WindowStart, WindowEnd,
IncidentWithDatabase("SalesDb"), null, "Unknown");

Assert.Equal(6, cells.Count);

Assert.Equal("header", (string?)cells[0]!.AsObject()["type"]);
Assert.Equal("status", (string?)cells[1]!.AsObject()["type"]);

var activeQueries = cells[2]!.AsObject();
Assert.Equal("read", (string?)activeQueries["type"]);
Assert.Equal("get_active_queries", (string?)activeQueries["read"]);
Assert.Equal("25", (string?)activeQueries["params"]!["limit"]);
Assert.Equal("1", (string?)activeQueries["params"]!["hours"]);

// The query_hash note (#4223): a markdown cell, never a get_plan_xml read cell.
var note = cells[3]!.AsObject();
Assert.Equal("markdown", (string?)note["type"]);
Assert.Contains("query_hash", (string?)note["text"], StringComparison.Ordinal);

var timeline = cells[4]!.AsObject();
Assert.Equal("panel", (string?)timeline["type"]);
Assert.Equal("long_query_completions", (string?)timeline["source"]);

var completions = cells[5]!.AsObject();
Assert.Equal("read", (string?)completions["type"]);
Assert.Equal("get_long_query_completions", (string?)completions["read"]);
Assert.Equal("20", (string?)completions["params"]!["limit"]);
Assert.Equal("24", (string?)completions["params"]!["hours"]);
}

[Fact]
public void LongRunningQuery_NeverEmitsAGetPlanXmlCell()
{
var template = AlertNotebookEndpoint.AuthoredTemplate("Long-Running Query");
Assert.NotNull(template);

var cells = template!.Value.BuildCells(
"Long-Running Query", "SRV1", AsOf, WindowStart, WindowEnd,
IncidentWithDatabase("SalesDb"), null, "Unknown");

var planXmlCells = cells.OfType<JsonObject>()
.Where(c => (string?)c["type"] == "read" && (string?)c["read"] == "get_plan_xml")
.ToArray();

Assert.Empty(planXmlCells);
}

[Fact]
public void LongRunningQuery_NoMatchedIncident_StillDegradesWithSameCellCount()
{
var template = AlertNotebookEndpoint.AuthoredTemplate("Long-Running Query");
Assert.NotNull(template);

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

Assert.Equal(withIncident.Count, withoutIncident.Count);
}

/* ═══════════════════════════ Forced Plan Failing: exact cells ═══════════════════════════ */

[Fact]
public void ForcedPlanFailing_BuildsHeaderStatusPlanCorrectionsAndTimeline()
{
var template = AlertNotebookEndpoint.AuthoredTemplate("Forced Plan Failing");
Assert.NotNull(template);

var cells = template!.Value.BuildCells(
"Forced Plan Failing", "SRV1", AsOf, WindowStart, WindowEnd,
IncidentWithDatabase("SalesDb"), null, "Unknown");

Assert.Equal(4, cells.Count);

Assert.Equal("header", (string?)cells[0]!.AsObject()["type"]);
Assert.Equal("status", (string?)cells[1]!.AsObject()["type"]);

var corrections = cells[2]!.AsObject();
Assert.Equal("read", (string?)corrections["type"]);
Assert.Equal("get_plan_corrections", (string?)corrections["read"]);
Assert.Equal("25", (string?)corrections["params"]!["limit"]);
Assert.Equal("24", (string?)corrections["params"]!["hours"]);
Assert.Equal("true", (string?)corrections["params"]!["full_text"]);

var timeline = cells[3]!.AsObject();
Assert.Equal("panel", (string?)timeline["type"]);
Assert.Equal("plan_correction", (string?)timeline["source"]);
Assert.Equal("plan_correction_captures", (string?)timeline["measure"]);
Assert.Equal("line", (string?)timeline["viz"]);
}

[Fact]
public void ForcedPlanFailing_NeverEmitsGetQueryStoreRegressionsAsAnAutoRunCell()
{
var template = AlertNotebookEndpoint.AuthoredTemplate("Forced Plan Failing");
Assert.NotNull(template);

var cells = template!.Value.BuildCells(
"Forced Plan Failing", "SRV1", AsOf, WindowStart, WindowEnd,
IncidentWithDatabase("SalesDb"), null, "Unknown");

// get_query_store_regressions must never appear as a "read" (auto-run) cell -- the issue keeps it
// click-to-run, and the notebook schema has no non-auto-run read cell type to bind it to instead.
var regressionReads = cells.OfType<JsonObject>()
.Where(c => (string?)c["type"] == "read" && (string?)c["read"] == "get_query_store_regressions")
.ToArray();

Assert.Empty(regressionReads);
}

[Fact]
public void ForcedPlanFailing_NoMatchedIncident_StillDegradesWithSameCellCount()
{
var template = AlertNotebookEndpoint.AuthoredTemplate("Forced Plan Failing");
Assert.NotNull(template);

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

Assert.Equal(withIncident.Count, withoutIncident.Count);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
/*
* 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>Long-Running Query template version (#4223). Bumped only if this template's SHAPE changes.</summary>
internal const int LongRunningQueryTemplateVersion = 1;

/// <summary>Forced Plan Failing template version (#4223). Bumped only if this template's SHAPE changes.</summary>
internal const int ForcedPlanFailingTemplateVersion = 1;

/// <summary>Long-Running Query (#4223): header, status, <c>get_active_queries</c> (limit 25), a note
/// that the plan for the still-running session is one click away in that row (<c>get_plan_xml</c> needs a
/// concrete <c>query_hash</c>, which neither <see cref="AlertIncident"/> nor the persisted history row
/// carries as data — <see cref="AlertContextBuilders.LongRunningQueryIncidents"/> hashes it into the
/// fingerprint's dedup key rather than exposing it, so there is no value to bind an auto-run cell to), a
/// completion-duration timeline, then <c>get_long_query_completions</c> over 24h.</summary>
private static JsonArray BuildLongRunningQueryCells(
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_active_queries", "Active queries", serverName, asOf,
("hours", "1"), ("limit", "25")),
new JsonObject
{
["type"] = "markdown",
["text"] = "The execution plan for a still-running session is one click away from its row " +
"above (Active queries) — this notebook cannot bind `get_plan_xml` without a concrete " +
"`query_hash`, which this alert does not carry as data.",
},
TimelinePanel(
"Long-query completions over time", "long_query_completions", "lqc_duration_us",
windowStart, windowEnd, annotation: "long_query_completions", databaseFilter: incident?.Database),
AuthoredReadCell("get_long_query_completions", "Completed long queries", serverName, asOf,
("hours", "24"), ("limit", "20")),
};

return cells;
}

/// <summary>Forced Plan Failing (#4223): header, status, <c>get_plan_corrections</c> (limit 25, full
/// text), then <c>plan_correction_captures</c> by <c>recommendation_state</c> as the corrections
/// timeline (no marker overlay — <see cref="MeasureCatalog.AnnotationSources"/> has no plan-correction
/// entry, and <see cref="TimelinePanel"/> requires a known annotation key, so this panel is built
/// directly rather than through that helper). <c>get_query_store_regressions</c> stays OFF this notebook
/// entirely: the issue calls it click-to-run, and the notebook schema (<see
/// cref="DarlingWebEndpoints.ValidateNotebookDefinition"/>) has no non-auto-run cell type to bind an
/// eager read to — every <c>read</c> cell runs on open, so putting the regressions read here would run it
/// eagerly against the issue's own intent.</summary>
private static JsonArray BuildForcedPlanFailingCells(
string? metric, string? serverName, string? asOf, DateTime windowStart, DateTime windowEnd,
AlertIncident? incident, DarlingAlertReader.AlertHistoryReadRow? row, string status)
{
var timelineCell = new JsonObject
{
["type"] = "panel",
["title"] = "Plan corrections over time",
["source"] = "plan_correction",
["measure"] = "plan_correction_captures",
["aggregate"] = "count",
["viz"] = "line",
["timeBucket"] = "hour",
["groupBy"] = new JsonArray { "recommendation_state" },
["range"] = new JsonObject
{
["windowStart"] = windowStart.ToString("o", System.Globalization.CultureInfo.InvariantCulture),
["windowEnd"] = windowEnd.ToString("o", System.Globalization.CultureInfo.InvariantCulture),
},
};

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

var cells = new JsonArray
{
HeaderCell(metric, serverName, incident, row),
StatusCell(status),
AuthoredReadCell("get_plan_corrections", "Plan corrections", serverName, asOf,
("hours", "24"), ("limit", "25"), ("full_text", "true")),
timelineCell,
};

return cells;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -374,6 +374,10 @@
new AuthoredTemplateEntry("authored/blocking", BlockingTemplateVersion, BuildBlockingCells)),
(new[] { "Deadlocks Detected" },
new AuthoredTemplateEntry("authored/deadlocks", DeadlocksTemplateVersion, BuildDeadlockCells)),
(new[] { "Forced Plan Failing" },
new AuthoredTemplateEntry("authored/forced-plan-failing", ForcedPlanFailingTemplateVersion, BuildForcedPlanFailingCells)),
(new[] { "Long-Running Query" },
new AuthoredTemplateEntry("authored/long-running-query", LongRunningQueryTemplateVersion, BuildLongRunningQueryCells)),
};

/// <summary>The authored template for a metric, or null when the metric falls back to the mechanical
Expand Down Expand Up @@ -410,7 +414,7 @@
/// 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 417 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)

Check warning on line 417 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 417 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 417 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 417 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 417 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 417 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 417 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 417 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)
{
"get_deadlock_trend",
};
Expand Down
Loading