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: 1 addition & 1 deletion Darling/Darling.Tests/DarlingMcpToolClassification.cs
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ internal static class DarlingMcpToolClassification
"get_pg_replication_slots", "get_pg_replication_stats", "get_pg_server_config",
"get_pg_server_config_changes", "get_pg_session_states", "get_pg_table_bloat", "get_pg_top_queries",
"get_pg_wait_sampling", "get_pg_wait_stats", "get_pg_wait_trend", "get_pg_wraparound_risk",
"get_pg_write_stats", "get_pg_xmin_horizon", "get_plan_cache_bloat", "get_plan_corrections", "get_plan_xml",
"get_pg_write_stats", "get_pg_xmin_horizon", "get_plan_cache_bloat", "get_plan_corrections", "get_plan_force_precheck", "get_plan_xml",
"get_procedure_duration_trend", "get_procedure_plan_xml", "get_pvs_stats", "get_query_duration_trend",
"get_query_heatmap", "get_query_repro_script", "get_query_store_clutter", "get_query_store_duration_trend",
"get_query_store_health", "get_query_store_plan_xml", "get_query_store_query_history",
Expand Down
2 changes: 2 additions & 0 deletions Darling/Darling.Tests/McpPayloadContractCensusTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2039,6 +2039,8 @@ public static readonly (string Spelling, string File, string WhatItIs)[] Severit
"audit_config's edition-NAME fallback (Enterprise / Standard / … / Unknown) — a name, spelled as the canon by coincidence"),
("Unknown", "McpAnalysisTools.cs",
"audit_config's edition-NAME fallback — the Lite twin of the above"),
("unknown", "DarlingMcpPlanForcePrecheckTools.cs",
"get_plan_force_precheck's unknown[] section name: the checks the store could not answer, listed beside refusals and never a refusal (#5632). A section name, not a band"),
("unknown", "DarlingMcpTools.cs",
"mute_analysis_finding's kind (plain / read-only / per-database) for a resolved server that has no definition row in the store (one defined in darling.json) — a registration kind, not a band"),
("unknown", "DarlingMcpPgWaitSamplingTools.cs",
Expand Down
213 changes: 213 additions & 0 deletions Darling/Darling.Tests/PlanForcePrecheckLiveTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
/*
* 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.Linq;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Npgsql;
using NpgsqlTypes;
using PerformanceMonitor.Collectors;
using PerformanceMonitor.Common;
using PerformanceMonitor.Darling.Service.Mcp;
using PerformanceMonitor.Darling.Storage;
using Xunit;

namespace Darling.Tests;

/// <summary>
/// #5632 step A2: get_plan_force_precheck over seeded store rows. Each case plants one query's Query Store rows, its text and its
/// stored plans, reads the tool, and asserts the verdict and the stable refusal codes. as_of is fixed so no clock decides the window.
/// </summary>
[Collection("live-postgres")]
public sealed class PlanForcePrecheckLiveTests
{
private const string ServerName = "darling-force-precheck";
private static readonly int ServerId = ServerIdHelper.GetDeterministicHashCode(ServerName);
private const string Db = "ForceDb";

private static readonly DateTime Anchor = DateTime.SpecifyKind(DarlingMcpTestData.TruncateToSeconds(DateTime.UtcNow).AddHours(-6), DateTimeKind.Unspecified);
private static readonly DateTime AsOf = Anchor.AddHours(1);

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

private static string Plan(string? value = null, string extra = "", string stmtAttrs = "", string? queryPlanAttrs = null) =>
"<ShowPlanXML xmlns=\"http://schemas.microsoft.com/sqlserver/2004/07/showplan\" Version=\"1.564\"><BatchSequence><Batch><Statements>"
+ "<StmtSimple StatementText=\"SELECT 1\" StatementId=\"1\" StatementType=\"SELECT\"" + stmtAttrs + ">" + extra
+ "<QueryPlan CachedPlanSize=\"16\"" + queryPlanAttrs + ">"
+ (value is null ? "" : "<ParameterList><ColumnReference Column=\"@p\" ParameterDataType=\"int\" ParameterCompiledValue=\"" + value + "\" ParameterRuntimeValue=\"" + value + "\"/></ParameterList>")
+ "</QueryPlan></StmtSimple></Statements></Batch></BatchSequence></ShowPlanXML>";

private sealed record Seed(long QueryId, string Text, (long PlanId, long Rows, long Execs, int LastExecMinutesBeforeAnchor, string Xml, bool Forced)[] Plans);

private static async Task<JsonDocument> RunAsync(Seed seed, Func<NpgsqlConnection, CancellationToken, Task>? extra = null, long? planId = null, long? compare = null)
{
var cs = ConnectionString;
Assert.SkipWhen(string.IsNullOrEmpty(cs), "Set DARLING_TEST_PG to a Postgres connection string to run the live pre-check test.");
var ct = TestContext.Current.CancellationToken;
await using var connection = new NpgsqlConnection(cs);
await connection.OpenAsync(ct);
await PgMigrations.MigrateAsync(connection, ct);
await DeleteAsync(connection, ct);
await using var postgres = NpgsqlDataSource.Create(cs!);
var ok = false;
try
{
await DarlingMcpTestData.RegisterServerAsync(connection, ServerId, ServerName, ct);
await Exec(connection, ct, "INSERT INTO collect.query_store_text (server_id, database_name, query_id, query_sql_text, last_seen) VALUES ($1,$2,$3,$4,$5)", ServerId, Db, seed.QueryId, seed.Text, Anchor);
foreach (var p in seed.Plans)
{
var last = Anchor.AddMinutes(-p.LastExecMinutesBeforeAnchor);
await Exec(connection, ct, @"INSERT INTO query_store_stats
(collection_id, collection_time, server_id, server_name, database_name, query_id, plan_id, execution_type_desc, runtime_stats_interval_id,
interval_start_time_utc, first_execution_time, last_execution_time, execution_count, avg_cpu_time_us, avg_duration_us, avg_rowcount, is_forced_plan, force_failure_count)
VALUES ($1,$2,$3,$4,$5,$6,$7,'Regular',$7,$8,$8,$9,$10,5000,7000,$11,$12,0)",
CollectionIdGenerator.Next(), Anchor, ServerId, ServerName, Db, seed.QueryId, p.PlanId, last.AddHours(-1), last, p.Execs, p.Rows, p.Forced);
var stored = p.Xml + "<!-- plan " + p.PlanId.ToString(CultureInfo.InvariantCulture) + " -->";
var digest = PayloadDimensions.Digest(stored);
await using (var dim = new NpgsqlCommand("INSERT INTO query_plan_dim (digest, query_plan_xml, last_seen) VALUES ($1,$2,$3) ON CONFLICT DO NOTHING", connection))
{
dim.Parameters.Add(new NpgsqlParameter { NpgsqlDbType = NpgsqlDbType.Bytea, Value = digest });
dim.Parameters.AddWithValue(stored);
dim.Parameters.AddWithValue(NpgsqlDbType.Timestamp, Anchor);
await dim.ExecuteNonQueryAsync(ct);
}

await using var map = new NpgsqlCommand("INSERT INTO collect.query_store_plan_map (server_id, database_name, plan_id, digest, plan_hash, last_seen) VALUES ($1,$2,$3,$4,$5,$6)", connection);
map.Parameters.AddWithValue(ServerId);
map.Parameters.AddWithValue(Db);
map.Parameters.AddWithValue(p.PlanId);
map.Parameters.Add(new NpgsqlParameter { NpgsqlDbType = NpgsqlDbType.Bytea, Value = digest });
map.Parameters.AddWithValue("0x5632");
map.Parameters.AddWithValue(NpgsqlDbType.Timestamp, Anchor);
await map.ExecuteNonQueryAsync(ct);
}

if (extra is not null) await extra(connection, ct);
var json = await DarlingMcpPlanForcePrecheckTools.GetPlanForcePrecheck(
postgres, Db, seed.QueryId, planId ?? seed.Plans[0].PlanId, ServerName, compare, 24, AsOf.ToString("o", CultureInfo.InvariantCulture), cancellationToken: ct);
var doc = JsonDocument.Parse(json);
ok = true;
return doc;
}
finally
{
await LiveStoreCleanup.RunAsync(cs!, ok, async (c, t) => await DeleteAsync(c, t));
}
}

private static Task Exec(NpgsqlConnection c, CancellationToken ct, string sql, params object?[] values) =>
DarlingMcpTestData.ExecAsync(c, ct, sql, values.Select(v => v is DateTime d ? DateTime.SpecifyKind(d, DateTimeKind.Unspecified) : v).ToArray());

private static string[] Codes(JsonDocument d) =>
d.RootElement.GetProperty("refusals").EnumerateArray().Select(r => r.GetProperty("code").GetString()!).ToArray();

private static string[] UnknownChecks(JsonDocument d) =>
d.RootElement.GetProperty("unknown").EnumerateArray().Select(r => r.GetProperty("check").GetString()!).ToArray();

/* A clean query: two plans of about the same output, both well run, both recent, no hint, no temp table. The candidate is the cheap, older plan. */
private static Seed Clean(long q = 7001, string text = "SELECT * FROM dbo.widgets WHERE id = @p") =>
new(q, text, [(1, 100, 60, 30, Plan("1"), false), (2, 120, 60, 5, Plan("1"), false)]);

[Fact]
public async Task ACleanCandidate_HasNoRefusal_ButTheVerdictNeverSaysSafe()
{
using var doc = await RunAsync(Clean());
var codes = Codes(doc);
/* The store holds no force state, so the gate's state_unavailable is the one refusal; every check this read owns is quiet. */
Assert.DoesNotContain(codes, c => c is "recompile_hint" or "temp_table" or "not_run_recently" or "too_few_executions" or "psp_variant" or "output_differs" or "query_store_read_only");
Assert.Contains("query_store_read_only", UnknownChecks(doc));
Assert.DoesNotContain("safe", doc.RootElement.GetProperty("verdict").GetString(), StringComparison.OrdinalIgnoreCase);
}

[Fact]
public async Task Pattern1_AStatementWithOptionRecompile_IsRefused()
{
using var doc = await RunAsync(Clean(7002, "SELECT * FROM dbo.widgets WHERE id = @p OPTION (RECOMPILE)"));
Assert.Contains("recompile_hint", Codes(doc));
Assert.Equal("refuse", doc.RootElement.GetProperty("verdict").GetString());
}

[Fact]
public async Task Pattern2_PlansReturning26000AndFiftyRowsPerCall_AreRefusedAsOutputDiffers()
{
var seed = new Seed(7003, "SELECT 1", [(1, 50, 60, 30, Plan("1"), false), (2, 26_000, 60, 5, Plan("1"), false)]);
using var doc = await RunAsync(seed);
Assert.Contains("output_differs", Codes(doc));
}

[Fact]
public async Task ABlockerFromTheExistingGate_AppearsUnderItsOwnCode()
{
var seed = new Seed(7004, "SELECT 1", [(1, 100, 60, 30, Plan("1"), true), (2, 100, 60, 5, Plan("1"), false)]);
using var doc = await RunAsync(seed);
Assert.Contains("already_forced", Codes(doc));
}

[Fact]
public async Task ARunStoppedQuery_AndAThinPlan_AreRefused()
{
var seed = new Seed(7005, "SELECT 1", [(1, 100, 3, 300, Plan("1"), false), (2, 100, 60, 240, Plan("1"), false)]);
using var doc = await RunAsync(seed);
var codes = Codes(doc);
Assert.Contains("not_run_recently", codes);
Assert.Contains("too_few_executions", codes);
}

[Fact]
public async Task DifferentCompiledValues_AreShown_NeverRefused()
{
var seed = new Seed(7006, "SELECT 1", [(1, 100, 60, 30, Plan("111"), false), (2, 100, 60, 5, Plan("222"), false)]);
using var doc = await RunAsync(seed);
Assert.DoesNotContain(Codes(doc), c => c is "output_differs" or "recompile_hint" or "psp_variant");
var raw = doc.RootElement.GetRawText();
Assert.Contains("\"compiled_value\":\"111\"", raw, StringComparison.Ordinal);
Assert.Contains("\"compiled_value\":\"222\"", raw, StringComparison.Ordinal);
Assert.Equal(PerformanceMonitor.Common.McpDataDetailMasker.ReplacedValue,
System.Text.Json.Nodes.JsonNode.Parse(McpDataDetailMasker.MaskJson(raw, McpDataDetail.MaskLiterals))!["shown"]!["plans"]![0]!["compiled_values"]![0]!["compiled_value"]!.GetValue<string>());
var meta = McpDataDetailMasker.MaskJson(raw, McpDataDetail.MetadataOnly);
Assert.DoesNotContain("111", meta, StringComparison.Ordinal);
Assert.DoesNotContain("222", meta, StringComparison.Ordinal);
}

[Fact]
public async Task ATempTable_AndAPspVariant_AreRefused_AndATableVariableIsNot()
{
var temp = Plan("1", "<RelOp NodeId=\"0\"><Object Database=\"[ForceDb]\" Schema=\"[dbo]\" Table=\"[#orders]\"/></RelOp>");
var tableVar = Plan("1", "<RelOp NodeId=\"0\"><Object Database=\"[ForceDb]\" Schema=\"[dbo]\" Table=\"[@orders]\"/></RelOp>");
using (var d1 = await RunAsync(new Seed(7007, "SELECT 1", [(1, 100, 60, 30, temp, false), (2, 100, 60, 5, Plan("1"), false)])))
Assert.Contains("temp_table", Codes(d1));
using (var d2 = await RunAsync(new Seed(7008, "SELECT 1", [(1, 100, 60, 30, tableVar, false), (2, 100, 60, 5, Plan("1"), false)])))
Assert.DoesNotContain("temp_table", Codes(d2));
using var d3 = await RunAsync(new Seed(7009, "SELECT 1", [(1, 100, 60, 30, Plan("1", queryPlanAttrs: " QueryVariantID=\"2\""), false), (2, 100, 60, 5, Plan("1"), false)]));
Assert.Contains("psp_variant", Codes(d3));
Assert.Contains("psp_variant_siblings", UnknownChecks(d3));
}

[Fact]
public async Task AReadOnlyQueryStore_IsRefused_AndNoHealthRowIsUnknown()
{
using var ro = await RunAsync(Clean(7010), async (c, ct) => await Exec(c, ct,
@"INSERT INTO collect.query_store_health (config_id, capture_time, server_id, server_name, database_name, actual_state, desired_state, readonly_reason)
VALUES ($1,$2,$3,$4,$5,'READ_ONLY','READ_WRITE',65536)", CollectionIdGenerator.Next(), Anchor, ServerId, ServerName, Db));
Assert.Contains("query_store_read_only", Codes(ro));
using var none = await RunAsync(Clean(7011));
Assert.DoesNotContain("query_store_read_only", Codes(none));
Assert.Contains("query_store_read_only", UnknownChecks(none));
}

private static async Task DeleteAsync(NpgsqlConnection c, CancellationToken ct)
{
foreach (var table in new[] { "query_store_stats", "collect.query_store_text", "collect.query_store_health", "collect.query_store_plan_map" })
await Exec(c, ct, "DELETE FROM " + table + " WHERE server_id = $1", ServerId);
await Exec(c, ct, "DELETE FROM query_plan_dim WHERE last_seen = $1 AND query_plan_xml LIKE '%<!-- plan %'", Anchor);
await Exec(c, ct, "DELETE FROM servers WHERE server_id = $1", ServerId);
}
}
3 changes: 2 additions & 1 deletion Darling/Darling.Tests/WebDatabaseFilterBehaviourTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,7 @@ public void TheRuntimeCensus_WithEveryDatabaseScopedReadFiltered_GivesEachOneAll
Assert.Equal(3, scopes.GetProperty("UNFILTERED").GetInt32());
/* Six since #5300: the Query Store History panel's read is one query's own rows, keyed by its database and query_id. */
/* Nine since the Blocking and Deadlocks plan reads (#5236) and the repro script (#5233) joined the plan viewer: each names one stored row. */
Assert.Equal(9, scopes.GetProperty("IDENTITY").GetInt32());
/* Ten since #5632: the force pre-check reads one query's plans, keyed by its database and query_id. */
Assert.Equal(10, scopes.GetProperty("IDENTITY").GetInt32());
}
}
4 changes: 2 additions & 2 deletions Darling/Darling.Tests/WebDatabaseFilterReadsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -243,11 +243,11 @@ public void EveryClassedRead_IsInTheReadCatalog()
/// together; moving a read from one to the other keeps the total), and FILTERED is a subset of what the plan filters: none of
/// the three deadlock reads.</summary>
[Fact]
public void TheIdentityReads_AreTheNineRowIdentityReads_AndTheDatabaseScopedReadsAreThirtyFour()
public void TheIdentityReads_AreTheTenRowIdentityReads_AndTheDatabaseScopedReadsAreThirtyFour()
{
var classes = Classes();
Assert.Equal(
Sorted(new[] { "get_query_trend", "get_plan_xml", "get_query_store_plan_xml", "get_active_query_plan_xml", "get_procedure_plan_xml", "get_query_store_query_history", "get_blocking_plan_xml", "get_deadlock_plan_xml", "get_query_repro_script" }),
Sorted(new[] { "get_query_trend", "get_plan_xml", "get_query_store_plan_xml", "get_active_query_plan_xml", "get_procedure_plan_xml", "get_query_store_query_history", "get_plan_force_precheck", "get_blocking_plan_xml", "get_deadlock_plan_xml", "get_query_repro_script" }),
Sorted(classes["IDENTITY"]));
Assert.Equal(34, classes["FILTERED"].Length + classes["UNFILTERED"].Length);
Assert.DoesNotContain(classes["FILTERED"], read => read is "get_deadlock_trend" or "get_deadlocks" or "get_deadlock_detail");
Expand Down
4 changes: 2 additions & 2 deletions Darling/Darling.Tests/WebReadCancellationPinTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -54,14 +54,14 @@ private static NpgsqlDataSource DeadStore() =>
/// <summary>
/// An <see cref="HttpContext"/> whose request is already aborted, carrying query values for the few
/// required text parameters (<c>get_query_trend</c>'s <c>query_hash</c> / <c>database_name</c>, and
/// <c>get_wait_trend</c>'s <c>wait_type</c>, <c>get_alert_details</c>'s alert key) so a dispatch entry with a synchronous "is this parameter
/// <c>get_wait_trend</c>'s <c>wait_type</c>, <c>get_alert_details</c>'s alert key, <c>get_plan_force_precheck</c>'s <c>plan_id</c>) so a dispatch entry with a synchronous "is this parameter
/// present" gate still reaches its store call instead of returning a missing-parameter envelope with
/// nothing cancelled.
/// </summary>
private static HttpContext CancelledRequest()
{
var context = new DefaultHttpContext();
context.Request.QueryString = new QueryString("?kind=query_hash&query_hash=deadbeef&database_name=probe&counter_name=x&wait_type=CXPACKET&metric=total_waits&query_id=1&sql_handle=0x01&collection_time=2026-01-01T00:00:00.000000Z&session_id=1&event_time=2026-01-01T00:00:00.000000Z&blocked_spid=1&blocking_spid=2&deadlock_time=2026-01-01T00:00:00.000000Z&server_id=1&metric_name=x&alert_time=2026-01-01T00:00:00Z");
context.Request.QueryString = new QueryString("?kind=query_hash&query_hash=deadbeef&database_name=probe&counter_name=x&wait_type=CXPACKET&metric=total_waits&query_id=1&plan_id=1&sql_handle=0x01&collection_time=2026-01-01T00:00:00.000000Z&session_id=1&event_time=2026-01-01T00:00:00.000000Z&blocked_spid=1&blocking_spid=2&deadlock_time=2026-01-01T00:00:00.000000Z&server_id=1&metric_name=x&alert_time=2026-01-01T00:00:00Z");
context.RequestAborted = new CancellationToken(canceled: true);
return context;
}
Expand Down
Loading
Loading