Skip to content
2 changes: 1 addition & 1 deletion Darling/Darling.Tests/DarlingCustomViewsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,7 @@ public void Catalog_ParamsNameTheActualWireQueryKeys_NotCSharpParamNames()
{
/* Builder 2 binds these keys onto ?query=strings, so they MUST be the literal keys the dispatch lambdas
read — the whole reason the metadata is hand-authored (reflection would emit the C# param names). */
AssertParamKeys("get_query_store_top", "server", "hours", "top", "database_name", "as_of", "execution_type");
AssertParamKeys("get_query_store_top", "server", "hours", "top", "database_name", "as_of", "execution_type", "module_name");
AssertParamKeys("get_wait_stats", "server", "hours", "limit", "as_of");
AssertParamKeys("get_wait_trend", "wait_type", "server", "hours", "as_of", "bucket_minutes");
AssertParamKeys("get_plan_xml", "query_hash", "server", "database_name");
Expand Down
30 changes: 29 additions & 1 deletion Darling/Darling.Tests/DarlingMcpDataToolsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ private static (string Name, bool Optional)[] McpParams(string toolName)
[InlineData("get_perfmon_stats", "server_name,counter_name,instance_name")]
[InlineData("get_top_queries_by_cpu", "server_name,hours_back,top,database_name,parallel_only,min_dop,as_of")]
[InlineData("get_top_procedures_by_cpu", "server_name,hours_back,top,database_name,as_of")]
[InlineData("get_query_store_top", "server_name,hours_back,top,database_name,as_of,execution_type")]
[InlineData("get_query_store_top", "server_name,hours_back,top,database_name,as_of,execution_type,module_name")]
[InlineData("get_collection_health", "server_name")]
/* #3287 gave this read two filters, on BOTH SKUs and in the same relative order, so it joins the theory
rather than sitting outside it. The two are LAST because as_of and collector_name are both `string?`:
Expand Down Expand Up @@ -681,9 +681,37 @@ primary and secondary workload blended into one row. */
Assert.Contains("$5::text IS NULL OR database_name = $5", sql, StringComparison.Ordinal);
Assert.Contains("$6::text IS NULL OR execution_type_desc = $6", sql, StringComparison.Ordinal);
Assert.Contains("r.execution_type_desc", sql, StringComparison.Ordinal);
Assert.Contains("$7::text IS NULL OR module_name = $7", sql, StringComparison.Ordinal);
Assert.Contains("MAX(module_name) AS module_name", sql, StringComparison.Ordinal);
Assert.Contains("r.module_name", sql, StringComparison.Ordinal);
Assert.Contains("SUM(execution_count)", sql, StringComparison.Ordinal);
}

[Fact]
public void QueryStoreSql_AppliesModuleFilterAfterDedupAndBeforeRankingLimit()
{
var sql = DarlingDataReader.QueryStoreTopSql;
var rankedCte = sql.IndexOf("ranked AS", StringComparison.Ordinal);
var dedupSurvivor = sql.IndexOf("WHERE rn = 1", rankedCte, StringComparison.Ordinal);
var moduleFilter = sql.IndexOf("$7::text IS NULL OR module_name = $7", StringComparison.Ordinal);
var firstLimit = sql.IndexOf("LIMIT $4 + 5", StringComparison.Ordinal);

Assert.True(dedupSurvivor > rankedCte && moduleFilter > dedupSurvivor,
"module_name must filter only the latest cumulative interval snapshots");
Assert.True(moduleFilter < firstLimit,
"the deduped, filtered population must be ranked before the result cap");
}

[Fact]
public void QueryStoreWindowFloor_RemainsACheapUnfilteredRetentionProbe()
{
var sql = DarlingDataReader.QueryStoreWindowFloorSql;
Assert.Contains("SELECT MIN(collection_time)", sql, StringComparison.Ordinal);
Assert.DoesNotContain("ROW_NUMBER", sql, StringComparison.Ordinal);
Assert.DoesNotContain("module_name", sql, StringComparison.Ordinal);
Assert.DoesNotContain("database_name", sql, StringComparison.Ordinal);
}

[Fact]
public void QueryStoreSql_DedupsPerIntervalBeforeAggregating()
{
Expand Down
89 changes: 88 additions & 1 deletion Darling/Darling.Tests/McpFilterSemanticsLivePostgresTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,80 @@ await LiveStoreCleanup.RunAsync(cs!, bodySucceeded, async (cleanup, cleanupCt) =
}
}

/// <summary>
/// <c>get_query_store_top</c>'s <c>module_name</c> (#4057) is a filter in the query, the A13 rule above: the
/// cheap procedure an investigation names must come back at <c>top = 1</c> even though a hotter module owns
/// the unfiltered rank, and its re-collected interval must count ONCE (the #1841 dedup runs first). A miss
/// over a window that holds rows is a true negative carrying the window it read; a miss over a window that
/// holds nothing is the unfiltered read's "unavailable", because the filter is not why it came back empty.
/// </summary>
[Fact]
public async Task QueryStoreModuleFilter_RanksTheFilteredPopulation_AndTellsAMissFromAnEmptyWindow()
{
var cs = ConnectionString;
Assert.SkipWhen(string.IsNullOrEmpty(cs), "Set DARLING_TEST_PG to a Postgres connection string to run the live filter-semantics 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 postgres = NpgsqlDataSource.Create(cs!);

var bodySucceeded = false;
try
{
await DarlingMcpTestData.RegisterServerAsync(connection, ServerId, ServerName, ct);
var now = DarlingMcpTestData.TruncateToSeconds(DateTime.UtcNow).AddMinutes(-2);

/* One hot module, then the cheap target, whose one interval was collected twice (2, then 5). The
re-collection shares the interval's identity, first_execution_time included, as a real one does. */
var firstExecution = now.AddMinutes(-40);
await PlantQueryStoreAsync(connection, ct, now.AddMinutes(-20), firstExecution, 101, "dbo.usp_HighCost", intervalId: 9301, executions: 100, avgDurationUs: 50_000);
await PlantQueryStoreAsync(connection, ct, now.AddMinutes(-30), firstExecution, 102, "dbo.usp_Target", intervalId: 9302, executions: 2, avgDurationUs: 200);
await PlantQueryStoreAsync(connection, ct, now.AddMinutes(-10), firstExecution, 102, "dbo.usp_Target", intervalId: 9302, executions: 5, avgDurationUs: 200);

/* Unfiltered at top = 1: the hot module owns the rank. */
var unfiltered = JsonDocument.Parse(await DarlingMcpDataTools.GetQueryStoreTop(postgres, ServerName, 1, 1)).RootElement;
var hot = Assert.Single(unfiltered.GetProperty("queries").EnumerateArray().ToArray());
Assert.Equal(101, hot.GetProperty("query_id").GetInt64());
Assert.Equal("dbo.usp_HighCost", hot.GetProperty("module_name").GetString());

/* Filtered at the same cap: the target, counted at its latest snapshot (5), not 2 + 5. */
var filtered = JsonDocument.Parse(await DarlingMcpDataTools.GetQueryStoreTop(postgres, ServerName, 1, 1, module_name: "dbo.usp_Target")).RootElement;
var target = Assert.Single(filtered.GetProperty("queries").EnumerateArray().ToArray());
Assert.Equal(102, target.GetProperty("query_id").GetInt64());
Assert.Equal(5, target.GetProperty("execution_count").GetInt64());
Assert.Equal("dbo.usp_Target", target.GetProperty("module_name").GetString());

/* A miss over a window that holds rows: a true negative, carrying the window actually read. */
var miss = JsonDocument.Parse(await DarlingMcpDataTools.GetQueryStoreTop(postgres, ServerName, 1, 1, module_name: "dbo.usp_target")).RootElement;
Assert.Equal("empty", miss.GetProperty("status").GetString());
Assert.Contains("case-sensitive", miss.GetProperty("message").GetString(), StringComparison.Ordinal);
var hints = miss.GetProperty("hints");
Assert.Equal(JsonValueKind.String, hints.GetProperty("effective_start").ValueKind);
Assert.False(hints.GetProperty("window_truncated").GetBoolean());

/* Beside #4060's execution_type: the module does run, but never with that outcome. Still a measured
zero, and the message names both filters, because either could be why nothing matched. */
var bothMiss = JsonDocument.Parse(await DarlingMcpDataTools.GetQueryStoreTop(postgres, ServerName, 1, 1, execution_type: "Aborted", module_name: "dbo.usp_Target")).RootElement;
Assert.Equal("empty", bothMiss.GetProperty("status").GetString());
Assert.Contains("with execution_type Aborted", bothMiss.GetProperty("message").GetString(), StringComparison.Ordinal);

/* The same filter over a window that holds nothing: not the filter's miss, so not "empty". */
var asOf = now.AddDays(-2).ToString("yyyy-MM-ddTHH:mm:ssZ");
var nothing = JsonDocument.Parse(await DarlingMcpDataTools.GetQueryStoreTop(postgres, ServerName, 1, 1, as_of: asOf, module_name: "dbo.usp_Target")).RootElement;
Assert.Equal("unavailable", nothing.GetProperty("status").GetString());

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

[Fact]
public async Task ActiveQueries_FiltersInTheQuery_KeepsHeadBlockers_AndNamesAbsentOnes()
{
Expand Down Expand Up @@ -343,6 +417,19 @@ await DarlingMcpTestData.ExecAsync(connection, ct,
digest, 10L, cpuUs, cpuUs, 100L, 1, maxDop);
}

private static Task PlantQueryStoreAsync(
NpgsqlConnection connection, CancellationToken ct, DateTime at, DateTime firstExecution, long queryId, string moduleName, long intervalId, long executions, long avgDurationUs) =>
DarlingMcpTestData.ExecAsync(connection, ct,
@"INSERT INTO query_store_stats (collection_id, collection_time, server_id, server_name, database_name,
query_id, plan_id, runtime_stats_interval_id, first_execution_time,
last_execution_time, execution_type_desc, query_hash, query_plan_hash,
module_name, execution_count, avg_duration_us, avg_cpu_time_us, query_text)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18)",
CollectionIdGenerator.Next(), at, ServerId, ServerName, Db,
queryId, queryId * 10, intervalId, firstExecution,
at, "Regular", $"0xQS{queryId}", $"0xQSP{queryId}",
moduleName, executions, avgDurationUs, avgDurationUs / 2, $"SELECT {queryId}");

private static Task PlantSnapshotAsync(
NpgsqlConnection connection, CancellationToken ct, DateTime at, int sessionId, string database, string text, int blockingSessionId, long cpuMs) =>
DarlingMcpTestData.ExecAsync(connection, ct,
Expand All @@ -362,7 +449,7 @@ INSERT INTO collection_log

private static async Task DeleteRowsAsync(NpgsqlConnection connection, CancellationToken ct)
{
var sql = string.Join(" ", new[] { "query_stats", "query_snapshots", "collection_log", "deadlocks" }
var sql = string.Join(" ", new[] { "query_stats", "query_store_stats", "query_snapshots", "collection_log", "deadlocks" }
.Select(tbl => $"DELETE FROM {tbl} WHERE server_id = {ServerId};"));
sql += " DELETE FROM config_collector_schedules WHERE server_id IS NULL AND collector_name = 'deadlocks' AND retention_days = 10;";
sql += $" DELETE FROM servers WHERE server_id = {ServerId};";
Expand Down
4 changes: 3 additions & 1 deletion Darling/Darling.Tests/McpPayloadContractCensusTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1720,7 +1720,9 @@ public void TheAutovacuumRunHistory_BindsToItsCapAndObserves()
/// </summary>
public static readonly (string File, string Idiom, int Blocks)[] WindowFloorBlocks =
[
("DarlingMcpDataTools.cs", "initializer", 1),
/* Two: get_query_store_top's payload, and (#4057) its module_name miss, which hands back the window it
read as hints so "no rows matched" is never read as a claim about the part the raw tier no longer holds. */
("DarlingMcpDataTools.cs", "initializer", 2),
("DarlingMcpQueryStoreClutterTools.cs", "initializer", 1),
("DarlingMcpTrendTools.cs", "envelope", 1),
("DarlingMcpTrendTools.cs", "initializer", 1),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ param get_query_store_top.as_of 167
param get_query_store_top.database_name 30
param get_query_store_top.execution_type 72
param get_query_store_top.hours_back 29
param get_query_store_top.module_name 186
param get_query_store_top.server_name 28
param get_query_store_top.top 34

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1950,7 +1950,7 @@ private static CatalogRead R(string category, string description, params Catalog
["get_query_heatmap"] = R(CatData, "Query counts per (time bin x log-magnitude bucket) - the viewer's Query Heatmap as a table.", PServer(), PHours(24), PText("metric"), PText("database_name"), PInt("bucket_minutes", 5), PLimit(500), PAsOf()),
["get_query_store_regressions"] = R(CatData, "Queries whose Query Store performance got WORSE vs their baseline.", PServer(), PHours(24), PText("database_name"), PLimit(50), PAsOf()),
["get_query_store_clutter"] = R(CatData, "The Query Store CLUTTER view: per database the collector's read cost (how often and by how much it was the slowest fan-out item), plan churn (plans per query, arrivals per day, one-shot plans) and the options row, each with raw numbers and a decomposed verdict; ONE per-server overhead block (non-sleep QDS_* wait deltas with the excluded sleep waits named, and the Query Store memory clerk). Replicas excluded by architecture with the reason on the row; query_capture_mode (ALL / AUTO / CUSTOM / NONE) carried with capture_mode_known beside it, null meaning a capture older than the V137 rung rather than NONE. window_truncated says the raw tier did not hold the whole window. Composed from collected rows - no new query against the server.", PServer(), PHours(24), PLimit(DarlingMcpQueryStoreClutterTools.DefaultLimit), PBool("include_fleet_median", false), PAsOf()),
["get_query_store_top"] = R(CatData, "Top Query Store queries in the window, optionally filtered by execution outcome; window_truncated says the raw tier did not hold the whole window (effective_hours_back how far it reached).", PServer(), PHours(24), PTop(20), PText("database_name"), PAsOf(), PText("execution_type")),
["get_query_store_top"] = R(CatData, "Top Query Store queries in the window, optionally filtered by execution outcome or to one exact module before ranking; window_truncated says the raw tier did not hold the whole window (effective_hours_back how far it reached).", PServer(), PHours(24), PTop(20), PText("database_name"), PAsOf(), PText("execution_type"), PText("module_name")),
["get_long_query_completions"] = R(CatData, "Completed long-running queries captured by the XE trace.", PServer(), PHours(24), PLimit(30), PAsOf()),
["get_server_properties"] = R(CatData, "Server properties/inventory for a server.", PServer()),
["get_tempdb_trend"] = R(CatData, "tempdb space usage over time.", PServer(), PHours(24), PAsOf(), PInt("bucket_minutes")),
Expand Down Expand Up @@ -2659,7 +2659,7 @@ logger is the tool's logger seat — the web host's SERVICE logger when MapAll b
["get_query_heatmap"] = (c, pg, an) => DarlingMcpQueryHeatmapTools.GetQueryHeatmap(pg, Server(c), Hours(c, 24), Str(c, "metric"), Str(c, "database_name"), QueryInt(c, "bucket_minutes", null, 5), Rows(c, "limit", 500), as_of: AsOf(c)),
["get_query_store_regressions"] = (c, pg, an) => DarlingMcpQueryStoreRegressionTools.GetQueryStoreRegressions(pg, Server(c), Hours(c, 24), Str(c, "database_name"), Rows(c, "limit", 50), as_of: AsOf(c)),
["get_query_store_clutter"] = (c, pg, an) => DarlingMcpQueryStoreClutterTools.GetQueryStoreClutter(pg, Server(c), Hours(c, 24), Rows(c, "limit", DarlingMcpQueryStoreClutterTools.DefaultLimit), QueryBool(c, "include_fleet_median", false), AsOf(c)),
["get_query_store_top"] = (c, pg, an) => DarlingMcpDataTools.GetQueryStoreTop(pg, Server(c), Hours(c, 24), Rows(c, "top", 20), Str(c, "database_name"), as_of: AsOf(c), execution_type: Str(c, "execution_type")),
["get_query_store_top"] = (c, pg, an) => DarlingMcpDataTools.GetQueryStoreTop(pg, Server(c), Hours(c, 24), Rows(c, "top", 20), Str(c, "database_name"), as_of: AsOf(c), execution_type: Str(c, "execution_type"), module_name: Str(c, "module_name")),
["get_long_query_completions"] = (c, pg, an) => DarlingMcpLongQueryTools.GetLongQueryCompletions(pg, Server(c), Hours(c, 24), Rows(c, "limit", 30), as_of: AsOf(c)),
["get_server_properties"] = (c, pg, an) => DarlingMcpDataTools.GetServerProperties(pg, Server(c)),
["get_tempdb_trend"] = (c, pg, an) => OptionalInt(c, "bucket_minutes", out var bucketMinutes)
Expand Down
Loading
Loading