diff --git a/Darling/Darling.Tests/DarlingQueryHeatmapTests.cs b/Darling/Darling.Tests/DarlingQueryHeatmapTests.cs index 725a080f64..17e4e36e27 100644 --- a/Darling/Darling.Tests/DarlingQueryHeatmapTests.cs +++ b/Darling/Darling.Tests/DarlingQueryHeatmapTests.cs @@ -11,12 +11,15 @@ using System.ComponentModel; using System.Linq; using System.Reflection; +using System.Security.Cryptography; +using System.Text; using System.Text.Json; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.DependencyInjection; using ModelContextProtocol.Server; using Npgsql; +using NpgsqlTypes; using PerformanceMonitor.Collectors; using PerformanceMonitor.Common; using PerformanceMonitor.Darling.Service.Mcp; @@ -151,10 +154,19 @@ public void HeatmapSql_KeepsTheViewersMagnitudeBands_AndFilters() } Assert.Contains("delta_execution_count > 0", sql, StringComparison.Ordinal); - /* Bound, not a literal 120 (#4198) — see HeatmapSql_PreviewWidthIsABoundParameter below. */ - Assert.Contains("LEFT(query_text, $7) AS query_preview", sql, StringComparison.Ordinal); + + /* #4233: base reads the fact table directly, never v_query_stats (#1767's payload-resolving + view), so no row in the window pays for the query_text_dim join or a truncation it will never + be shown. */ + Assert.Contains("FROM query_stats", sql, StringComparison.Ordinal); + Assert.DoesNotContain("v_query_stats", sql, StringComparison.Ordinal); + + /* The preview is resolved for the rn = 1 row of each cell only, at the #4198 bound width - inline + text when the row predates #1767, the dimension row when it does not, truncated last. */ + Assert.Contains( + "LEFT(COALESCE(query_text, (SELECT d.query_text FROM query_text_dim d WHERE d.digest = ranked.query_text_digest)), $7) AS top_query_text", + sql, StringComparison.Ordinal); Assert.DoesNotContain("LEFT(query_text, 120)", sql, StringComparison.Ordinal); - Assert.Contains("FROM v_query_stats", sql, StringComparison.Ordinal); /* DuckDB's ARG_MAX has no Postgres equivalent; the viewer's replacement is a top-1 window over the cell, with the cell's count carried alongside so one pass yields both. */ @@ -163,6 +175,28 @@ public void HeatmapSql_KeepsTheViewersMagnitudeBands_AndFilters() Assert.Contains("WHERE rn = 1", sql, StringComparison.Ordinal); } + /// + /// #4233's source pin: base carries the raw digest and the raw inline text (pre-#1767 rows) + /// through the window sorts, never a resolved-and-truncated preview - that resolution happens once, + /// in the outer SELECT, only for the row each cell keeps. Fails against the pre-#4233 SQL, which + /// computed LEFT(query_text, $7) AS query_preview inside base for every row. + /// + [Fact] + public void BaseCte_CarriesTheDigestAndInlineText_NeverATruncatedPreview() + { + foreach (var metric in AllMetrics) + { + var sql = DarlingQueryHeatmapReader.BuildQueryHeatmapSql(metric); + var baseCte = sql[..sql.IndexOf("binned AS (", StringComparison.Ordinal)]; + + Assert.DoesNotContain("query_preview", baseCte, StringComparison.Ordinal); + Assert.DoesNotContain("LEFT(", baseCte, StringComparison.Ordinal); + Assert.Contains("query_text,", baseCte, StringComparison.Ordinal); + Assert.Contains("query_text_digest", baseCte, StringComparison.Ordinal); + Assert.Contains("FROM query_stats", baseCte, StringComparison.Ordinal); + } + } + /// /// The cap orders NEWEST bin first. An MCP read has a row cap the desktop chart does not, and cutting the /// recent end of an incident window is the one thing a capped heatmap must not do. @@ -245,17 +279,19 @@ public void UnknownMetricNames_AreRefused(string name) /// table: query_stats is PERIODIC, so no rows really does mean nobody looked. /// [Fact] - public void CoverageSql_ProbesTheSameView_ForEverAndForTheWindow() + public void CoverageSql_ProbesTheSameTable_ForEverAndForTheWindow() { var sql = DarlingQueryHeatmapReader.HeatmapCoverageSql; - Assert.Equal(2, CountOf(sql, "FROM v_query_stats")); + Assert.Equal(2, CountOf(sql, "FROM query_stats")); Assert.Equal(2, CountOf(sql, "EXISTS (")); Assert.Contains("collection_time >= $2", sql, StringComparison.Ordinal); Assert.Contains("collection_time <= $3", sql, StringComparison.Ordinal); - /* The read itself is over the view, so the probe must be too - a probe on the base table could say - "collected" about rows the read cannot see. */ - Assert.Contains("FROM v_query_stats", DarlingQueryHeatmapReader.BuildQueryHeatmapSql(HeatmapMetric.Duration), StringComparison.Ordinal); + /* #4233 moved the read itself off v_query_stats onto query_stats directly; the probe follows so + this stays true by construction, not just by the (still valid) argument that the view's LEFT + JOINs never drop a fact row. */ + Assert.Contains("FROM query_stats", DarlingQueryHeatmapReader.BuildQueryHeatmapSql(HeatmapMetric.Duration), StringComparison.Ordinal); + Assert.DoesNotContain("v_query_stats", DarlingQueryHeatmapReader.BuildQueryHeatmapSql(HeatmapMetric.Duration), StringComparison.Ordinal); /* The "ever" arm is deliberately UNBOUNDED - it is the question "did anyone ever collect this server", and bounding it would make a quiet window look like a missing collector. */ @@ -574,4 +610,234 @@ private static async Task DeleteRowsAsync(NpgsqlConnection connection, Cancellat 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); } + + private const int ParityServerId = -949558; + private const string ParityServerName = "query-heatmap-parity"; + private const string ParityDb = "AppDb"; + + /// + /// #4233 ruling item 4's live pin: the pre-#4233 SQL (fetched via git show origin/dev:... before + /// this PR's rewrite landed, pinned below as so it cannot silently rot) + /// and #4233's new must return IDENTICAL + /// cells, counts, top hash and preview text over the same seeded window - proving the rewrite (read + /// query_stats directly, resolve the preview only for the rn = 1 row) is a performance change with no + /// behavior change, by measurement rather than by argument. One window carries all three shapes the + /// ruling asked for, each in its own 5-minute bin so they cannot interact: + /// + /// (a) a winner whose text lives only in query_text_dim - digest set, inline query_text NULL + /// (b) a pre-#1767 winner - inline query_text set, digest NULL + /// (c) a tie inside one cell - two rows with equal delta_execution_count, so ROW_NUMBER's + /// tiebreak is unspecified by either query, and the old and the new SQL have different plans, so + /// PostgreSQL does not promise they pick the same row. The pin is only that each query's winner is + /// one of the two tied rows, with the hash and preview text that belong to that same row. + /// + /// + [Fact] + public async Task OldSqlFromDev_AndTheNewBuilder_AgreeOnCellsCountsHashAndPreview() + { + var cs = ConnectionString; + Assert.SkipWhen(string.IsNullOrEmpty(cs), + "Set DARLING_TEST_PG to a Postgres connection string to run the live heatmap parity test."); + + var ct = TestContext.Current.CancellationToken; + using var connection = new NpgsqlConnection(cs); + await connection.OpenAsync(ct); + await PgMigrations.MigrateAsync(connection, ct); + + var dimDigest = SHA256.HashData(Encoding.UTF8.GetBytes($"#4233 parity dim winner {ParityServerId}")); + await DeleteParityRowsAsync(connection, dimDigest, ct); + + var bodySucceeded = false; + try + { + await DarlingMcpTestData.RegisterServerAsync(connection, ParityServerId, ParityServerName, ct); + var t0 = FloorToHour(DarlingMcpTestData.TruncateToSeconds(DateTime.UtcNow).AddHours(-6)); + + /* (a) dimension-resolved winner. */ + await InsertDimTextAsync(connection, ct, dimDigest, "SELECT dim_winner_text FROM t"); + await SeedParityRowAsync(connection, ct, t0, "0xDIMWIN", deltaExec: 9, deltaElapsed: 450_000, queryText: null, digest: dimDigest); + + /* (b) pre-#1767 winner: inline text, no digest. */ + await SeedParityRowAsync(connection, ct, t0.AddMinutes(5), "0xINLINEWIN", deltaExec: 7, deltaElapsed: 350_000, queryText: "SELECT inline_winner_text", digest: null); + + /* (c) a tie: equal delta_execution_count, same cell. */ + await SeedParityRowAsync(connection, ct, t0.AddMinutes(10), "0xTIEA", deltaExec: 3, deltaElapsed: 150_000, queryText: "SELECT tie_a", digest: null); + await SeedParityRowAsync(connection, ct, t0.AddMinutes(10), "0xTIEB", deltaExec: 3, deltaElapsed: 150_000, queryText: "SELECT tie_b", digest: null); + + var start = t0.AddMinutes(-1); + var end = t0.AddMinutes(20); + + var oldRows = await RunHeatmapSqlAsync(connection, PreQ4233HeatmapSql, ParityServerId, start, end, ct); + var newRows = await RunHeatmapSqlAsync(connection, DarlingQueryHeatmapReader.BuildQueryHeatmapSql(HeatmapMetric.Duration), ParityServerId, start, end, ct); + + Assert.Equal(3, oldRows.Count); + Assert.Equal(oldRows.Count, newRows.Count); + + var tieBin = t0.AddMinutes(10); + var tiedPreviewByHash = new Dictionary + { + ["0xTIEA"] = "SELECT tie_a", + ["0xTIEB"] = "SELECT tie_b", + }; + + for (var i = 0; i < oldRows.Count; i++) + { + Assert.Equal(oldRows[i].TimeBin, newRows[i].TimeBin); + Assert.Equal(oldRows[i].BucketIndex, newRows[i].BucketIndex); + Assert.Equal(oldRows[i].Count, newRows[i].Count); + + if (newRows[i].TimeBin == tieBin) + { + /* PostgreSQL does not order a ROW_NUMBER tie, and the two plans differ, so the old and + the new SQL can pick different tied rows. Each must pick one of the two, with the + hash and preview text of that row. */ + Assert.Contains(oldRows[i].Hash, tiedPreviewByHash.Keys); + Assert.Equal(tiedPreviewByHash[oldRows[i].Hash], oldRows[i].Text); + Assert.Contains(newRows[i].Hash, tiedPreviewByHash.Keys); + Assert.Equal(tiedPreviewByHash[newRows[i].Hash], newRows[i].Text); + continue; + } + + Assert.Equal(oldRows[i].Hash, newRows[i].Hash); + Assert.Equal(oldRows[i].Text, newRows[i].Text); + } + + /* Confirm the agreement above is not two queries independently returning the WRONG (but + matching) answer - check the new builder's rows against what was actually seeded. */ + var dimCell = newRows.Single(r => r.TimeBin == t0); + Assert.Equal("0xDIMWIN", dimCell.Hash); + Assert.Equal("SELECT dim_winner_text FROM t", dimCell.Text); + + var inlineCell = newRows.Single(r => r.TimeBin == t0.AddMinutes(5)); + Assert.Equal("0xINLINEWIN", inlineCell.Hash); + Assert.Equal("SELECT inline_winner_text", inlineCell.Text); + + var tieCell = newRows.Single(r => r.TimeBin == t0.AddMinutes(10)); + Assert.Equal(2, tieCell.Count); + Assert.Contains(tieCell.Hash, new[] { "0xTIEA", "0xTIEB" }); + + bodySucceeded = true; + } + finally + { + await LiveStoreCleanup.RunAsync(cs!, bodySucceeded, async (cleanup, cleanupCt) => + await DeleteParityRowsAsync(cleanup, dimDigest, cleanupCt)); + } + } + + /// + /// The pre-#4233 shape of , copied from + /// `git show origin/dev:Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingQueryHeatmapReader.cs` + /// before this PR's rewrite landed there, with the metric expression resolved to Duration inline so the + /// comparison needs no template parameter. Reads v_query_stats (#1767's payload-resolving view) and + /// truncates query_text to a preview INSIDE base, for every row in the window - the shape #4233 replaced. + /// + private const string PreQ4233HeatmapSql = """ + WITH base AS ( + SELECT + date_bin(($5::integer * INTERVAL '1 minute'), collection_time, TIMESTAMP '1970-01-01 00:00:00') AS time_bin, + (delta_elapsed_time / 1000.0) / NULLIF(delta_execution_count, 0) AS metric_value, + query_hash, + LEFT(query_text, $7) AS query_preview, + delta_execution_count + FROM v_query_stats + WHERE server_id = $1 + AND collection_time >= $2 + AND collection_time <= $3 + AND ($4::text[] IS NULL OR database_name = ANY($4)) + AND delta_execution_count > 0 + AND (delta_elapsed_time / 1000.0) / NULLIF(delta_execution_count, 0) IS NOT NULL + ), + binned AS ( + SELECT + time_bin, + CASE + WHEN metric_value < 1 THEN 0 + WHEN metric_value < 10 THEN 1 + WHEN metric_value < 100 THEN 2 + WHEN metric_value < 1000 THEN 3 + WHEN metric_value < 10000 THEN 4 + WHEN metric_value < 100000 THEN 5 + ELSE 6 + END AS bucket_index, + query_hash, + query_preview, + delta_execution_count + FROM base + ), + ranked AS ( + SELECT + time_bin, + bucket_index, + query_hash, + query_preview, + COUNT(*) OVER (PARTITION BY time_bin, bucket_index) AS query_count, + ROW_NUMBER() OVER (PARTITION BY time_bin, bucket_index ORDER BY delta_execution_count DESC) AS rn + FROM binned + ) + SELECT + time_bin, + bucket_index, + query_count, + query_hash AS top_query_hash, + query_preview AS top_query_text + FROM ranked + WHERE rn = 1 + ORDER BY time_bin DESC, bucket_index + LIMIT $6 + """; + + private static async Task SeedParityRowAsync( + NpgsqlConnection connection, CancellationToken ct, DateTime collectionTime, string queryHash, + long deltaExec, long deltaElapsed, string? queryText, byte[]? digest) => + await DarlingMcpTestData.ExecAsync(connection, ct, @" +INSERT INTO query_stats + (collection_id, collection_time, server_id, server_name, database_name, query_hash, + sample_interval_seconds, delta_execution_count, delta_worker_time, delta_elapsed_time, + delta_logical_reads, delta_logical_writes, query_text, query_text_digest) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)", + CollectionIdGenerator.Next(), DarlingMcpTestData.Naive(collectionTime), ParityServerId, ParityServerName, + ParityDb, queryHash, 60, deltaExec, 0L, deltaElapsed, 0L, 0L, queryText, digest); + + private static async Task InsertDimTextAsync(NpgsqlConnection connection, CancellationToken ct, byte[] digest, string text) => + await DarlingMcpTestData.ExecAsync(connection, ct, + "INSERT INTO query_text_dim (digest, query_text, last_seen) VALUES ($1, $2, $3) ON CONFLICT (digest) DO NOTHING", + digest, text, DarlingMcpTestData.Naive(DateTime.UtcNow)); + + /// Runs either heatmap SQL text with the same seven bound parameters ($4 database filter NULL, + /// $5 bucket_minutes 5, $6 limit 500, $7 preview length 120) and returns the cells sorted by + /// (time_bin, bucket_index) - sorted explicitly rather than trusting each query's own ORDER BY, so the + /// comparison is over the same SET of cells rather than incidentally over matching physical order. + private static async Task> RunHeatmapSqlAsync( + NpgsqlConnection connection, string sql, int serverId, DateTime start, DateTime end, CancellationToken ct) + { + var rows = new List<(DateTime TimeBin, int BucketIndex, long Count, string Hash, string Text)>(); + await using var command = new NpgsqlCommand(sql, connection); + DarlingMcpReadParameters.AddWindow(command, serverId, start, end); + command.Parameters.Add(new NpgsqlParameter { NpgsqlDbType = NpgsqlDbType.Array | NpgsqlDbType.Text, Value = DBNull.Value }); + command.Parameters.Add(new NpgsqlParameter { TypedValue = 5 }); + command.Parameters.Add(new NpgsqlParameter { TypedValue = 500 }); + command.Parameters.Add(new NpgsqlParameter { TypedValue = 120 }); + + await using var reader = await command.ExecuteReaderAsync(ct); + while (await reader.ReadAsync(ct)) + { + rows.Add(( + reader.GetDateTime(0), + reader.IsDBNull(1) ? 0 : Convert.ToInt32(reader.GetValue(1)), + reader.IsDBNull(2) ? 0 : Convert.ToInt64(reader.GetValue(2)), + reader.IsDBNull(3) ? "" : reader.GetString(3), + reader.IsDBNull(4) ? "" : reader.GetString(4))); + } + + return rows.OrderBy(r => r.TimeBin).ThenBy(r => r.BucketIndex).ToList(); + } + + private static async Task DeleteParityRowsAsync(NpgsqlConnection connection, byte[] dimDigest, CancellationToken ct) + { + await DarlingMcpTestData.ExecAsync(connection, ct, "DELETE FROM query_stats WHERE server_id = $1", ParityServerId); + await DarlingMcpTestData.ExecAsync(connection, ct, "DELETE FROM servers WHERE server_id = $1", ParityServerId); + await DarlingMcpTestData.ExecAsync(connection, ct, "DELETE FROM config_monitored_servers WHERE server_id = $1", ParityServerId); + await DarlingMcpTestData.ExecAsync(connection, ct, "DELETE FROM query_text_dim WHERE digest = $1", dimDigest); + } } diff --git a/Darling/Darling.Tests/QueryHeatmapSqlTwinParityTests.cs b/Darling/Darling.Tests/QueryHeatmapSqlTwinParityTests.cs new file mode 100644 index 0000000000..04af9041b5 --- /dev/null +++ b/Darling/Darling.Tests/QueryHeatmapSqlTwinParityTests.cs @@ -0,0 +1,79 @@ +/* + * 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 Xunit; + +namespace Darling.Tests; + +/// +/// #4233 ruling item 2: BuildQueryHeatmapSql is hand-copied into two files - the WPF viewer's chart +/// () and the MCP/web reader behind +/// get_query_heatmap () - +/// because the headless service must not reference the WPF viewer. A hand-edit that changes only one copy +/// is exactly the kind of drift a reviewer can miss in a large diff, so this pins the two byte-identical +/// through the final WHERE rn = 1 filter - base/binned/ranked, the magnitude CASE, the top-1 window, +/// and #4233's own COALESCE/query_text_dim subquery - once the differences that predate #4233 are +/// normalized away: +/// +/// the bin width: a literal 5-minute interval in the viewer vs. the reader's bound $5 parameter; +/// the preview width: a literal 120 in the viewer vs. the reader's bound $7 parameter (#4198). +/// +/// The ORDER BY/LIMIT tail AFTER WHERE rn = 1 is a third, deliberate difference (the +/// reader caps and orders newest-first; the viewer has no cap and orders ascending) and is pinned on its +/// own by DarlingQueryHeatmapSurfaceAndSqlTests.HeatmapSql_CapsFromTheRecentEnd, so it is excluded +/// here by cutting the string rather than normalized. +/// +/// The two HeatmapMetric enums are kept fully qualified rather than imported side by side - +/// each namespace declares its own (the service must not reference the viewer's), and importing both would +/// make every unqualified HeatmapMetric in this file ambiguous. Casting the shared underlying int is +/// how "the same argument" reaches both builders without that ambiguity. +/// +/// Proven live, not just by construction: temporarily changing one character inside either copy's +/// COALESCE/query_text_dim subquery text made this test fail before the character was reverted. +/// +public sealed class QueryHeatmapSqlTwinParityTests +{ + private const string RnFilter = "WHERE rn = 1"; + + [Theory] + [InlineData(0)] // Duration + [InlineData(1)] // Cpu + [InlineData(2)] // LogicalReads + [InlineData(3)] // LogicalWrites + [InlineData(4)] // ExecutionCount + public void BothCopies_AreByteIdentical_ThroughTheRnFilter(int metric) + { + var viewerSql = PerformanceMonitor.Darling.Viewer.ViewerDataService.BuildQueryHeatmapSql( + (PerformanceMonitor.Darling.Viewer.HeatmapMetric)metric); + var readerSql = PerformanceMonitor.Darling.Service.Mcp.DarlingQueryHeatmapReader.BuildQueryHeatmapSql( + (PerformanceMonitor.Darling.Service.Mcp.HeatmapMetric)metric); + + /* Both known normalizations must actually fire - if a future edit changes the literal/parameter + spelling on either side, this catches the normalization silently becoming a no-op, which the + Assert.Equal below alone could miss if an unrelated one-sided edit happened to cancel it out. */ + const string ReaderBinWidth = "date_bin(($5::integer * INTERVAL '1 minute'), collection_time, TIMESTAMP '1970-01-01 00:00:00')"; + const string ViewerBinWidth = "date_bin(INTERVAL '5 minutes', collection_time, TIMESTAMP '1970-01-01 00:00:00')"; + const string ReaderPreviewWidth = "query_text_digest)), $7) AS top_query_text"; + const string ViewerPreviewWidth = "query_text_digest)), 120) AS top_query_text"; + + Assert.Contains(ReaderBinWidth, readerSql, StringComparison.Ordinal); + Assert.Contains(ViewerBinWidth, viewerSql, StringComparison.Ordinal); + Assert.Contains(ReaderPreviewWidth, readerSql, StringComparison.Ordinal); + Assert.Contains(ViewerPreviewWidth, viewerSql, StringComparison.Ordinal); + + var normalizedReader = readerSql + .Replace(ReaderBinWidth, ViewerBinWidth, StringComparison.Ordinal) + .Replace(ReaderPreviewWidth, ViewerPreviewWidth, StringComparison.Ordinal); + + var viewerHead = viewerSql[..(viewerSql.IndexOf(RnFilter, StringComparison.Ordinal) + RnFilter.Length)]; + var readerHead = normalizedReader[..(normalizedReader.IndexOf(RnFilter, StringComparison.Ordinal) + RnFilter.Length)]; + + Assert.Equal(viewerHead, readerHead); + } +} diff --git a/Darling/Darling.Tests/ViewerQueriesRestTests.cs b/Darling/Darling.Tests/ViewerQueriesRestTests.cs index d108713746..98082d459c 100644 --- a/Darling/Darling.Tests/ViewerQueriesRestTests.cs +++ b/Darling/Darling.Tests/ViewerQueriesRestTests.cs @@ -227,12 +227,17 @@ public void HeatmapSql_RewritesArgMaxToTop1Window_ForEveryMetric() public void HeatmapSql_KeepsLitesMagnitudeBuckets_Filters_And_Preview() { var sql = ViewerDataService.BuildQueryHeatmapSql(HeatmapMetric.Duration); - /* v_query_stats, not the base table (#1767): the cell preview is LEFT(query_text, 120), and the - base table's inline query_text is NULL on every row written since the migration — the heatmap - would render with blank previews rather than fail. */ - Assert.Contains("FROM v_query_stats", sql, StringComparison.Ordinal); + /* #4233: base reads the fact table directly, not v_query_stats (#1767) - no row in the window + pays for the query_text_dim join or a preview truncation it will never be shown. The preview + is resolved only for the rn = 1 row of each cell: inline query_text when the row predates + #1767, the query_text_dim lookup when it does not (post-#1767 rows leave the inline column + NULL), truncated last - the same resolution v_query_stats performs, just not for every row. */ + Assert.Contains("FROM query_stats", sql, StringComparison.Ordinal); + Assert.DoesNotContain("v_query_stats", sql, StringComparison.Ordinal); Assert.Contains("delta_execution_count > 0", sql, StringComparison.Ordinal); - Assert.Contains("LEFT(query_text, 120) AS query_preview", sql, StringComparison.Ordinal); + Assert.Contains( + "LEFT(COALESCE(query_text, (SELECT d.query_text FROM query_text_dim d WHERE d.digest = ranked.query_text_digest)), 120) AS top_query_text", + sql, StringComparison.Ordinal); /* The 7-way log-magnitude CASE (only the boundaries are pinned). */ Assert.Contains("WHEN metric_value < 1 THEN 0", sql, StringComparison.Ordinal); Assert.Contains("WHEN metric_value < 100000 THEN 5", sql, StringComparison.Ordinal); @@ -490,6 +495,10 @@ public async Task QueryHeatmap_BinsByTime_BucketsByMagnitude_TopQueryByExecution Assert.Equal(1.0, result.Intensities[0, 0]); /* bucket 0: 0xLOW */ Assert.Equal("0xHOT", result.CellDetails[2, 0].TopQueryHash); /* ARG_MAX replacement: higher delta_exec wins */ Assert.Equal("0xLOW", result.CellDetails[0, 0].TopQueryHash); + /* #4233: the preview still resolves end-to-end through the C# reader for the winning row, + even though base no longer carries a pre-truncated column. */ + Assert.Equal("SELECT hot", result.CellDetails[2, 0].TopQueryText); + Assert.Equal("SELECT low", result.CellDetails[0, 0].TopQueryText); /* 0xZERO (delta_exec = 0) contributed to no cell. */ Assert.Equal(0.0, result.Intensities[6, 0]); diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingQueryHeatmapReader.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingQueryHeatmapReader.cs index f72ff82d6f..c4d9b59f0b 100644 --- a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingQueryHeatmapReader.cs +++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingQueryHeatmapReader.cs @@ -60,6 +60,15 @@ public enum HeatmapMetric /// nobody looked, and an existence probe on the data is the right denominator (see /// ). /// +/// #4233: the preview is resolved for the rn = 1 row of each cell only, not every row in the +/// window. base reads query_stats directly rather than v_query_stats (#1767's +/// payload-resolving view), so no row pays for the query_text_dim join or a truncation it will +/// never be shown - the raw inline query_text (pre-#1767 rows only) and the digest ride the +/// window sorts in its place. The outer rn = 1 filter runs before the SELECT list, so +/// LEFT(COALESCE(query_text, (SELECT ... FROM query_text_dim ...)), $7) - the same resolution +/// v_query_stats would have performed, at the same #4198 bound width - executes only for the +/// one row per cell the caller sees. +/// /// The SQL is built by a public method so the tests can pin the dialect and the shape without a live /// Postgres. /// @@ -180,9 +189,10 @@ WITH base AS ( date_bin(($5::integer * INTERVAL '1 minute'), collection_time, TIMESTAMP '1970-01-01 00:00:00') AS time_bin, {metricExpr} AS metric_value, query_hash, - LEFT(query_text, $7) AS query_preview, + query_text, + query_text_digest, delta_execution_count - FROM v_query_stats + FROM query_stats WHERE server_id = $1 AND collection_time >= $2 AND collection_time <= $3 @@ -203,7 +213,8 @@ WHEN metric_value < 100000 THEN 5 ELSE 6 END AS bucket_index, query_hash, - query_preview, + query_text, + query_text_digest, delta_execution_count FROM base ), @@ -212,7 +223,8 @@ ranked AS ( time_bin, bucket_index, query_hash, - query_preview, + query_text, + query_text_digest, COUNT(*) OVER (PARTITION BY time_bin, bucket_index) AS query_count, ROW_NUMBER() OVER (PARTITION BY time_bin, bucket_index ORDER BY delta_execution_count DESC) AS rn FROM binned @@ -222,7 +234,7 @@ FROM binned bucket_index, query_count, query_hash AS top_query_hash, - query_preview AS top_query_text + LEFT(COALESCE(query_text, (SELECT d.query_text FROM query_text_dim d WHERE d.digest = ranked.query_text_digest)), $7) AS top_query_text FROM ranked WHERE rn = 1 ORDER BY time_bin DESC, bucket_index @@ -238,19 +250,21 @@ FROM ranked /// writes a row every cycle for whatever sits in the plan cache, so a server with zero rows in its whole /// history is a server nobody collected — unlike blocking or deadlocks, where zero rows is the healthy /// answer and a data probe would send someone to fix collection that works. - /// Probes v_query_stats, the same relation the read itself uses, so the probe cannot - /// disagree with the read about which rows exist. $1 server_id, $2 window start, $3 window end. + /// Probes query_stats directly (#4233 moved the read itself off v_query_stats + /// onto the fact table; the view's payload-dimension joins are LEFT JOINs, so they never drop a + /// fact row, and existence here is identical either way - this keeps the probe's FROM matching the + /// read's literally rather than by that argument). $1 server_id, $2 window start, $3 window end. /// public const string HeatmapCoverageSql = """ SELECT EXISTS ( SELECT 1 - FROM v_query_stats + FROM query_stats WHERE server_id = $1 ) AS has_any, EXISTS ( SELECT 1 - FROM v_query_stats + FROM query_stats WHERE server_id = $1 AND collection_time >= $2 AND collection_time <= $3 diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.QueryHeatmap.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.QueryHeatmap.cs index af95663981..186134bd44 100644 --- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.QueryHeatmap.cs +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.QueryHeatmap.cs @@ -87,7 +87,19 @@ public sealed partial class ViewerDataService /// COUNT(*) OVER the same partition carried alongside so one pass yields both the /// count and the top row. Ties resolve arbitrarily, exactly as ARG_MAX did. /// The magnitude CASE, the delta_execution_count > 0 / metric IS NOT NULL filters, - /// the LEFT(query_text, 120) preview, and the $1/$2/$3 placeholders are Lite's verbatim. + /// and the $1/$2/$3 placeholders are Lite's verbatim. + /// + /// #4233: the 120-character preview is resolved for the rn = 1 row of each cell only, + /// not every row in the window. base reads query_stats directly rather than + /// v_query_stats (#1767's payload-resolving view), so no row pays for the + /// query_text_dim join or a truncation it will never be shown — the raw inline + /// query_text (populated only on pre-#1767 rows) and the digest ride the window sorts in + /// its place, which is narrower on almost every row since a post-#1767 row's inline column is + /// NULL. The outer WHERE rn = 1 filters before the SELECT list runs, so + /// LEFT(COALESCE(query_text, (SELECT … FROM query_text_dim …)), 120) — the same resolution + /// v_query_stats would have performed — executes only for the one row per cell the caller + /// sees. Measured on a seeded 24-hour, ~180k-row window: see the PR for before/after + /// EXPLAIN (ANALYZE, BUFFERS). /// public static string BuildQueryHeatmapSql(HeatmapMetric metric) { @@ -98,9 +110,10 @@ WITH base AS ( date_bin(INTERVAL '5 minutes', collection_time, TIMESTAMP '1970-01-01 00:00:00') AS time_bin, {metricExpr} AS metric_value, query_hash, - LEFT(query_text, 120) AS query_preview, + query_text, + query_text_digest, delta_execution_count - FROM v_query_stats + FROM query_stats WHERE server_id = $1 AND collection_time >= $2 AND collection_time <= $3 @@ -121,7 +134,8 @@ WHEN metric_value < 100000 THEN 5 ELSE 6 END AS bucket_index, query_hash, - query_preview, + query_text, + query_text_digest, delta_execution_count FROM base ), @@ -130,7 +144,8 @@ ranked AS ( time_bin, bucket_index, query_hash, - query_preview, + query_text, + query_text_digest, COUNT(*) OVER (PARTITION BY time_bin, bucket_index) AS query_count, ROW_NUMBER() OVER (PARTITION BY time_bin, bucket_index ORDER BY delta_execution_count DESC) AS rn FROM binned @@ -140,7 +155,7 @@ FROM binned bucket_index, query_count, query_hash AS top_query_hash, - query_preview AS top_query_text + LEFT(COALESCE(query_text, (SELECT d.query_text FROM query_text_dim d WHERE d.digest = ranked.query_text_digest)), 120) AS top_query_text FROM ranked WHERE rn = 1 ORDER BY time_bin, bucket_index