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
128 changes: 128 additions & 0 deletions Darling/Darling.Tests/DarlingBlockingStatsReadTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
/*
* 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;
using System.Threading;
using System.Threading.Tasks;
using Npgsql;
using PerformanceMonitor.Collectors;
using PerformanceMonitor.Darling.Service.Mcp;
using PerformanceMonitor.Darling.Storage;
using Xunit;

namespace Darling.Tests;

/// <summary>
/// get_blocking_stats (#2484), and specifically the three-source capture probe behind its empty verdict.
///
/// <para>The verdict gates on the blocking series AND the deadlock series both being empty, so the probe
/// has to cover every path that could have filled either. The first cut checked only the two BLOCKING
/// sources, which would have called a server "genuinely clear" on the strength of blocking capture alone
/// while deadlock capture had never run — the reassuring-wrong answer the probe exists to prevent, missed
/// for the deadlock half. Review caught it; there was no test here to catch it, which is why there is one
/// now.</para>
/// </summary>
[Collection("live-postgres")]
public sealed class DarlingBlockingStatsReadTests
{
private const int ServerId = -949554;
private const string ServerName = "blocking-stats-read";

private static string? ConnectionString => Environment.GetEnvironmentVariable("DARLING_TEST_PG");

/// <summary>One victim, one blocker, so total wait sums both and victim_count is 1.</summary>
private const string GraphXml =
"<deadlock><victim-list><victimProcess id=\"p1\"/></victim-list><process-list>" +
"<process id=\"p1\" spid=\"55\" waittime=\"1000\"><inputbuf>x</inputbuf></process>" +
"<process id=\"p2\" spid=\"66\" waittime=\"3000\"><inputbuf>y</inputbuf></process>" +
"</process-list></deadlock>";

[Fact]
public async Task DeadlockCaptureAlone_CountsAsCaptured_AgainstDevPostgres()
{
var cs = ConnectionString;
Assert.SkipWhen(string.IsNullOrEmpty(cs),
"Set DARLING_TEST_PG to a Postgres connection string to run the live blocking-stats test.");

var ct = TestContext.Current.CancellationToken;
using var connection = new NpgsqlConnection(cs);
await connection.OpenAsync(ct);
await PgMigrations.MigrateAsync(connection, ct);
await DeleteRowsAsync(connection, ct);

await using var dataSource = NpgsqlDataSource.Create(cs!);
var bodySucceeded = false;

try
{
await DarlingMcpTestData.RegisterServerAsync(connection, ServerId, ServerName, ct);

/* ── nothing captured at all: NOT a clean bill of health ── */
var never = await DarlingMcpDataTools.GetBlockingStats(dataSource, ServerName, 24);
var neverDoc = JsonDocument.Parse(never);
Assert.Equal("unavailable", neverDoc.RootElement.GetProperty("status").GetString());
Assert.Contains("NOT a clean bill of health", neverDoc.RootElement.GetProperty("message").GetString()!, StringComparison.Ordinal);

/*
── the regression this file exists for ──
A deadlock OUTSIDE the asked-for window, and no blocking rows at all. The server has
plainly captured something, so the honest answer is "the window is clear". A probe that
only looked at the two blocking sources would say "never captured" here, which is the
same defect in the opposite direction: it tells an operator to go fix collection that is
working.
*/
await SeedDeadlockAsync(connection, ct, HoursAgo(48));

var quiet = await DarlingMcpDataTools.GetBlockingStats(dataSource, ServerName, 1);
var quietDoc = JsonDocument.Parse(quiet);
Assert.Equal("empty", quietDoc.RootElement.GetProperty("status").GetString());
Assert.Contains("genuinely clear", quietDoc.RootElement.GetProperty("message").GetString()!, StringComparison.Ordinal);

/* ── in-window deadlock: severity sums EVERY process, not just the victim ── */
await SeedDeadlockAsync(connection, ct, MinutesAgo(10));

var hit = await DarlingMcpDataTools.GetBlockingStats(dataSource, ServerName, 24);
var severity = JsonDocument.Parse(hit).RootElement.GetProperty("deadlock_severity");
Assert.True(severity.GetArrayLength() >= 1);

var bucket = severity[severity.GetArrayLength() - 1];
Assert.Equal(1, bucket.GetProperty("victim_count").GetInt32());

/* 4000, not 1000: the blocker's wait counts too. */
Assert.Equal(4000, bucket.GetProperty("total_wait_ms").GetInt64());
Assert.Equal(3000, bucket.GetProperty("max_wait_ms").GetInt64());

bodySucceeded = true;
}
finally
{
await LiveStoreCleanup.RunAsync(cs!, bodySucceeded, async (cleanup, cleanupCt) =>
await DeleteRowsAsync(cleanup, cleanupCt));
}
}

private static DateTime MinutesAgo(int minutes) =>
DarlingMcpTestData.TruncateToSeconds(DateTime.UtcNow.AddMinutes(-minutes));

private static DateTime HoursAgo(int hours) =>
DarlingMcpTestData.TruncateToSeconds(DateTime.UtcNow.AddHours(-hours));

private static async Task SeedDeadlockAsync(NpgsqlConnection connection, CancellationToken ct, DateTime at) =>
await DarlingMcpTestData.ExecAsync(connection, ct,
"INSERT INTO deadlocks (deadlock_id, collection_time, server_id, server_name, deadlock_time, victim_process_id, victim_sql_text, deadlock_graph_xml) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)",
CollectionIdGenerator.Next(), DarlingMcpTestData.Naive(at), ServerId, ServerName,
DarlingMcpTestData.Naive(at), "p1", "UPDATE Users SET Reputation = 1", GraphXml);

private static async Task DeleteRowsAsync(NpgsqlConnection connection, CancellationToken ct)
{
await DarlingMcpTestData.ExecAsync(connection, ct, "DELETE FROM deadlocks WHERE server_id = $1", ServerId);
await DarlingMcpTestData.ExecAsync(connection, ct, "DELETE FROM servers WHERE server_id = $1", ServerId);
await DarlingMcpTestData.ExecAsync(connection, ct, "DELETE FROM config_monitored_servers WHERE server_id = $1", ServerId);
}
}
9 changes: 5 additions & 4 deletions Darling/Darling.Tests/DarlingMcpDataToolsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
namespace Darling.Tests;

/// <summary>
/// Pins the core data-read MCP slice — the seventeen resource-metric / query-performance /
/// Pins the core data-read MCP slice — the eighteen resource-metric / query-performance /
/// discovery-health tools over the Postgres store, the same names the Dashboard and Lite expose.
/// Ungated: the tool surface is EXACTLY the pinned names (all static, on a [McpServerToolType]
/// class, returning the string envelope); each tool's MCP parameter contract matches Lite's (server_name
Expand All @@ -40,10 +40,11 @@ public sealed class DarlingMcpDataToolsSurfaceAndSqlTests
{
/* ---------------- ungated: tool-surface pin ---------------- */

/// <summary>The seventeen data-read tool names, ordinal-sorted — the same names Lite and the Dashboard
/// <summary>The eighteen data-read tool names, ordinal-sorted — the same names Lite and the Dashboard
/// expose, so MCP clients see one consistent product.</summary>
private static readonly string[] DataToolSurface =
{
"get_blocking_stats",
"get_collection_health",
"get_collection_log",
"get_cpu_utilization",
Expand All @@ -69,7 +70,7 @@ private static MethodInfo[] ToolMethods() => typeof(DarlingMcpDataTools)
.ToArray();

[Fact]
public void ToolSurface_ExactlyTheSeventeenDataTools()
public void ToolSurface_ExactlyTheEighteenDataTools()
{
var toolMethods = ToolMethods();

Expand Down Expand Up @@ -528,7 +529,7 @@ void Walk(JsonElement element, string path)
}

[Fact]
public void AdvertisedSchema_IsGeminiClean_ForAllSeventeenDataTools()
public void AdvertisedSchema_IsGeminiClean_ForAllEighteenDataTools()
{
var tools = BuildDataToolSchemas();
/* Derived from the pinned name list rather than restated. This literal was 15 while the list
Expand Down
100 changes: 100 additions & 0 deletions Darling/Darling.Tests/DeadlockSeverityAggregatorTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
/*
* 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.Collections.Generic;
using System.Linq;
using System.Text;
using PerformanceMonitor.Common;
using Xunit;

namespace Darling.Tests;

/// <summary>
/// The deadlock-severity aggregation at its NEW home in Common (#2484), reached directly rather than through
/// the viewer.
///
/// <para><see cref="DeadlockSeverityAggregationTests"/> already covers the arithmetic in depth, and because
/// the viewer now delegates, those pins exercise this implementation — which is the real evidence that the
/// move changed nothing. What they cannot cover is the Common entry point itself: they all call the viewer's
/// wrapper, so if that wrapper were ever removed or re-pointed, the shared function the headless service
/// depends on would have no test of its own. That is what this file is for.</para>
/// </summary>
public sealed class DeadlockSeverityAggregatorTests
{
/// <summary>The proven fixture shape from the viewer's suite: victims are named in the victim-list, not
/// by an attribute on the process.</summary>
private static string BuildGraph(params (string Id, int Spid, long WaitTime, bool IsVictim)[] processes)
{
var sb = new StringBuilder();
sb.Append("<deadlock><victim-list>");
foreach (var p in processes.Where(p => p.IsVictim))
sb.Append($"<victimProcess id=\"{p.Id}\"/>");
sb.Append("</victim-list><process-list>");
foreach (var p in processes)
sb.Append($"<process id=\"{p.Id}\" spid=\"{p.Spid}\" waittime=\"{p.WaitTime}\"><inputbuf>x</inputbuf></process>");
sb.Append("</process-list></deadlock>");
return sb.ToString();
}

[Fact]
public void TheSharedEntryPoint_SumsEveryProcessNotOnlyVictims_AndBucketsByTheMinute()
{
var minuteA = new DateTime(2026, 6, 21, 6, 51, 30, DateTimeKind.Unspecified);
var minuteAAgain = new DateTime(2026, 6, 21, 6, 51, 5, DateTimeKind.Unspecified);
var minuteB = new DateTime(2026, 6, 21, 6, 53, 0, DateTimeKind.Unspecified);

/* Fed out of order on purpose: the dictionary rollup does not preserve read order. */
var graphs = new List<(DateTime? DeadlockTime, string? Xml)>
{
(minuteB, BuildGraph(("p1", 55, 100, true))),
(minuteA, BuildGraph(("p2", 66, 1000, true), ("p3", 77, 3000, false))),
(minuteAAgain, BuildGraph(("p4", 88, 500, true))),
};

var points = DeadlockSeverityAggregator.Aggregate(graphs);

Assert.Equal(2, points.Count);
Assert.True(points[0].Time < points[1].Time, "buckets must come back in time order");

var first = points[0];

/*
4500, not 1500. Total wait sums EVERY process in the graphs, victims and blockers alike — the
Dashboard analyzer's semantics — which is why victim_count is a separate field rather than
something derivable from the total.
*/
Assert.Equal(4500, first.TotalWaitMs);
Assert.Equal(2, first.VictimCount);
Assert.Equal(3000, first.MaxWaitMs);

/* Process-weighted: 4500 over the three processes in the bucket, not over the two graphs. */
Assert.Equal(1500.0, first.AvgWaitMs);
}

[Fact]
public void AGraphThatCannotBeParsed_ContributesNothing_RatherThanAZeroBucket()
{
var at = new DateTime(2026, 6, 21, 6, 51, 0, DateTimeKind.Unspecified);
var graphs = new List<(DateTime? DeadlockTime, string? Xml)>
{
(at, "<deadlock></deadlock>"),
(at, null),
(at, "not xml at all"),

/* A graph with no time cannot be placed on the axis at all. */
(null, BuildGraph(("p1", 55, 100, true))),
};

/*
An empty bucket would read as "a deadlock happened and cost nothing", which is worse than
silence: absent evidence is not evidence of calm.
*/
Assert.Empty(DeadlockSeverityAggregator.Aggregate(graphs));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -1106,6 +1106,7 @@ private static CatalogRead R(string category, string description, params Catalog
["get_collection_health"] = R(CatData, "Per-collector collection health for a server.", PServer()),
["get_collection_log"] = R(CatData, "Raw per-run collector log for a server, newest first.", PServer(), PHours(24), PLimit(200)),
["get_current_waits_trend"] = R(CatData, "Waiting-task and blocked-session series over time.", PServer(), PHours(4), PText("database_name")),
["get_blocking_stats"] = R(CatData, "Blocking duration and deadlock severity per minute.", PServer(), PHours(24)),
["get_cpu_utilization"] = R(CatData, "CPU utilization over time.", PServer(), PHours(4)),
["get_file_io_stats"] = R(CatData, "Per-file IO stall/throughput stats.", PServer()),
["get_memory_clerks"] = R(CatData, "Top memory clerks by allocation.", PServer()),
Expand Down Expand Up @@ -1544,6 +1545,7 @@ internal static IReadOnlyDictionary<string, ReadToolHandler> BuildReadDispatch()
["get_collection_health"] = (c, pg, an) => DarlingMcpDataTools.GetCollectionHealth(pg, Server(c)),
["get_collection_log"] = (c, pg, an) => DarlingMcpDataTools.GetCollectionLog(pg, Server(c), Hours(c, 24), Rows(c, "limit", 200)),
["get_current_waits_trend"] = (c, pg, an) => DarlingMcpDataTools.GetCurrentWaitsTrend(pg, Server(c), Hours(c, 4), Str(c, "database_name")),
["get_blocking_stats"] = (c, pg, an) => DarlingMcpDataTools.GetBlockingStats(pg, Server(c), Hours(c, 24)),
["get_cpu_utilization"] = (c, pg, an) => DarlingMcpDataTools.GetCpuUtilization(pg, Server(c), Hours(c, 4)),
["get_file_io_stats"] = (c, pg, an) => DarlingMcpDataTools.GetFileIoStats(pg, Server(c)),
["get_memory_clerks"] = (c, pg, an) => DarlingMcpDataTools.GetMemoryClerks(pg, Server(c)),
Expand Down
Loading
Loading