From 6470884cd854193aac0a7195d2053f830a95fdc4 Mon Sep 17 00:00:00 2001
From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com>
Date: Fri, 2 Oct 2026 12:35:46 -0400
Subject: [PATCH 001/399] Web viewer: the server page's Range stops at 7 days,
the widest window its reads serve (#4946)
* Web viewer: the server page's Range stops at 7 days, the widest window its reads serve
The Range offered "last 30 days", but all but three of the page's ranged
reads refuse more than 168 hours. At 30 days nearly every panel asked
again for 7 days and said so, and the current-waits trend asked its 7-day
table for 30 days under a "last 30 days" label. The presets are now the
desktop viewers' (1, 4, 12 and 24 hours, 7 days).
WebServerPageRangeTests runs every offered range through every tab of both
registries under Node, against reads that refuse a wider window, and reads
the presets from the source so the rule holds without Node.
* Web viewer: the Blocking Severity chart says where longer history is, and the read-limit notice says what it means
Blocking stats take any window, and their tables keep more than the 7 days the
server page offers, so the Blocking Severity subtitle now says a Custom View can
show more of that history.
The notice a read shows when it asks again for a shorter window said "This view
keeps up to 168 hours (7 days) of history". That is the read's limit, not the
store's history, so it now says "This view reads at most 168 hours (7 days) at a
time". Two comments made the same mix-up, and one said composed panels read
rollups, which only the query tables do.
The Node-driven page tests now report as skipped when Node is not installed,
instead of passing without running.
* Web viewer: one Blocking tab note names the widest Range and points to Custom Views
get_blocking_stats draws both Blocking Severity and Deadlock Severity, and its
tables keep more than the page shows. The pointer moves out of the Blocking
Severity subtitle into the Blocking tab's note, which now covers both charts.
The note takes its number from the widest preset, which server.js hands to
tabNote, so narrowing the presets cannot leave it wrong.
Tests: the census guard against a second refusal now reads the same constant
as the second-refusal pin, so it matches the text the page shows. The offered
range census asserts that named ranged reads were seen for each registry. Six
more Node-driven test helpers report the test as skipped when Node is missing.
---
.../AlertHistoryDetailLabelsTests.cs | 3 +-
.../AlertNotebookRenderBehaviourTests.cs | 1 +
.../AzureSqlDatabaseHardwareTests.cs | 2 +-
Darling/Darling.Tests/ServerPageTabsTests.cs | 4 +-
.../WebAutoRefreshPolicyTests.cs | 3 +-
.../WebDatabaseSizesNoteColumnTests.cs | 2 +-
.../WebPerfmonPerSecondBehaviourTests.cs | 1 +
.../WebRangeKeptHistoryBehaviourTests.cs | 23 ++--
Darling/Darling.Tests/WebRenderSettleTests.cs | 5 +-
.../Darling.Tests/WebServerPageRangeTests.cs | 112 ++++++++++++++++++
.../web-kept-history-harness.mjs | 86 +++++++++++++-
.../wwwroot/js/pages/server-tabs.js | 25 ++--
.../wwwroot/js/pages/server.js | 14 ++-
.../wwwroot/js/util.js | 24 ++--
14 files changed, 261 insertions(+), 44 deletions(-)
create mode 100644 Darling/Darling.Tests/WebServerPageRangeTests.cs
diff --git a/Darling/Darling.Tests/AlertHistoryDetailLabelsTests.cs b/Darling/Darling.Tests/AlertHistoryDetailLabelsTests.cs
index 7e551cdd9d..4ac92d5787 100644
--- a/Darling/Darling.Tests/AlertHistoryDetailLabelsTests.cs
+++ b/Darling/Darling.Tests/AlertHistoryDetailLabelsTests.cs
@@ -45,7 +45,7 @@ public sealed class AlertHistoryDetailLabelsTests
"const parse = new Function(src.slice(start, end) + '\\nreturn parseDetailFields;')();" +
"console.log(JSON.stringify(parse(JSON.parse(process.argv[2]).join('\\n'))));";
- /// Runs the shipped parser over the lines, or returns false when Node is not installed.
+ /// Runs the shipped parser over the lines. When Node is not installed, the test is reported as skipped.
private static bool TryParse(string[] lines, out List<(string? Label, string Value)> fields)
{
fields = new List<(string?, string)>();
@@ -68,6 +68,7 @@ private static bool TryParse(string[] lines, out List<(string? Label, string Val
}
catch (Win32Exception)
{
+ Assert.Skip("Node is not installed, so the shipped page script cannot be run.");
return false;
}
diff --git a/Darling/Darling.Tests/AlertNotebookRenderBehaviourTests.cs b/Darling/Darling.Tests/AlertNotebookRenderBehaviourTests.cs
index 977ea965ff..a5ed50f63f 100644
--- a/Darling/Darling.Tests/AlertNotebookRenderBehaviourTests.cs
+++ b/Darling/Darling.Tests/AlertNotebookRenderBehaviourTests.cs
@@ -41,6 +41,7 @@ private static bool TryRun(string scenario, out JsonElement result)
}
catch (Win32Exception)
{
+ Assert.Skip("Node is not installed, so the shipped page script cannot be run.");
return false;
}
diff --git a/Darling/Darling.Tests/AzureSqlDatabaseHardwareTests.cs b/Darling/Darling.Tests/AzureSqlDatabaseHardwareTests.cs
index 1c9e5fb464..f75b7782ac 100644
--- a/Darling/Darling.Tests/AzureSqlDatabaseHardwareTests.cs
+++ b/Darling/Darling.Tests/AzureSqlDatabaseHardwareTests.cs
@@ -166,7 +166,7 @@ public void WebStatRenderer_DropsTilesByTheirCondition_OnEdition5_AndKeepsEveryT
Process proc;
try { proc = Process.Start(psi)!; }
- catch (Win32Exception) { return; } // Node is not installed; the source pins above still hold the change in place.
+ catch (Win32Exception) { Assert.Skip("Node is not installed, so the shipped page script cannot be run."); return; } // The source pins above still hold the change in place.
using (proc)
{
diff --git a/Darling/Darling.Tests/ServerPageTabsTests.cs b/Darling/Darling.Tests/ServerPageTabsTests.cs
index 99a3395bc7..6be124b6eb 100644
--- a/Darling/Darling.Tests/ServerPageTabsTests.cs
+++ b/Darling/Darling.Tests/ServerPageTabsTests.cs
@@ -885,8 +885,8 @@ public void TheTabsThatCannotDoWhatTheDesktopDoes_SaySo()
Assert.Contains("block-chain view", js, StringComparison.Ordinal);
/* The note renders — a `note` field with no renderer is the same silence in a different place. */
- Assert.Contains("return tab.note ? noticeStrip(tab.note) : null;", js, StringComparison.Ordinal);
- Assert.Contains("tabNote(tab)", ServerJs, StringComparison.Ordinal);
+ Assert.Contains("return note ? noticeStrip(note) : null;", js, StringComparison.Ordinal);
+ Assert.Contains("tabNote(tab, WIDEST_RANGE_HOURS)", ServerJs, StringComparison.Ordinal);
}
///
diff --git a/Darling/Darling.Tests/WebAutoRefreshPolicyTests.cs b/Darling/Darling.Tests/WebAutoRefreshPolicyTests.cs
index 6bd706375e..dfb7044ff1 100644
--- a/Darling/Darling.Tests/WebAutoRefreshPolicyTests.cs
+++ b/Darling/Darling.Tests/WebAutoRefreshPolicyTests.cs
@@ -158,7 +158,8 @@ public void RefreshPolicyJs_BackOffRunsUnderNode()
}
catch (System.ComponentModel.Win32Exception)
{
- // Node is not installed on this machine; RefreshPolicyJs_CarriesTheBackOffConstants still pins the rule.
+ // RefreshPolicyJs_CarriesTheBackOffConstants still pins the rule.
+ Assert.Skip("Node is not installed, so the shipped page script cannot be run.");
}
}
diff --git a/Darling/Darling.Tests/WebDatabaseSizesNoteColumnTests.cs b/Darling/Darling.Tests/WebDatabaseSizesNoteColumnTests.cs
index e552cdd981..6bd2734962 100644
--- a/Darling/Darling.Tests/WebDatabaseSizesNoteColumnTests.cs
+++ b/Darling/Darling.Tests/WebDatabaseSizesNoteColumnTests.cs
@@ -74,7 +74,7 @@ public void TheTableRenderer_DropsAnEmptyOptInColumn_AndKeepsEveryOtherColumn()
Process proc;
try { proc = Process.Start(psi)!; }
- catch (Win32Exception) { return; } // Node is not installed; the source pins above still hold the change in place.
+ catch (Win32Exception) { Assert.Skip("Node is not installed, so the shipped page script cannot be run."); return; } // The source pins above still hold the change in place.
using (proc)
{
diff --git a/Darling/Darling.Tests/WebPerfmonPerSecondBehaviourTests.cs b/Darling/Darling.Tests/WebPerfmonPerSecondBehaviourTests.cs
index cf85897650..45bbd7c321 100644
--- a/Darling/Darling.Tests/WebPerfmonPerSecondBehaviourTests.cs
+++ b/Darling/Darling.Tests/WebPerfmonPerSecondBehaviourTests.cs
@@ -41,6 +41,7 @@ private static bool TryRun(string scenario, out JsonElement result)
}
catch (Win32Exception)
{
+ Assert.Skip("Node is not installed, so the shipped page script cannot be run.");
return false;
}
diff --git a/Darling/Darling.Tests/WebRangeKeptHistoryBehaviourTests.cs b/Darling/Darling.Tests/WebRangeKeptHistoryBehaviourTests.cs
index 02bc0e7902..69a3941a7f 100644
--- a/Darling/Darling.Tests/WebRangeKeptHistoryBehaviourTests.cs
+++ b/Darling/Darling.Tests/WebRangeKeptHistoryBehaviourTests.cs
@@ -18,9 +18,11 @@
namespace Darling.Tests;
///
-/// The server page's Range goes to 30 days, and most reads keep 7 (McpHelpers.MaxHoursBack). A read that
-/// refuses the page's window for that reason is asked again, once, for the hours it keeps, and the panel shows that
-/// data with a notice naming the window it covers. Run from the shipped util.js, panels.js and
+/// Most reads keep 7 days (McpHelpers.MaxHoursBack). The server page's Range stops there
+/// (), but a Custom View's read panel stores its own hours and can still ask
+/// for more. A read that refuses a window for that reason is asked again, once, for the hours it keeps, and the
+/// panel shows that data with a notice naming the window it covers. These drive 30 days into the panels directly,
+/// so every ranged read is proven to take that path. Run from the shipped util.js, panels.js and
/// pages/server-tabs.js under Node (web-kept-history-harness.mjs): the descriptor loader and the
/// hand-built composites, their fetches, notices, errors and chart windows. Node is skipped when it is not
/// installed, the way does; the last test pins the routing in the
@@ -28,9 +30,13 @@ namespace Darling.Tests;
///
public sealed class WebRangeKeptHistoryBehaviourTests
{
- private const string KeptNotice = "This view keeps up to 168 hours (7 days) of history, so it shows the last 7 days.";
+ private const string KeptNotice = "This view reads at most 168 hours (7 days) at a time, so it shows the last 7 days.";
- private static bool TryRun(string scenario, out JsonElement result)
+ /// How the notice ends when the retry is refused too. The second-refusal pin and the census guard both read
+ /// it, so the guard cannot drift from the text the page shows.
+ private const string PickShorter = "Pick a shorter range.";
+
+ internal static bool TryRun(string scenario, out JsonElement result)
{
result = default;
var psi = new ProcessStartInfo("node") { RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false };
@@ -45,6 +51,7 @@ private static bool TryRun(string scenario, out JsonElement result)
}
catch (Win32Exception)
{
+ Assert.Skip("Node is not installed, so the shipped page script cannot be run.");
return false;
}
@@ -138,7 +145,7 @@ public void ARetryThatIsRefusedToo_IsNotAskedAThirdTime()
Assert.Equal(2, Strings(r, "fetches").Length);
/* The second refusal falls back to the #2780 notice, which names the window the read now reports. */
Assert.Equal(
- "This view keeps up to 96 hours (4 days) of history — pick a shorter range.",
+ "This view reads at most 96 hours (4 days) at a time. " + PickShorter,
Assert.Single(Strings(r, "notices")));
Assert.Empty(Strings(r, "errors"));
}
@@ -199,7 +206,7 @@ public void TheViewEditorsSampleRead_IsAskedAgainTheSameWay_SoSaveFindsTheFields
///
/// Every tab of both registries at 30 days, with every read refusing it: each ranged read on the page, whether
/// a descriptor, a fanout or a hand-built composite, is asked exactly once more at 168 hours, and no panel is
- /// left on the "pick a shorter range" notice. The picker reads (wait, counter and query trends) are in the count,
+ /// left on the "Pick a shorter range." notice. The picker reads (wait, counter and query trends) are in the count,
/// so a composite that bypasses the shared helper fails here.
///
[Fact]
@@ -224,7 +231,7 @@ public void EveryRangedReadOnEveryTab_IsAskedAgainOnceAtTheHoursItKeeps()
Assert.Contains(ranged, f => f.StartsWith("/api/read/" + trend + "?", StringComparison.Ordinal) && f.Contains("hours=168", StringComparison.Ordinal));
}
- Assert.DoesNotContain(Strings(r, "notices"), n => n.Contains("pick a shorter range", StringComparison.Ordinal));
+ Assert.DoesNotContain(Strings(r, "notices"), n => n.Contains(PickShorter, StringComparison.Ordinal));
Assert.Contains(KeptNotice, Strings(r, "notices"));
Assert.Empty(Strings(r, "errors"));
Assert.Empty(Strings(r, "rejections"));
diff --git a/Darling/Darling.Tests/WebRenderSettleTests.cs b/Darling/Darling.Tests/WebRenderSettleTests.cs
index 0bf52d8f38..a401b5b732 100644
--- a/Darling/Darling.Tests/WebRenderSettleTests.cs
+++ b/Darling/Darling.Tests/WebRenderSettleTests.cs
@@ -24,7 +24,7 @@ public sealed class WebRenderSettleTests
private static readonly string s_wwwroot = Path.Combine(
"Darling", "PerformanceMonitor.Darling.Service", "wwwroot", "js");
- /// Runs one harness scenario and returns its JSON, or false when Node is not installed.
+ /// Runs one harness scenario and returns its JSON. When Node is not installed, the test is reported as skipped.
private static bool TryRun(string scenario, out JsonElement result)
{
result = default;
@@ -49,7 +49,8 @@ private static bool TryRun(string scenario, out JsonElement result)
}
catch (Win32Exception)
{
- // Node is not installed on this machine; the source-order pin below still holds the fix in place.
+ // The source-order pin below still holds the fix in place.
+ Assert.Skip("Node is not installed, so the shipped page script cannot be run.");
return false;
}
diff --git a/Darling/Darling.Tests/WebServerPageRangeTests.cs b/Darling/Darling.Tests/WebServerPageRangeTests.cs
new file mode 100644
index 0000000000..2672840a62
--- /dev/null
+++ b/Darling/Darling.Tests/WebServerPageRangeTests.cs
@@ -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.Linq;
+using System.Text.RegularExpressions;
+using PerformanceMonitor.Common;
+using Xunit;
+using static Darling.Tests.RepoFile;
+
+namespace Darling.Tests;
+
+///
+/// The server page's Range offers only windows its tabs can serve. All but three of the page's ranged reads refuse
+/// more than hours, so a 30-day choice came back as 7 days on nearly every
+/// panel, each with a notice saying it had asked again. The first test runs every offered range through every tab
+/// of both registries under Node (web-kept-history-harness.mjs), against reads that refuse a wider window the
+/// way the real ones do. The second reads the presets from the source, so it holds where Node is not installed.
+///
+public sealed class WebServerPageRangeTests
+{
+ ///
+ /// Every range the page offers, on every tab of both registries: no ranged read is asked for more hours than it
+ /// takes. A tab added later is built too, and a read it would over-ask fails here by range, tab and read.
+ ///
+ [Fact]
+ public void EveryOfferedRange_IsOneEveryRangedReadOnEveryTabTakes()
+ {
+ if (!WebRangeKeptHistoryBehaviourTests.TryRun("offeredRanges:" + McpHelpers.MaxHoursBack, out var r)) return;
+
+ var offered = r.GetProperty("offered").EnumerateArray().Select(e => e.GetInt32()).ToArray();
+ Assert.NotEmpty(offered);
+
+ var beyond = r.GetProperty("beyond").EnumerateArray().Select(e => e.GetString()!).ToArray();
+ Assert.True(
+ beyond.Length == 0,
+ $"The Range offers {string.Join(", ", offered)} hours, and {beyond.Length} ranged reads would be asked for more"
+ + $" than {McpHelpers.MaxHoursBack} hours, the widest window the capped reads take:\n" + string.Join("\n", beyond.Take(40)));
+
+ Assert.Empty(r.GetProperty("rejections").EnumerateArray());
+
+ /* "Nothing over-asked" must not pass because nothing was asked: each range builds the tabs of both registries,
+ and named ranged reads from each are seen at that range's hours. */
+ var observed = r.GetProperty("observed").EnumerateArray().Select(e => e.GetString()!).ToArray();
+ foreach (var hours in offered)
+ {
+ foreach (var (engine, reads) in s_witnesses)
+ {
+ var seen = observed.Where(o => o.StartsWith($"{hours} {engine} ", System.StringComparison.Ordinal)).ToArray();
+ foreach (var read in reads)
+ {
+ Assert.True(
+ seen.Contains($"{hours} {engine} {read}"),
+ $"At {hours} hours the {engine} tabs never asked {read} for a window ({seen.Length} ranged reads seen there).");
+ }
+ }
+ }
+ }
+
+ /// Ranged reads each registry's tabs make at every range: the census must see them asked.
+ private static readonly (string Engine, string[] Reads)[] s_witnesses =
+ [
+ ("SQL Server", ["get_cpu_utilization", "get_wait_trend", "get_blocking_stats", "get_current_waits_trend", "get_collection_log"]),
+ ("PostgreSQL", ["get_pg_blocking", "get_pg_io_stats", "get_collection_log"]),
+ ];
+
+ ///
+ /// The Blocking tab's note says how far back the page reaches and where the rest of the history is. Its number is
+ /// the widest preset, which the page hands to tabNote, so narrowing the presets cannot leave the note
+ /// wrong. get_blocking_stats takes any window and draws both severity charts, and its tables keep more than
+ /// the page shows. No other tab carries the pointer: the PostgreSQL tabs' only read that takes any window is the
+ /// collection log, which asks for the newest 200 runs.
+ ///
+ [Fact]
+ public void TheBlockingTabNote_NamesTheWidestOfferedRange()
+ {
+ if (!WebRangeKeptHistoryBehaviourTests.TryRun("offeredRanges:" + McpHelpers.MaxHoursBack, out var r)) return;
+
+ var widest = r.GetProperty("offered").EnumerateArray().Max(e => e.GetInt32());
+ Assert.True(widest % 24 == 0, $"The widest preset is {widest} hours, not a whole number of days, so this pin needs remapping.");
+ var days = widest / 24;
+ var notes = r.GetProperty("notes").EnumerateArray().Select(e => e.GetString()!).ToArray();
+
+ var blocking = Assert.Single(notes, n => n.StartsWith("SQL Server blocking: ", System.StringComparison.Ordinal));
+ Assert.Contains(
+ $"This page shows at most {days} day{(days == 1 ? "" : "s")}. A Custom View can show more blocking and deadlock history.",
+ blocking,
+ System.StringComparison.Ordinal);
+ Assert.Single(notes, n => n.Contains("A Custom View can show more", System.StringComparison.Ordinal));
+ }
+
+ /// The same rule read from pages/server.js: each preset is at least an hour and no wider than
+ /// .
+ [Fact]
+ public void TheOfferedRanges_InTheSource_AreNoWiderThanTheReadsTake()
+ {
+ var server = ReadRepoFile("Darling", "PerformanceMonitor.Darling.Service", "wwwroot", "js", "pages", "server.js");
+ var options = Regex.Match(server, @"const RANGE_OPTIONS = \[(.*?)\];", RegexOptions.Singleline);
+ Assert.True(options.Success, "pages/server.js no longer declares RANGE_OPTIONS.");
+
+ var hours = Regex.Matches(options.Groups[1].Value, @"hours:\s*([0-9\s*]+),")
+ .Select(m => m.Groups[1].Value.Split('*').Aggregate(1, (product, factor) => product * int.Parse(factor.Trim())))
+ .ToArray();
+
+ Assert.NotEmpty(hours);
+ Assert.All(hours, h => Assert.InRange(h, 1, McpHelpers.MaxHoursBack));
+ }
+}
diff --git a/Darling/Darling.Tests/web-kept-history-harness.mjs b/Darling/Darling.Tests/web-kept-history-harness.mjs
index ecc25174e8..db6866f0de 100644
--- a/Darling/Darling.Tests/web-kept-history-harness.mjs
+++ b/Darling/Darling.Tests/web-kept-history-harness.mjs
@@ -1,6 +1,7 @@
/* Runs the web viewer's real read path (wwwroot/js/util.js, panels.js and pages/server-tabs.js, plus editor.js for
- an editor scenario) against a scripted /api/read answer and prints what the panels fetched and drew as one line of
- JSON. WebRangeKeptHistoryBehaviourTests starts it as
+ an editor scenario and pages/server.js for the offeredRanges scenario) against a scripted /api/read answer and
+ prints what the panels fetched and drew as one line of JSON. WebRangeKeptHistoryBehaviourTests and
+ WebServerPageRangeTests start it as
node web-kept-history-harness.mjs
The modules are copied into a scratch folder beside a recording stand-in for charts.js (the SVG renderer, which
needs a real browser), then imported. `fetch` and the DOM are stand-ins: a node tree of plain objects, and a fetch
@@ -10,7 +11,9 @@ import os from "node:os";
import path from "node:path";
import { pathToFileURL } from "node:url";
-const [jsDir, scenario] = process.argv.slice(-2);
+const [jsDir, scenarioArg] = process.argv.slice(-2);
+/* A scenario may carry one argument after a colon: "offeredRanges:168" is the offeredRanges scenario at 168 hours. */
+const [scenario, scenarioValue] = scenarioArg.split(":");
class FakeNode {
constructor(tag, text) {
@@ -73,12 +76,24 @@ process.on("unhandledRejection", (e) => rejections.push(String(e && e.stack ? e.
/* The field config an editor scenario derived, or null. */
let vizcfg = null;
+/* The offeredRanges scenario's findings: the hours the server page offers, each read it would ask for more, every
+ ranged read it asked for (as "hours engine tool"), and each tab's note as the page renders it. */
+let offered = null;
+let beyond = null;
+let observed = null;
+let notes = null;
+
/* The modules, unchanged, with charts.js replaced by a stand-in that records the window each chart was given. An
editor scenario also loads the view editor (editor.js) and the modules it imports, with compose.js (the composed
panel card, which no editor scenario draws) as a stand-in. editor.js keeps its save-path sample read in a
module-private function, so the scratch copy appends one line exporting ensureFieldConfigs; nothing else in the
copy changes. */
const editorScenario = scenario.startsWith("editor");
+/* The offeredRanges scenario also loads the server page (pages/server.js and the fleet page it imports). The page
+ keeps its Range presets, and the widest of them that it hands tabNote, in module-private constants, so the scratch
+ copy appends one line exporting RANGE_OPTIONS and WIDEST_RANGE_HOURS, the same way the editor copy exports
+ ensureFieldConfigs. */
+const serverPageScenario = scenario === "offeredRanges";
const scratch = fs.mkdtempSync(path.join(os.tmpdir(), "kept-history-"));
let modules;
try {
@@ -109,6 +124,13 @@ try {
);
fs.writeFileSync(path.join(scratch, "compose.js"), "export function renderComposedPanelCard() { throw new Error('not drawn here'); }\n");
}
+ if (serverPageScenario) {
+ fs.copyFileSync(path.join(jsDir, "pages", "fleet.js"), path.join(scratch, "pages", "fleet.js"));
+ fs.writeFileSync(
+ path.join(scratch, "pages", "server.js"),
+ fs.readFileSync(path.join(jsDir, "pages", "server.js"), "utf8") + "\nexport { RANGE_OPTIONS, WIDEST_RANGE_HOURS };\n"
+ );
+ }
const load = (rel) => import(pathToFileURL(path.join(scratch, rel)).href);
modules = {
util: await load("util.js"),
@@ -116,6 +138,7 @@ try {
tabs: await load(path.join("pages", "server-tabs.js")),
charts: await load("charts.js"),
editor: editorScenario ? await load("editor.js") : null,
+ server: serverPageScenario ? await load(path.join("pages", "server.js")) : null,
};
} finally {
fs.rmSync(scratch, { recursive: true, force: true });
@@ -152,6 +175,15 @@ const PICKER_ROWS = {
get_top_queries_by_cpu: { queries: [{ query_hash: "0x1", database_name: "db1", query_text: "select 1" }] },
};
+/* Let every read and every retry settle (each one is a few promise hops). A timer turn lasts about 15 ms on
+ Windows, so a scenario that settles once per tab turns the event loop with setImmediate instead. */
+const settle = async () => {
+ for (let i = 0; i < 200; i++) await new Promise((r) => setTimeout(r, 0));
+};
+const settleFast = async () => {
+ for (let i = 0; i < 200; i++) await new Promise((r) => setImmediate(r));
+};
+
const scenarios = {
// The page asks for 30 days; the read keeps 7 and says so; the retry at 168 hours answers.
loaderRefused: () => {
@@ -221,6 +253,47 @@ const scenarios = {
}
return nodes;
},
+ // Every range the server page's Range offers, through every tab of both registries, against reads that refuse
+ // more than the scenario's hours the way the capped reads do. Each read the page would ask for more than that is
+ // listed by range, registry and tab.
+ offeredRanges: async () => {
+ const maxHours = Number(scenarioValue);
+ answer = (url) => {
+ const hours = Number(asked(url));
+ return hours > maxHours ? refusal(hours, maxHours, Math.round(maxHours / 24)) : data(PICKER_ROWS[tool(url)] || {});
+ };
+ offered = modules.server.RANGE_OPTIONS.map((option) => option.hours);
+ beyond = [];
+ observed = [];
+ notes = [];
+ for (const option of modules.server.RANGE_OPTIONS) {
+ for (const [engine, registry] of [["SQL Server", modules.tabs.SERVER_TABS], ["PostgreSQL", modules.tabs.POSTGRES_TABS]]) {
+ for (const tab of registry) {
+ const before = fetches.length;
+ tab.build("SRV1", { hours: option.hours, label: option.label });
+ await settleFast();
+ for (const fetched of fetches.slice(before)) {
+ const url = new URL(fetched, "http://viewer.test");
+ if (asked(url) !== null) {
+ observed.push(option.hours + " " + engine + " " + tool(url));
+ }
+ if (Number(asked(url)) > maxHours) {
+ beyond.push(option.label + ": " + engine + " " + tab.id + " tab, " + tool(url) + " asked for " + asked(url) + " hours");
+ }
+ }
+ }
+ }
+ }
+ for (const [engine, registry] of [["SQL Server", modules.tabs.SERVER_TABS], ["PostgreSQL", modules.tabs.POSTGRES_TABS]]) {
+ for (const tab of registry) {
+ const note = modules.tabs.tabNote(tab, modules.server.WIDEST_RANGE_HOURS);
+ if (note) {
+ notes.push(engine + " " + tab.id + ": " + note.textContent);
+ }
+ }
+ }
+ return [];
+ },
};
const chosen = scenarios[scenario];
@@ -228,8 +301,7 @@ if (!chosen) throw new Error("unknown scenario " + scenario);
const root = new FakeNode("main");
modules.util.mount(root, await chosen());
-// Let every read and every retry settle (each one is a few promise hops).
-for (let i = 0; i < 200; i++) await new Promise((r) => setTimeout(r, 0));
+await settle();
const strips = (kind) => {
const found = [];
@@ -250,5 +322,9 @@ console.log(JSON.stringify({
loading: strips("loading").length,
chartHours: modules.charts.chartCalls.map((c) => (c.windowStart == null ? null : Math.round((c.windowEnd - c.windowStart) / 3600000))),
vizcfg,
+ offered,
+ beyond,
+ observed,
+ notes,
rejections,
}));
diff --git a/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/server-tabs.js b/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/server-tabs.js
index 2040bb36a2..c2a0b40977 100644
--- a/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/server-tabs.js
+++ b/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/server-tabs.js
@@ -39,7 +39,7 @@
* touches innerHTML.
*/
-import { el, readTool, readToolWithinKeptHistory, keptWindowStrip, mount, truncate, loadingStrip, errorStrip, readErrorStrip, emptyStrip, disclosure, noticeStrip, getPath, fmtMs, fmtRate, localTime, windowFromHours } from "../util.js";
+import { el, readTool, readToolWithinKeptHistory, keptWindowStrip, mount, truncate, loadingStrip, errorStrip, readErrorStrip, emptyStrip, disclosure, noticeStrip, getPath, fmtMs, fmtRate, localTime, windowFromHours, daysText } from "../util.js";
import { renderPanel, VIZ } from "../panels.js";
import { renderLineChart, SERIES_COLORS } from "../charts.js";
import { READ_FIELDS } from "../read-fields.js";
@@ -445,8 +445,9 @@ async function drawQueryTrend(slot, server, ctx, query) {
/* #2353: the read routes to the hourly rollup once the window reaches past the raw tier's four-day
retention, and reports which tier answered and how far it really reached. Those sentences are rendered
- rather than dropped, because this page's range goes to 30 days: without them the DOP and plan-hash
- columns simply go blank, and a blank column reads as "nothing to see" rather than "not measured here". */
+ rather than dropped, because this page's range goes to 7 days, past that tier: without them the DOP and
+ plan-hash columns simply go blank, and a blank column reads as "nothing to see" rather than "not measured
+ here". */
const notes = [];
if (trend.data.aggregate_note) notes.push(trend.data.aggregate_note);
/* #3653 item 17: the window floor is `window_truncated` — the page dialect's `truncated` (a limit biting,
@@ -945,9 +946,14 @@ export const SERVER_TABS = [
{
id: "blocking",
label: "Blocking",
- note:
+ /* get_blocking_stats, which draws Blocking Severity and Deadlock Severity, takes any window, and its tables keep
+ more than this page's widest Range. The note names that Range from the presets (tabNote), so narrowing them
+ cannot leave it wrong. */
+ note: (widestHours) =>
"Blocked-process reports and deadlock graphs are shown here as their captured XML. The block-chain view " +
- "and the interactive deadlock graph are desktop-viewer features.",
+ "and the interactive deadlock graph are desktop-viewer features. This page shows at most " +
+ daysText(widestHours) +
+ ". A Custom View can show more blocking and deadlock history.",
build: (server, ctx) => [
line("Blocking Events", "get_blocking_trend", { server, hours: ctx.hours }, "trend", "time", COUNT_SERIES, {
subtitle: ctx.label,
@@ -2487,9 +2493,12 @@ export function findServerTab(id, tabs) {
return registry.find((t) => t.id === id) || registry[0];
}
-/** The tab's note as a rendered strip, or null. Kept here so the shell has no opinion about its wording. */
-export function tabNote(tab) {
- return tab.note ? noticeStrip(tab.note) : null;
+/** The tab's note as a rendered strip, or null. Kept here so the shell has no opinion about its wording. A note can be
+ * a function of `widestHours`, the widest window the page's Range offers, so a sentence that names it stays true
+ * when the presets change. */
+export function tabNote(tab, widestHours) {
+ const note = typeof tab.note === "function" ? tab.note(widestHours) : tab.note;
+ return note ? noticeStrip(note) : null;
}
/* ─────────────────────────── stat descriptors ─────────────────────────── */
diff --git a/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/server.js b/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/server.js
index 6c8fac1edd..6789e1477e 100644
--- a/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/server.js
+++ b/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/server.js
@@ -40,7 +40,7 @@
*
* The time range is the web twin of ViewerServerTab.TimeRange.cs's preset picker: one page-level window that
* every time-windowed panel is given. It is module state (like the fleet page's sort), so it survives the
- * refresh — but it is NOT persisted to localStorage, because a page that reopens on a 30-day window is slow for
+ * refresh — but it is NOT persisted to localStorage, because a page that reopens on a 7-day window is slow for
* a reason the reader cannot see. Panels whose read takes no window at all say "latest snapshot" in their own
* subtitle rather than inheriting a label that would misdescribe them.
*/
@@ -50,16 +50,22 @@ import { setPanelSignal } from "../panels.js";
import { serverTabsFor, findServerTab, tabNote } from "./server-tabs.js";
import { metricBands } from "./fleet.js";
-/** The page time range. Mirrors the desktop viewer's presets, plus the two longer windows the view chrome uses. */
+/** The page time range: the desktop viewers' presets, which stop at 7 days. All but three ranged reads on these
+ * tabs (the collection log, current waits and blocking stats) take at most McpHelpers.MaxHoursBack (168) hours, so
+ * a wider choice was never served: those panels asked again for 7 days and said so. Custom Views offer longer
+ * windows: their composed panels read the store directly, through rollups for the query tables.
+ * WebServerPageRangeTests runs every option through every tab of both registries. */
const RANGE_OPTIONS = [
{ hours: 1, label: "last hour" },
{ hours: 4, label: "last 4 hours" },
{ hours: 12, label: "last 12 hours" },
{ hours: 24, label: "last 24 hours" },
{ hours: 24 * 7, label: "last 7 days" },
- { hours: 24 * 30, label: "last 30 days" },
];
+/** The widest preset. A tab note that names the longest window this page shows (the Blocking tab's) is given it. */
+const WIDEST_RANGE_HOURS = Math.max(...RANGE_OPTIONS.map((o) => o.hours));
+
/* Module state, deliberately not persisted — see the header comment. `gridNode` + the current server/tab let the
range control redraw only the panels, so changing the window does not flash the header or refetch /api/fleet. */
let pageHours = 24;
@@ -170,7 +176,7 @@ function paintTabs(tabsSlot, server, tabId, card) {
const tabs = serverTabsFor(card);
const tab = findServerTab(tabId, tabs);
current = { server, tab };
- mount(tabsSlot, [subtabBar(server, tab, tabs), tabNote(tab)]);
+ mount(tabsSlot, [subtabBar(server, tab, tabs), tabNote(tab, WIDEST_RANGE_HOURS)]);
redrawPanels();
return tabs;
}
diff --git a/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/util.js b/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/util.js
index 31637ef689..f4357cd704 100644
--- a/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/util.js
+++ b/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/util.js
@@ -139,14 +139,14 @@ export function noticeStrip(message) {
* Render a read error, degrading the "window too wide" case to a notice (#2780). A range wider than a read can
* serve comes back as a raw `hours_back value 'N' exceeds maximum of M hours (D days)...` validation string
* (McpHelpers.ValidateHoursBack); that is a range choice, not a fault, so it becomes a status notice naming the
- * window the view keeps rather than a red error carrying the API's own wording. Every other message stays an
+ * widest window the read takes rather than a red error carrying the API's own wording. Every other message stays an
* error. SHARED by every read-error site — the descriptor loader AND the hand-built server-tab composites — so
* a tab cannot show a friendly notice on one panel and the raw string on its neighbour.
*/
export function readErrorStrip(message) {
const hours = keptHoursOf(message);
if (hours != null) {
- return noticeStrip(keptHistoryText(hours) + " — pick a shorter range.");
+ return noticeStrip(readLimitText(hours) + ". Pick a shorter range.");
}
return errorStrip(message);
}
@@ -158,20 +158,22 @@ function keptHoursOf(message) {
return m ? Number(m[1]) : null;
}
-function daysText(hours) {
+export function daysText(hours) {
const days = Math.round(hours / 24);
return days + " day" + (days === 1 ? "" : "s");
}
-function keptHistoryText(hours) {
- return "This view keeps up to " + hours + " hours (" + daysText(hours) + ") of history";
+/* The read's own limit, not the store's history: most tables keep far longer than the 7 days most reads take. */
+function readLimitText(hours) {
+ return "This view reads at most " + hours + " hours (" + daysText(hours) + ") at a time";
}
/**
- * Run a read, and when it refuses the page's window because it keeps less history than that, ask it again ONCE
- * for the history it does keep. The Range select offers 30 days, and most reads keep 7: before this, each of
- * those panels showed only readErrorStrip's "pick a shorter range" notice and no data, beside panels whose reads
- * accept 30 days. Now the panel shows the last M hours with keptWindowStrip's notice saying so.
+ * Run a read, and when it refuses the page's window as wider than it takes, ask it again ONCE for the widest window
+ * it does take. Most reads take at most 7 days. The server page's Range stops there, but a Custom View's
+ * read panel stores its own hours and can still ask for more: before this, such a panel showed only
+ * readErrorStrip's "pick a shorter range" notice and no data. Now it shows the last M hours with keptWindowStrip's
+ * notice saying so.
*
* `fetchWith(params)` is the read itself (readTool, or apiGet over a raw path), so the descriptor loader and the
* hand-built server-tab composites share this one rule. The retry happens only for the window refusal and only
@@ -194,10 +196,10 @@ export function readToolWithinKeptHistory(tool, params, signal) {
return readWithinKeptHistory((p) => readTool(tool, p, signal), params);
}
-/** The notice for a read readWithinKeptHistory narrowed to the history it keeps, or null for any other result. */
+/** The notice for a read readWithinKeptHistory narrowed to the widest window it takes, or null for any other result. */
export function keptWindowStrip(res) {
if (!res || !res.keptHours) return null;
- return noticeStrip(keptHistoryText(res.keptHours) + ", so it shows the last " + daysText(res.keptHours) + ".");
+ return noticeStrip(readLimitText(res.keptHours) + ", so it shows the last " + daysText(res.keptHours) + ".");
}
export function loadingStrip(label) {
return el("div", { class: "strip loading" }, [label || "Loading…"]);
From 502db9199bd52021ef35a5c60698dd1fc945ee3c Mon Sep 17 00:00:00 2001
From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com>
Date: Fri, 2 Oct 2026 12:37:13 -0400
Subject: [PATCH 002/399] Lite and Darling MCP tools refuse a fraction for a
whole-number parameter, such as hours_back: 0.5, with a message that names it
(#4945)
* MCP: refuse a fraction for an integer parameter with a message that names it
hours_back: 0.5 (or 1.0, "0.5", true) for any integer parameter failed inside
the SDK's argument binding, and the caller got "An error occurred invoking
''." with no word on which argument was wrong. The shared call-tool
guard, which both MCP hosts already run, now refuses such a value before the
tool runs: the message names the argument, says it takes a whole number (of
hours for hours_back), quotes the value sent and lists the accepted
parameters, in the same refusal envelope as an unknown argument.
The guard refuses only values the binder cannot read into any integer
parameter. Null and integers past int range but within long range pass
through as before.
Pins, one set per MCP, run through a real in-process server and client:
every tool that takes hours_back is called with 0.5, get_wait_stats with
limit: 2.5, and every integer parameter is checked against 2.5, 1.0, "0.5"
and true (refused) and 1, "1" and null (accepted).
* MCP: argument names match exactly, so a miscased key is refused instead of dropped
The SDK binds an argument only when its name matches the parameter's in letter
case, so {"HOURS_BACK": 2} ran at the default 24 hours. The guard matched names
ignoring case and let that key through. It now matches exactly, refuses the key
as unknown, names the parameter it differs from only by case, and says that
names must match in letter case.
The whole-number refusal now says "with no decimal point" and "only as a whole
number". The census text no longer claims the binder reads null for every
integer parameter. describe_custom_view_catalog's description named its
full_detail parameter in capitals; it now uses the real name.
* MCP: cut quoted values on whole characters, and call a whole number past long too large or too small
McpHelpers.Truncate now cuts back to the last whole text element at or before
the limit, the #3625 rule from WebhookAlertService.SlackCutLength. A cut inside
an emoji's surrogate pair reached the caller as U+FFFD. The unknown-argument
guard's quoted value goes through Truncate instead of its own copy of the cut.
A whole number the binder cannot read because it is past long was refused with
"no decimal point, such as 1". The refusal now says the value is too large or
too small, decided by the value's own text.
Lite.Tests.csproj drops a paragraph about apostrophes that stopped being true
when CrossAppGuardCiGateTests moved to XDocument.
* MCP tests: pin that the SDK binder drops a key differing only by case, and dispose the host's inert services
The in-process host can now leave the unknown-argument guard out, so a test sees the SDK's binder alone. Both apps
send HOURS_BACK with a value no integer can take and find no binding failure logged, then send it as hours_back and
find one. The host disposes the inert instances it is handed, and the schema checks read a parameter's type only
when it is a string.
---
Darling/Darling.Tests/McpInProcessHost.cs | 331 ++++++++++++++++++
.../McpUnknownArgumentGuardTests.cs | 77 +++-
.../McpWholeNumberArgumentTests.cs | 251 +++++++++++++
.../Mcp/DarlingMcpCustomViewTools.cs | 2 +-
Lite.Tests/Lite.Tests.csproj | 10 +-
Lite.Tests/McpHelpersTruncateTests.cs | 92 +++++
Lite.Tests/McpUnknownArgumentGuardTests.cs | 79 ++++-
Lite.Tests/McpWholeNumberArgumentTests.cs | 229 ++++++++++++
PerformanceMonitor.Common/Mcp/McpHelpers.cs | 74 +++-
.../Mcp/McpUnknownArgumentGuard.cs | 216 ++++++++++--
10 files changed, 1319 insertions(+), 42 deletions(-)
create mode 100644 Darling/Darling.Tests/McpInProcessHost.cs
create mode 100644 Darling/Darling.Tests/McpWholeNumberArgumentTests.cs
create mode 100644 Lite.Tests/McpHelpersTruncateTests.cs
create mode 100644 Lite.Tests/McpWholeNumberArgumentTests.cs
diff --git a/Darling/Darling.Tests/McpInProcessHost.cs b/Darling/Darling.Tests/McpInProcessHost.cs
new file mode 100644
index 0000000000..00c127a231
--- /dev/null
+++ b/Darling/Darling.Tests/McpInProcessHost.cs
@@ -0,0 +1,331 @@
+/*
+ * 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.Concurrent;
+using System.Collections.Generic;
+using System.IO.Pipelines;
+using System.Linq;
+using System.Reflection;
+using System.Runtime.CompilerServices;
+using System.Text.Json;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using ModelContextProtocol.Client;
+using ModelContextProtocol.Protocol;
+using ModelContextProtocol.Server;
+using PerformanceMonitor.Common;
+using Xunit;
+
+namespace Darling.Tests;
+
+///
+/// A real MCP server and client in one process, joined by two pipes. The server registers the given tool types
+/// through and installs
+/// as its call-tool filter, as the host does, so a call runs through the
+/// SDK's real JSON-RPC, filter pipeline and argument binder. A test can leave the guard out to see what the
+/// SDK's binder does on its own.
+///
+/// Each service parameter is bound to an uninitialized instance of its type, never to a working one, so
+/// service binding succeeds and the first thing that can fail is the binding of the caller's arguments. A call
+/// that gets past argument binding reaches the tool body and fails there, on the empty service. Nothing here
+/// touches a database. The exceptions the server logs for failed tool calls are kept in
+/// , so a test can tell a binding failure from a body failure.
+///
+internal sealed class McpInProcessHost : IAsyncDisposable
+{
+ private readonly ServiceProvider _provider;
+ private readonly CancellationTokenSource _stop;
+ private readonly Task _serverRun;
+ private readonly List
public sealed class ViewerServerSetSyncTests
{
@@ -178,6 +184,140 @@ public void EverySurfaceThatListsServers_ReadsTheFleetTheReloadRebuilds()
Assert.Matches(@"new\s+DatabaseStateOverridesWindow\s*\(\s*_dataService\s*,\s*_servers\s*\)", settings);
}
+ [Fact]
+ public void Reload_RebuildsTheRecommendationsPicker_KeepingItsServerById()
+ {
+ var load = MethodBody("MainWindow.xaml.cs", "LoadServersAsync");
+
+ /* The old rebuild went back to the first server by index, whatever the picker showed. */
+ Assert.DoesNotMatch(@"RecommendationsServerSelector\s*\.\s*SelectedIndex\s*=", load);
+
+ var previous = Regex.Match(load, @"RecommendationsServerSelector\s*\.\s*SelectedItem\s+as\s+DarlingServer\b");
+ var rebuild = Regex.Match(load, @"RecommendationsServerSelector\s*\.\s*ItemsSource\s*=");
+
+ Assert.True(
+ previous.Success && rebuild.Success && previous.Index < rebuild.Index,
+ "LoadServersAsync must read the picker's server before it rebuilds the picker's list");
+ Assert.Matches(
+ @"RecommendationsServerSelector\s*\.\s*SelectedItem\s*=\s*ViewerServerSetSync\s*\.\s*PickerSelectionAfterReload\s*\(",
+ load);
+ }
+
+ [Fact]
+ public void Reload_AndTheFavoritesResort_RestoreTheSidebar_WithoutMovingThePickers()
+ {
+ foreach (var (file, method) in new[]
+ {
+ ("MainWindow.xaml.cs", "LoadServersAsync"),
+ ("MainWindow.ServerManagement.cs", "ReapplyFavoritesToServerList"),
+ })
+ {
+ var body = MethodBody(file, method);
+
+ Assert.Matches(@"\bRestoreSidebarSelection\s*\(", body);
+ Assert.DoesNotMatch(@"ServerList\s*\.\s*SelectedItem\s*=", body);
+ }
+
+ /* An unguarded restore runs ServerList_SelectionChanged, and that moves both pickers to the
+ sidebar's server: SyncAggregateServerSelectors. */
+ var restore = MethodBody("MainWindow.xaml.cs", "RestoreSidebarSelection");
+ var guard = Regex.Match(restore, @"_suppressSidebarSelection\s*=\s*true\b");
+ var select = Regex.Match(restore, @"ServerList\s*\.\s*SelectedItem\s*=");
+
+ Assert.True(
+ guard.Success && select.Success && guard.Index < select.Index,
+ "RestoreSidebarSelection must set the sidebar's selection under _suppressSidebarSelection");
+ Assert.DoesNotMatch(@"\bSyncAggregateServerSelectors\s*\(", restore);
+
+ /* The guard also skips the handler's reload of the visible tab, so the restore runs it. */
+ Assert.Matches(@"\bRefreshVisibleAsync\s*\(", restore);
+ }
+
+ [Fact]
+ public void FinOpsPicker_KeepsItsServer_AndFallsBackToTheSidebarsServer_AtBothOfItsRebuilds()
+ {
+ var setServers = MethodBody("FinOpsTab.xaml.cs", "SetServers");
+
+ Assert.Matches(
+ @"ServerSelector\s*\.\s*SelectedItem\s*=\s*ViewerServerSetSync\s*\.\s*PickerSelectionAfterReload\s*\(",
+ setServers);
+
+ /* The reload hands it the list, and so does the FinOps tab when it opens. Both pass the sidebar's
+ server, for the fallback. */
+ var shell = Stripped("MainWindow.xaml.cs");
+ var calls = Regex.Matches(shell, @"FinOpsContent\s*\.\s*SetServers\s*\(");
+
+ Assert.Equal(2, calls.Count);
+ foreach (Match call in calls)
+ {
+ var open = call.Index + call.Length - 1;
+ var arguments = shell[open..(ClosingParen(shell, open) + 1)];
+
+ Assert.Matches(@",\s*[^,]*ServerId\b", arguments);
+ }
+ }
+
+ [Fact]
+ public void Reload_ReadsTheServersItKeeps_AfterItsAwait()
+ {
+ var load = MethodBody("MainWindow.xaml.cs", "LoadServersAsync");
+
+ /* A change made while the server list loads is the user's latest choice. Read before the await, the
+ reload put back the server that the sidebar or the picker showed when the load began. */
+ var loaded = Regex.Match(load, @"\bawait\s+_dataService\s*\.\s*GetManagedServersAsync\s*\(");
+ var rebind = Regex.Match(load, @"ServerList\s*\.\s*ItemsSource\s*=");
+
+ Assert.True(loaded.Success && rebind.Success, "LoadServersAsync must await the server list, then rebind the sidebar");
+
+ foreach (var (what, pattern) in new[]
+ {
+ ("the sidebar's server", @"ServerList\s*\.\s*SelectedItem\s+as\s+FleetServerRow\b"),
+ ("the Recommendations picker's server", @"RecommendationsServerSelector\s*\.\s*SelectedItem\s+as\s+DarlingServer\b"),
+ })
+ {
+ var read = Regex.Match(load, pattern);
+
+ Assert.True(
+ read.Success && loaded.Index < read.Index && read.Index < rebind.Index,
+ $"LoadServersAsync must read {what} after its await and before it rebinds the sidebar");
+ }
+ }
+
+ [Fact]
+ public void EverySidebarSelectionWrite_RunsUnderTheSuppressionGuard()
+ {
+ /* An unguarded write runs ServerList_SelectionChanged, and that moves both pickers to the sidebar's
+ server. Each write must follow a "_suppressSidebarSelection = true" with no "= false" between them, as
+ RestoreSidebarSelection does. */
+ var viewer = Path.GetDirectoryName(ViewerPath("MainWindow.xaml.cs"))!;
+ var writes = 0;
+ var unguarded = new List();
+
+ foreach (var path in Directory.EnumerateFiles(viewer, "*.cs", SearchOption.AllDirectories)
+ .Where(path => !path.Contains($"{Path.DirectorySeparatorChar}bin{Path.DirectorySeparatorChar}", StringComparison.Ordinal)
+ && !path.Contains($"{Path.DirectorySeparatorChar}obj{Path.DirectorySeparatorChar}", StringComparison.Ordinal)))
+ {
+ var code = CSharpSourceWalker.StripCommentsAndStrings(File.ReadAllText(path));
+ var toggles = Regex.Matches(code, @"\b_suppressSidebarSelection\s*=\s*(?true|false)\b");
+
+ foreach (Match write in Regex.Matches(code, @"\bServerList\s*\.\s*Selected(?:Item|Index|Value)\s*=(?!=)"))
+ {
+ writes++;
+
+ var last = toggles.LastOrDefault(toggle => toggle.Index < write.Index);
+
+ if (last is null || last.Groups["on"].Value != "true")
+ {
+ var line = code.AsSpan(0, write.Index).Count('\n') + 1;
+ unguarded.Add($"{Path.GetFileName(path)}:{line}");
+ }
+ }
+ }
+
+ Assert.True(writes > 0, "the scan found no sidebar selection write, so its pattern no longer matches the viewer");
+ Assert.True(unguarded.Count == 0, "a sidebar selection write outside the guard: " + string.Join(", ", unguarded));
+ }
+
// ── Behavior: the comparer against a real FleetView, counting the reloads ──────────────────────
[Fact]
@@ -275,6 +415,48 @@ public async Task OneServerSwappedForAnother_SameCount_StillReloads()
Assert.Equal(1, tick.SelectedId);
}
+ // ── Behavior: the server each picker shows after a rebuild ──────────────────────────────────────
+
+ [Fact]
+ public void Picker_KeepsItsServer_WhileItExists_WhereverTheSidebarIs()
+ {
+ var servers = new[] { Server(1, "SQL01"), Server(2, "SQL02"), Server(3, "SQL03") };
+
+ var picked = ViewerServerSetSync.PickerSelectionAfterReload(servers, previousServerId: 2, sidebarServerId: 1);
+
+ Assert.Equal(2, picked?.ServerId);
+ }
+
+ [Fact]
+ public void Picker_WhoseServerWasRemoved_FollowsTheSidebar_AsOnAFreshLoad()
+ {
+ var servers = new[] { Server(1, "SQL01"), Server(3, "SQL03") };
+
+ var picked = ViewerServerSetSync.PickerSelectionAfterReload(servers, previousServerId: 2, sidebarServerId: 3);
+
+ Assert.Equal(3, picked?.ServerId);
+ }
+
+ [Fact]
+ public void FreshLoad_StartsThePicker_OnTheSidebarsServer()
+ {
+ var servers = new[] { Server(1, "SQL01"), Server(2, "SQL02") };
+
+ var picked = ViewerServerSetSync.PickerSelectionAfterReload(servers, previousServerId: null, sidebarServerId: 2);
+
+ Assert.Equal(2, picked?.ServerId);
+ }
+
+ [Fact]
+ public void NothingToKeepOrFollow_TakesTheFirstServer_AndAnEmptyListSelectsNothing()
+ {
+ var servers = new[] { Server(1, "SQL01"), Server(3, "SQL03") };
+
+ Assert.Equal(1, ViewerServerSetSync.PickerSelectionAfterReload(servers, previousServerId: 2, sidebarServerId: null)?.ServerId);
+ Assert.Equal(1, ViewerServerSetSync.PickerSelectionAfterReload(servers, previousServerId: 2, sidebarServerId: 9)?.ServerId);
+ Assert.Null(ViewerServerSetSync.PickerSelectionAfterReload(Array.Empty(), previousServerId: 2, sidebarServerId: 1));
+ }
+
private static DarlingServer Server(int id, string name) =>
new(id, name, name, isEnabled: true, sqlMajorVersion: 16);
diff --git a/Darling/PerformanceMonitor.Darling.Viewer/FinOpsTab.xaml.cs b/Darling/PerformanceMonitor.Darling.Viewer/FinOpsTab.xaml.cs
index f69857239f..590322fb8a 100644
--- a/Darling/PerformanceMonitor.Darling.Viewer/FinOpsTab.xaml.cs
+++ b/Darling/PerformanceMonitor.Darling.Viewer/FinOpsTab.xaml.cs
@@ -70,25 +70,26 @@ InitializeComponent so the named grids exist. Body lives in FinOpsTab.Loaders.cs
///
/// Populates the server selector from the shell's server list (mirrors the Recommendations tab's own
- /// selector). Suppresses SelectionChanged during population, preserving the current selection when the list
- /// is re-supplied. The shell drives the first load once the tab becomes visible.
+ /// selector). Suppresses SelectionChanged during population. With , the
+ /// selector keeps its server while that server is in the list; otherwise, or once it is gone, it takes
+ /// , the shell's sidebar server, as the initial load does
+ /// (). The shell drives the first load once the
+ /// tab becomes visible.
///
- public void SetServers(IReadOnlyList servers)
+ public void SetServers(IReadOnlyList servers, int? sidebarServerId, bool keepSelection = true)
{
var previousId = (ServerSelector.SelectedItem as DarlingServer)?.ServerId;
_populatingServers = true;
ServerSelector.ItemsSource = servers;
- if (servers.Count > 0)
- {
- var match = previousId is int pid ? servers.FirstOrDefault(s => s.ServerId == pid) : null;
- ServerSelector.SelectedItem = match ?? servers[0];
- }
+ ServerSelector.SelectedItem = ViewerServerSetSync.PickerSelectionAfterReload(
+ servers, keepSelection ? previousId : null, sidebarServerId);
_populatingServers = false;
- /* The previously selected server is gone (removed elsewhere), so the selection fell back to another
- one with SelectionChanged suppressed: reset the drills and column filters exactly as a deliberate
- server switch does, so the old server's filters cannot zero the new server's grids (#2306). */
+ /* The selection changed with SelectionChanged suppressed: the previously selected server is gone
+ (removed elsewhere), or a load that does not keep the selection moved it. Reset the drills and column
+ filters exactly as a deliberate server switch does, so the old server's filters cannot zero the new
+ server's grids (#2306). */
if (previousId is not null && servers.Count > 0 && ServerSelector.SelectedItem is DarlingServer now && now.ServerId != previousId)
{
ShowFinOpsStorageView(FinOpsStorageDrillLevel.Parent);
diff --git a/Darling/PerformanceMonitor.Darling.Viewer/MainWindow.ServerManagement.cs b/Darling/PerformanceMonitor.Darling.Viewer/MainWindow.ServerManagement.cs
index bf159314d3..7ead424fac 100644
--- a/Darling/PerformanceMonitor.Darling.Viewer/MainWindow.ServerManagement.cs
+++ b/Darling/PerformanceMonitor.Darling.Viewer/MainWindow.ServerManagement.cs
@@ -80,7 +80,10 @@ whatever the sidebar currently shows. */
var selected = (ServerList.SelectedItem as FleetServerRow)?.Server.ServerId;
_fleet.SetAll(SortWithFavorites(_fleet.All.ToList()));
ServerList.ItemsSource = _fleet.Visible;
- ServerList.SelectedItem = _fleet.ResolveSelection(selected);
+
+ /* A re-sort is not the user choosing a server, so the restore leaves the Recommendations and FinOps
+ pickers where they are. */
+ RestoreSidebarSelection(_fleet.ResolveSelection(selected));
}
/// Single-flight guard for . Declared beside the one method
diff --git a/Darling/PerformanceMonitor.Darling.Viewer/MainWindow.xaml.cs b/Darling/PerformanceMonitor.Darling.Viewer/MainWindow.xaml.cs
index ff10e14f33..9f802129ca 100644
--- a/Darling/PerformanceMonitor.Darling.Viewer/MainWindow.xaml.cs
+++ b/Darling/PerformanceMonitor.Darling.Viewer/MainWindow.xaml.cs
@@ -934,6 +934,32 @@ private void ToggleGroup(FleetHeaderRow header, int? previousServerId)
}
}
+ ///
+ /// Puts the sidebar back on after its list was rebuilt (a reload, the favorites
+ /// re-sort), then loads the visible tab once. A rebuild is not the user choosing a server, so the selection is
+ /// set under : does not run,
+ /// and does not move the Recommendations and FinOps pickers off the
+ /// servers they show. The guard also skips the handler's load of the visible tab, so this starts it, the way
+ /// the handler did. No row (an empty or fully filtered list) selects nothing and loads nothing, as before.
+ ///
+ private void RestoreSidebarSelection(FleetServerRow? row)
+ {
+ _suppressSidebarSelection = true;
+ try
+ {
+ ServerList.SelectedItem = row;
+ }
+ finally
+ {
+ _suppressSidebarSelection = false;
+ }
+
+ if (row is not null)
+ {
+ _ = RefreshVisibleAsync();
+ }
+ }
+
///
/// Lazy per-tab load: switching top-level tabs loads the newly visible one. SelectionChanged is a
/// bubbling routed event, so selections inside the tab content (a findings grid, an inner server
@@ -1026,33 +1052,43 @@ private async Task LoadServersAsync(bool preserveSelection = false)
try
{
- var previousSelection = (ServerList.SelectedItem as FleetServerRow)?.Server.ServerId;
-
/* The DESIRED-state managed set (config_monitored_servers), enriched with the observed
collect.servers facts by the shared server_id, so a viewer add/remove/enable is reflected at
once. Stamp the viewer's favorite pins (matched by server id) and sort favorites-first. */
var servers = ApplyFavoritesAndSort(await _dataService.GetManagedServersAsync());
+
+ /* Read the servers to keep AFTER the await: a sidebar or picker change made while the list loads is
+ the user's latest choice, and the reload keeps it. */
+ var previousSelection = (ServerList.SelectedItem as FleetServerRow)?.Server.ServerId;
+ var previousRecoServer = (RecommendationsServerSelector.SelectedItem as DarlingServer)?.ServerId;
_fleet.SetAll(servers);
ServerList.ItemsSource = _fleet.Visible;
UpdateServerCountText();
+ /* The sidebar's server after this load: the prior one after a server add/edit/remove
+ (preserveSelection), so the view doesn't jump back to the first server; the first one on the
+ initial load. Resolved by SERVER, never by index: once tag rows share this list, row 0 is a header
+ rather than a server. The two pickers below fall back to it. */
+ var sidebarRow = _fleet.ResolveSelection(preserveSelection ? previousSelection : null);
+ var sidebarServerId = sidebarRow?.Server.ServerId;
+
/* The Recommendations tab has its OWN server selector, synced to the sidebar selection on a
single-click (SyncAggregateServerSelectors) yet independently changeable while the tab is open.
- Populate it from the same list; the guard suppresses its SelectionChanged during this initial
- population so the first load comes from the sidebar-driven RefreshVisibleAsync below (which
- reads the now-populated combo). */
+ Populate it from the same list. A reload keeps the server it shows while that server exists, and
+ the initial load starts it on the sidebar's server (PickerSelectionAfterReload). The guard
+ suppresses its SelectionChanged here, so the load comes from the RefreshVisibleAsync that
+ RestoreSidebarSelection starts below (which reads the now-populated combo). */
_populatingRecoServers = true;
RecommendationsServerSelector.ItemsSource = servers;
- if (servers.Count > 0)
- {
- RecommendationsServerSelector.SelectedIndex = 0;
- }
+ RecommendationsServerSelector.SelectedItem = ViewerServerSetSync.PickerSelectionAfterReload(
+ servers, preserveSelection ? previousRecoServer : null, sidebarServerId);
_populatingRecoServers = false;
- /* The FinOps aggregate tab has its OWN server selector too; hand it the same list. It suppresses
- its selector's SelectionChanged during population (like the Recommendations selector above), so
- the first FinOps load comes from tab activation, not this call. */
- FinOpsContent.SetServers(servers);
+ /* The FinOps aggregate tab has its OWN server selector too; hand it the same list. It keeps or falls
+ back the same way, and suppresses its selector's SelectionChanged during population (like the
+ Recommendations selector above), so the first FinOps load comes from tab activation, not this
+ call. */
+ FinOpsContent.SetServers(servers, sidebarServerId, keepSelection: preserveSelection);
var hasServers = servers.Count > 0;
ServersHintText.Visibility = hasServers ? Visibility.Collapsed : Visibility.Visible;
@@ -1061,14 +1097,9 @@ private async Task LoadServersAsync(bool preserveSelection = false)
if (hasServers)
{
- /* Triggers SelectionChanged, which loads the active aggregate tab. Restore the prior
- selection after a server add/edit/remove (preserveSelection) so the view doesn't jump
- back to the first server; the initial load selects the first.
-
- Resolved by SERVER, never by index: once tag rows share this list, row 0 is a header
- rather than a server, so a SelectedIndex = 0 fallback would select a non-server and the
- aggregate-tab sync would silently never run. */
- ServerList.SelectedItem = _fleet.ResolveSelection(preserveSelection ? previousSelection : null);
+ /* Selects the sidebar's server and loads the visible tab once, under the suppression guard, so
+ the restore does not move the two pickers populated above to the sidebar's server. */
+ RestoreSidebarSelection(sidebarRow);
/* Fold the fleet tags into the sidebar (opt-in: no tags => the list stays flat). Done after
the selectors are populated and the initial selection is set, so the tag re-projection
@@ -1181,12 +1212,15 @@ private async Task LoadVisibleTabAsync()
break;
case TabItem tab when ReferenceEquals(tab, FinOpsTab):
/* The picker lists the servers known when LoadServersAsync last ran; a server added since
- (by another client) is picked up here, keeping the current selection. */
+ (by another client) is picked up here, keeping the current selection, or taking the
+ sidebar's server when the selected one is gone. */
if (_dataService is not null)
{
try
{
- FinOpsContent.SetServers(ApplyFavoritesAndSort(await _dataService.GetManagedServersAsync()));
+ FinOpsContent.SetServers(
+ ApplyFavoritesAndSort(await _dataService.GetManagedServersAsync()),
+ (ServerList.SelectedItem as FleetServerRow)?.Server.ServerId);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerSetSync.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerSetSync.cs
index 7e89477f79..9c908499f2 100644
--- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerSetSync.cs
+++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerSetSync.cs
@@ -16,7 +16,7 @@ namespace PerformanceMonitor.Darling.Viewer;
///
/// Decides whether the viewer's server list needs reloading because a server was added or removed outside
/// this window — another viewer, the web viewer, the MCP add and remove tools — and runs the reload when it
-/// does. The fleet refresh tick calls it; nothing else does.
+/// does. The fleet refresh tick calls ; nothing else does.
///
/// Compared by server id, and only the set. The registry side is the config list only
/// (GetConfigManagedServersAsync), which on a seeded store is the list the loaded side came from, so
@@ -35,6 +35,10 @@ namespace PerformanceMonitor.Darling.Viewer;
/// cleanup the context-menu remove runs, and this runs it for each removed server before the reload. A
/// local remove leaves that server's open tab where it is, showing the history already collected, so this
/// leaves it too.
+///
+/// A rebuild keeps what each picker shows. picks the server
+/// that the Recommendations and FinOps pickers show after their lists are rebuilt. The reload calls it for both,
+/// and the FinOps tab calls it when it re-reads the registry on opening.
///
internal static class ViewerServerSetSync
{
@@ -91,4 +95,27 @@ internal static async Task ApplyAsync(
return true;
}
+
+ ///
+ /// The server a picker shows after the server list is rebuilt: the server it showed before, while that one is
+ /// still in ; otherwise the sidebar's server; otherwise the first server. Null when
+ /// the list is empty. A rebuild is not the user choosing a server, so only a picker whose server is gone
+ /// moves. The initial load passes no previous server, so its pickers start on the sidebar's server.
+ ///
+ internal static DarlingServer? PickerSelectionAfterReload(
+ IReadOnlyList servers,
+ int? previousServerId,
+ int? sidebarServerId)
+ {
+ ArgumentNullException.ThrowIfNull(servers);
+
+ if (servers.Count == 0)
+ {
+ return null;
+ }
+
+ return servers.FirstOrDefault(server => server.ServerId == previousServerId)
+ ?? servers.FirstOrDefault(server => server.ServerId == sidebarServerId)
+ ?? servers[0];
+ }
}
From 0065d49b740238b1a24d256f65a307515deaeb48 Mon Sep 17 00:00:00 2001
From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com>
Date: Fri, 2 Oct 2026 14:10:11 -0400
Subject: [PATCH 004/399] Update FUNDING.yml (#4962)
---
.github/FUNDING.yml | 1 +
1 file changed, 1 insertion(+)
diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml
index 098803aba5..c3fd7d78fa 100644
--- a/.github/FUNDING.yml
+++ b/.github/FUNDING.yml
@@ -1 +1,2 @@
github: erikdarlingdata
+patreon: DarlingData
From 94a3575c91e487685cb44ce6c4509fdd4a06f01a Mon Sep 17 00:00:00 2001
From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com>
Date: Fri, 2 Oct 2026 14:56:25 -0400
Subject: [PATCH 005/399] Update FUNDING.yml (#4965)
---
.github/FUNDING.yml | 1 +
1 file changed, 1 insertion(+)
diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml
index c3fd7d78fa..5a7e8d2628 100644
--- a/.github/FUNDING.yml
+++ b/.github/FUNDING.yml
@@ -1,2 +1,3 @@
github: erikdarlingdata
patreon: DarlingData
+custom: "https://go.erikdarling.com/Donate"
From 0ab6b94a540c8c19dbe2902af382514a1b5e37ee Mon Sep 17 00:00:00 2001
From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com>
Date: Fri, 2 Oct 2026 15:11:09 -0400
Subject: [PATCH 006/399] Dependencies: SqlClient 7.1.1, Velopack 1.2.161 and
two AWS SDK updates (#4963)
* Bump the nuget-patch-and-minor group with 5 updates
Bumps AWSSDK.PI from 4.0.100.14 to 4.0.100.15
Bumps AWSSDK.RDS from 4.0.105.3 to 4.0.106.1
Bumps Microsoft.Data.SqlClient from 7.1.0 to 7.1.1
Bumps Microsoft.Data.SqlClient.Extensions.Azure from 7.1.0 to 7.1.1
Bumps Velopack from 1.2.158 to 1.2.161
---
updated-dependencies:
- dependency-name: AWSSDK.PI
dependency-version: 4.0.100.15
dependency-type: direct:production
update-type: version-update:semver-patch
dependency-group: nuget-patch-and-minor
- dependency-name: AWSSDK.RDS
dependency-version: 4.0.106.1
dependency-type: direct:production
update-type: version-update:semver-patch
dependency-group: nuget-patch-and-minor
- dependency-name: Microsoft.Data.SqlClient
dependency-version: 7.1.1
dependency-type: direct:production
update-type: version-update:semver-patch
dependency-group: nuget-patch-and-minor
- dependency-name: Microsoft.Data.SqlClient.Extensions.Azure
dependency-version: 7.1.1
dependency-type: direct:production
update-type: version-update:semver-patch
dependency-group: nuget-patch-and-minor
- dependency-name: Velopack
dependency-version: 1.2.161
dependency-type: direct:production
update-type: version-update:semver-patch
dependency-group: nuget-patch-and-minor
...
Signed-off-by: dependabot[bot]
* Dependencies: regenerate the lock files for the #4943 bumps and drop the Storage SqlClient line
Dependabot moved Directory.Packages.props and rewrote only some of the
packages.lock.json files, so a locked-mode restore failed NU1004 for the
rest. This is the result of `dotnet restore PerformanceMonitor.sln
--force-evaluate`: the six lock files it still rewrote (Darling.Tests,
Darling.Viewer, Lite.Tests, Dashboard.Tests, Installer.Tests, Installer).
The two Installer files had Microsoft.Data.SqlClient.Extensions.Azure
half-updated at 7.1.0.
Dependabot also added a direct Microsoft.Data.SqlClient reference to
PerformanceMonitor.Darling.Storage.csproj. Storage already gets SqlClient
through PerformanceMonitor.PlanAnalysis, and central package management
sets the version, so the line is removed again.
---------
Signed-off-by: dependabot[bot]
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
---
Darling/Darling.Tests/packages.lock.json | 136 +++++++++---------
.../packages.lock.json | 74 +++++-----
Directory.Packages.props | 10 +-
Lite.Tests/packages.lock.json | 72 +++++-----
Lite/packages.lock.json | 62 ++++----
deprecated/Dashboard.Tests/packages.lock.json | 89 +++++-------
deprecated/Dashboard/packages.lock.json | 69 ++++-----
deprecated/Installer.Core/packages.lock.json | 30 ++--
deprecated/Installer.Tests/packages.lock.json | 34 ++---
deprecated/Installer/packages.lock.json | 44 +++---
10 files changed, 297 insertions(+), 323 deletions(-)
diff --git a/Darling/Darling.Tests/packages.lock.json b/Darling/Darling.Tests/packages.lock.json
index 1700c8a597..f2fe257123 100644
--- a/Darling/Darling.Tests/packages.lock.json
+++ b/Darling/Darling.Tests/packages.lock.json
@@ -19,8 +19,8 @@
},
"AWSSDK.Core": {
"type": "Transitive",
- "resolved": "4.0.102.6",
- "contentHash": "YwUi9qpz/OHqwEgsf8I5jj1aC/xkkjmF91R8Yx6yD66dhOiORgQJynYQk0fzI4xdhBgUaw8SRGbMkLRrvL+CNA=="
+ "resolved": "4.0.102.8",
+ "contentHash": "90B9Skhl+ORbtWNXrrmGdGhGvAfZiPmZafRYrB/xHelZjePIwPvXioqz9UiiF1vo21eDLzTnXh5EC4RjbNWsFQ=="
},
"Azure.Core": {
"type": "Transitive",
@@ -85,16 +85,16 @@
},
"Microsoft.Data.SqlClient.Extensions.Abstractions": {
"type": "Transitive",
- "resolved": "7.1.0",
- "contentHash": "8xqe8rxLEJealAw21Rh/w+Kis4TtgAZQ+e7HrTkG/gOeiy3CbCtcMXJr3P5H9txhUQNNxkoR9qy1FIk5T85FSg==",
+ "resolved": "7.1.1",
+ "contentHash": "qP/zUOCJwfKqWNC9o+TWqltfNbLnZi1APNpJZgj0yeEJhoI+h+eoQLX+331yZHNOlDxdafZLP5arW/yrS1jAAw==",
"dependencies": {
- "Microsoft.Data.SqlClient.Internal.Logging": "[7.1.0, 8.0.0)"
+ "Microsoft.Data.SqlClient.Internal.Logging": "[7.1.1, 8.0.0)"
}
},
"Microsoft.Data.SqlClient.Internal.Logging": {
"type": "Transitive",
- "resolved": "7.1.0",
- "contentHash": "z+uzJ+/xqZlYSlv0slMhiHd3TRZr20ijZmjUG2qOaNjKzMMO5cx8itHQ0s/YguqMq/8BMlXZe5n6XSgswe+73g=="
+ "resolved": "7.1.1",
+ "contentHash": "bVqbATLnHQngue7U9K7GRnzvfdDa7IyiBU2vijfU2vBMBSZrAnQp/wXqNSfaVonckSaMXUk07wfujTqy9IVMdw=="
},
"Microsoft.Data.SqlClient.SNI.runtime": {
"type": "Transitive",
@@ -733,8 +733,8 @@
"performancemonitor.alerting": {
"type": "Project",
"dependencies": {
- "PerformanceMonitor.Common": "[3.8.0, )",
- "PerformanceMonitor.Notifications": "[3.8.0, )"
+ "PerformanceMonitor.Common": "[3.9.0, )",
+ "PerformanceMonitor.Notifications": "[3.9.0, )"
}
},
"performancemonitor.analysis": {
@@ -743,7 +743,7 @@
"performancemonitor.collectors": {
"type": "Project",
"dependencies": {
- "PerformanceMonitor.Common": "[3.8.0, )"
+ "PerformanceMonitor.Common": "[3.9.0, )"
}
},
"performancemonitor.common": {
@@ -757,36 +757,36 @@
"performancemonitor.darling.analysis": {
"type": "Project",
"dependencies": {
- "Microsoft.Data.SqlClient": "[7.1.0, )",
- "Microsoft.Data.SqlClient.Extensions.Azure": "[7.1.0, )",
+ "Microsoft.Data.SqlClient": "[7.1.1, )",
+ "Microsoft.Data.SqlClient.Extensions.Azure": "[7.1.1, )",
"ModelContextProtocol": "[2.2.0, )",
- "PerformanceMonitor.Analysis": "[3.8.0, )",
- "PerformanceMonitor.Collectors": "[3.8.0, )",
- "PerformanceMonitor.Common": "[3.8.0, )",
- "PerformanceMonitor.Darling.Storage": "[3.8.0, )",
- "PerformanceMonitor.Notifications": "[3.8.0, )",
- "PerformanceMonitor.PlanAnalysis": "[3.8.0, )"
+ "PerformanceMonitor.Analysis": "[3.9.0, )",
+ "PerformanceMonitor.Collectors": "[3.9.0, )",
+ "PerformanceMonitor.Common": "[3.9.0, )",
+ "PerformanceMonitor.Darling.Storage": "[3.9.0, )",
+ "PerformanceMonitor.Notifications": "[3.9.0, )",
+ "PerformanceMonitor.PlanAnalysis": "[3.9.0, )"
}
},
"performancemonitor.darling.service": {
"type": "Project",
"dependencies": {
- "AWSSDK.PI": "[4.0.100.14, )",
- "AWSSDK.RDS": "[4.0.105.3, )",
+ "AWSSDK.PI": "[4.0.100.15, )",
+ "AWSSDK.RDS": "[4.0.106.1, )",
"BlackwellSystems.Gcf": "[1.0.0, )",
- "Microsoft.Data.SqlClient": "[7.1.0, )",
- "Microsoft.Data.SqlClient.Extensions.Azure": "[7.1.0, )",
+ "Microsoft.Data.SqlClient": "[7.1.1, )",
+ "Microsoft.Data.SqlClient.Extensions.Azure": "[7.1.1, )",
"Microsoft.Extensions.Hosting": "[10.0.12, )",
"Microsoft.Extensions.Hosting.WindowsServices": "[10.0.12, )",
"ModelContextProtocol": "[2.2.0, )",
"ModelContextProtocol.AspNetCore": "[2.2.0, )",
- "PerformanceMonitor.Alerting": "[3.8.0, )",
- "PerformanceMonitor.Collectors": "[3.8.0, )",
- "PerformanceMonitor.Common": "[3.8.0, )",
- "PerformanceMonitor.Darling.Analysis": "[3.8.0, )",
- "PerformanceMonitor.Darling.Storage": "[3.8.0, )",
- "PerformanceMonitor.Notifications": "[3.8.0, )",
- "PerformanceMonitor.PlanAnalysis": "[3.8.0, )",
+ "PerformanceMonitor.Alerting": "[3.9.0, )",
+ "PerformanceMonitor.Collectors": "[3.9.0, )",
+ "PerformanceMonitor.Common": "[3.9.0, )",
+ "PerformanceMonitor.Darling.Analysis": "[3.9.0, )",
+ "PerformanceMonitor.Darling.Storage": "[3.9.0, )",
+ "PerformanceMonitor.Notifications": "[3.9.0, )",
+ "PerformanceMonitor.PlanAnalysis": "[3.9.0, )",
"System.IO.FileSystem.AccessControl": "[5.0.0, )"
}
},
@@ -794,66 +794,66 @@
"type": "Project",
"dependencies": {
"Npgsql": "[10.0.3, )",
- "PerformanceMonitor.Collectors": "[3.8.0, )",
- "PerformanceMonitor.Common": "[3.8.0, )",
- "PerformanceMonitor.PlanAnalysis": "[3.8.0, )"
+ "PerformanceMonitor.Collectors": "[3.9.0, )",
+ "PerformanceMonitor.Common": "[3.9.0, )",
+ "PerformanceMonitor.PlanAnalysis": "[3.9.0, )"
}
},
"performancemonitor.darling.viewer": {
"type": "Project",
"dependencies": {
"Hardcodet.NotifyIcon.Wpf": "[2.0.1, )",
- "PerformanceMonitor.Alerting": "[3.8.0, )",
- "PerformanceMonitor.Analysis": "[3.8.0, )",
- "PerformanceMonitor.Collectors": "[3.8.0, )",
- "PerformanceMonitor.Common": "[3.8.0, )",
- "PerformanceMonitor.Darling.Analysis": "[3.8.0, )",
- "PerformanceMonitor.Darling.Storage": "[3.8.0, )",
- "PerformanceMonitor.Notifications": "[3.8.0, )",
- "PerformanceMonitor.PlanAnalysis": "[3.8.0, )",
- "PerformanceMonitor.Ui": "[3.8.0, )",
+ "PerformanceMonitor.Alerting": "[3.9.0, )",
+ "PerformanceMonitor.Analysis": "[3.9.0, )",
+ "PerformanceMonitor.Collectors": "[3.9.0, )",
+ "PerformanceMonitor.Common": "[3.9.0, )",
+ "PerformanceMonitor.Darling.Analysis": "[3.9.0, )",
+ "PerformanceMonitor.Darling.Storage": "[3.9.0, )",
+ "PerformanceMonitor.Notifications": "[3.9.0, )",
+ "PerformanceMonitor.PlanAnalysis": "[3.9.0, )",
+ "PerformanceMonitor.Ui": "[3.9.0, )",
"ScottPlot.WPF": "[5.1.59, )",
- "Velopack": "[1.2.158, )"
+ "Velopack": "[1.2.161, )"
}
},
"performancemonitor.notifications": {
"type": "Project",
"dependencies": {
"Microsoft.Extensions.Logging.Abstractions": "[10.0.12, )",
- "PerformanceMonitor.Analysis": "[3.8.0, )"
+ "PerformanceMonitor.Analysis": "[3.9.0, )"
}
},
"performancemonitor.plananalysis": {
"type": "Project",
"dependencies": {
- "Microsoft.Data.SqlClient": "[7.1.0, )",
- "PerformanceMonitor.Common": "[3.8.0, )"
+ "Microsoft.Data.SqlClient": "[7.1.1, )",
+ "PerformanceMonitor.Common": "[3.9.0, )"
}
},
"performancemonitor.ui": {
"type": "Project",
"dependencies": {
- "PerformanceMonitor.Common": "[3.8.0, )",
- "PerformanceMonitor.PlanAnalysis": "[3.8.0, )",
+ "PerformanceMonitor.Common": "[3.9.0, )",
+ "PerformanceMonitor.PlanAnalysis": "[3.9.0, )",
"ScottPlot.WPF": "[5.1.59, )"
}
},
"AWSSDK.PI": {
"type": "CentralTransitive",
- "requested": "[4.0.100.14, )",
- "resolved": "4.0.100.14",
- "contentHash": "4IWTPrEW9TzuET1B8GEftX2rx+HyJ0hwmS6sRvx7DTpupCbD80QdSqGBlmsH34eUvv/Gvcowp321Dv/SzZP0CQ==",
+ "requested": "[4.0.100.15, )",
+ "resolved": "4.0.100.15",
+ "contentHash": "Pk25EjBAV+yY7r5vP7c7VmqeX922BwCIpNO/ynp5WIEfZbIZD1FSyg/D++W6gqIOq2vWRSvSXygMlCagMHx4xQ==",
"dependencies": {
- "AWSSDK.Core": "[4.0.102.6, 5.0.0)"
+ "AWSSDK.Core": "[4.0.102.8, 5.0.0)"
}
},
"AWSSDK.RDS": {
"type": "CentralTransitive",
- "requested": "[4.0.105.3, )",
- "resolved": "4.0.105.3",
- "contentHash": "LF3fUze3mbiOj+EGEPK5mTgd+VYA5X7Xk5wU6ITMFjo11oce0tMfPifdOAGlnWIgad1jV2s1bDaLRiwkE7aHpg==",
+ "requested": "[4.0.106.1, )",
+ "resolved": "4.0.106.1",
+ "contentHash": "ok3d6CrjA4jS6u6gZC46AHPPStSzjfzp2y/TF19GJRrIvK1eRgkay0xVJaXYrqbmn65W0u6ziMSsFhXlwCfWtw==",
"dependencies": {
- "AWSSDK.Core": "[4.0.102.6, 5.0.0)"
+ "AWSSDK.Core": "[4.0.102.8, 5.0.0)"
}
},
"BlackwellSystems.Gcf": {
@@ -876,13 +876,13 @@
},
"Microsoft.Data.SqlClient": {
"type": "CentralTransitive",
- "requested": "[7.1.0, )",
- "resolved": "7.1.0",
- "contentHash": "yKPkjufNvrX5VDv49XHaGbUNPL4kDx9ALCrqDhYu+ThFQxjM8zGMzYxPv9iiwRU0ZL3vhgq0F2KEeoV3PT2utQ==",
+ "requested": "[7.1.1, )",
+ "resolved": "7.1.1",
+ "contentHash": "Moz4+bba/OdEM9I4jXWggduJ4sqoNcgVAQyXG/dNnGGvnS/YDr2fTjP83fg5YX3drgRki6fRAhR94P8dAZgZtQ==",
"dependencies": {
"Microsoft.Bcl.Cryptography": "9.0.18",
- "Microsoft.Data.SqlClient.Extensions.Abstractions": "[7.1.0, 8.0.0)",
- "Microsoft.Data.SqlClient.Internal.Logging": "[7.1.0, 8.0.0)",
+ "Microsoft.Data.SqlClient.Extensions.Abstractions": "[7.1.1, 8.0.0)",
+ "Microsoft.Data.SqlClient.Internal.Logging": "[7.1.1, 8.0.0)",
"Microsoft.Data.SqlClient.SNI.runtime": "[7.1.0, 8.0.0)",
"Microsoft.Extensions.Caching.Memory": "9.0.18",
"Microsoft.IdentityModel.JsonWebTokens": "8.16.0",
@@ -893,14 +893,14 @@
},
"Microsoft.Data.SqlClient.Extensions.Azure": {
"type": "CentralTransitive",
- "requested": "[7.1.0, )",
- "resolved": "7.1.0",
- "contentHash": "ZMfBGoTa2D1VugOSh8y6Klv9mh8hefgjygAFiSHAl/1dw+EehRrBl6YFbB8IX27XUHj1tQ0I+bdGMZ5DtwMOlw==",
+ "requested": "[7.1.1, )",
+ "resolved": "7.1.1",
+ "contentHash": "3/Z+6JJKGUw8+shNtftUSGOagt7XCbuhXknZjzXQfDr5aA4lUEMtU/MMNSiwMIrkviYslNKvFh1yWOArudDxQA==",
"dependencies": {
"Azure.Core": "1.51.1",
"Azure.Identity": "1.18.0",
- "Microsoft.Data.SqlClient.Extensions.Abstractions": "[7.1.0, 8.0.0)",
- "Microsoft.Data.SqlClient.Internal.Logging": "[7.1.0, 8.0.0)",
+ "Microsoft.Data.SqlClient.Extensions.Abstractions": "[7.1.1, 8.0.0)",
+ "Microsoft.Data.SqlClient.Internal.Logging": "[7.1.1, 8.0.0)",
"Microsoft.Extensions.Caching.Memory": "8.0.1",
"Microsoft.Identity.Client": "4.84.2",
"Microsoft.Identity.Client.Broker": "4.84.2"
@@ -1038,9 +1038,9 @@
},
"Velopack": {
"type": "CentralTransitive",
- "requested": "[1.2.158, )",
- "resolved": "1.2.158",
- "contentHash": "wQN38beVrAf0WzFMnZtl7UmwxqwaJ7xVYypqITdOI4YUTYch2UvE+xAjg9msfaitVDtgfWeIcGsjRNQ65C03ZQ=="
+ "requested": "[1.2.161, )",
+ "resolved": "1.2.161",
+ "contentHash": "OpHNacIumnkhaaX3ZlQ/TCR4YPOJvud2/PAOoWb6o+mIrLwIVA275I5wAgt4q/1O2VrJeDwR1qFQyqzG4lUArg=="
}
}
}
diff --git a/Darling/PerformanceMonitor.Darling.Viewer/packages.lock.json b/Darling/PerformanceMonitor.Darling.Viewer/packages.lock.json
index 62cc39cc34..8827833780 100644
--- a/Darling/PerformanceMonitor.Darling.Viewer/packages.lock.json
+++ b/Darling/PerformanceMonitor.Darling.Viewer/packages.lock.json
@@ -22,9 +22,9 @@
},
"Velopack": {
"type": "Direct",
- "requested": "[1.2.158, )",
- "resolved": "1.2.158",
- "contentHash": "wQN38beVrAf0WzFMnZtl7UmwxqwaJ7xVYypqITdOI4YUTYch2UvE+xAjg9msfaitVDtgfWeIcGsjRNQ65C03ZQ=="
+ "requested": "[1.2.161, )",
+ "resolved": "1.2.161",
+ "contentHash": "OpHNacIumnkhaaX3ZlQ/TCR4YPOJvud2/PAOoWb6o+mIrLwIVA275I5wAgt4q/1O2VrJeDwR1qFQyqzG4lUArg=="
},
"Azure.Core": {
"type": "Transitive",
@@ -84,16 +84,16 @@
},
"Microsoft.Data.SqlClient.Extensions.Abstractions": {
"type": "Transitive",
- "resolved": "7.1.0",
- "contentHash": "8xqe8rxLEJealAw21Rh/w+Kis4TtgAZQ+e7HrTkG/gOeiy3CbCtcMXJr3P5H9txhUQNNxkoR9qy1FIk5T85FSg==",
+ "resolved": "7.1.1",
+ "contentHash": "qP/zUOCJwfKqWNC9o+TWqltfNbLnZi1APNpJZgj0yeEJhoI+h+eoQLX+331yZHNOlDxdafZLP5arW/yrS1jAAw==",
"dependencies": {
- "Microsoft.Data.SqlClient.Internal.Logging": "[7.1.0, 8.0.0)"
+ "Microsoft.Data.SqlClient.Internal.Logging": "[7.1.1, 8.0.0)"
}
},
"Microsoft.Data.SqlClient.Internal.Logging": {
"type": "Transitive",
- "resolved": "7.1.0",
- "contentHash": "z+uzJ+/xqZlYSlv0slMhiHd3TRZr20ijZmjUG2qOaNjKzMMO5cx8itHQ0s/YguqMq/8BMlXZe5n6XSgswe+73g=="
+ "resolved": "7.1.1",
+ "contentHash": "bVqbATLnHQngue7U9K7GRnzvfdDa7IyiBU2vijfU2vBMBSZrAnQp/wXqNSfaVonckSaMXUk07wfujTqy9IVMdw=="
},
"Microsoft.Data.SqlClient.SNI.runtime": {
"type": "Transitive",
@@ -464,8 +464,8 @@
"performancemonitor.alerting": {
"type": "Project",
"dependencies": {
- "PerformanceMonitor.Common": "[3.8.0, )",
- "PerformanceMonitor.Notifications": "[3.8.0, )"
+ "PerformanceMonitor.Common": "[3.9.0, )",
+ "PerformanceMonitor.Notifications": "[3.9.0, )"
}
},
"performancemonitor.analysis": {
@@ -474,7 +474,7 @@
"performancemonitor.collectors": {
"type": "Project",
"dependencies": {
- "PerformanceMonitor.Common": "[3.8.0, )"
+ "PerformanceMonitor.Common": "[3.9.0, )"
}
},
"performancemonitor.common": {
@@ -488,45 +488,45 @@
"performancemonitor.darling.analysis": {
"type": "Project",
"dependencies": {
- "Microsoft.Data.SqlClient": "[7.1.0, )",
- "Microsoft.Data.SqlClient.Extensions.Azure": "[7.1.0, )",
+ "Microsoft.Data.SqlClient": "[7.1.1, )",
+ "Microsoft.Data.SqlClient.Extensions.Azure": "[7.1.1, )",
"ModelContextProtocol": "[2.2.0, )",
- "PerformanceMonitor.Analysis": "[3.8.0, )",
- "PerformanceMonitor.Collectors": "[3.8.0, )",
- "PerformanceMonitor.Common": "[3.8.0, )",
- "PerformanceMonitor.Darling.Storage": "[3.8.0, )",
- "PerformanceMonitor.Notifications": "[3.8.0, )",
- "PerformanceMonitor.PlanAnalysis": "[3.8.0, )"
+ "PerformanceMonitor.Analysis": "[3.9.0, )",
+ "PerformanceMonitor.Collectors": "[3.9.0, )",
+ "PerformanceMonitor.Common": "[3.9.0, )",
+ "PerformanceMonitor.Darling.Storage": "[3.9.0, )",
+ "PerformanceMonitor.Notifications": "[3.9.0, )",
+ "PerformanceMonitor.PlanAnalysis": "[3.9.0, )"
}
},
"performancemonitor.darling.storage": {
"type": "Project",
"dependencies": {
"Npgsql": "[10.0.3, )",
- "PerformanceMonitor.Collectors": "[3.8.0, )",
- "PerformanceMonitor.Common": "[3.8.0, )",
- "PerformanceMonitor.PlanAnalysis": "[3.8.0, )"
+ "PerformanceMonitor.Collectors": "[3.9.0, )",
+ "PerformanceMonitor.Common": "[3.9.0, )",
+ "PerformanceMonitor.PlanAnalysis": "[3.9.0, )"
}
},
"performancemonitor.notifications": {
"type": "Project",
"dependencies": {
"Microsoft.Extensions.Logging.Abstractions": "[10.0.12, )",
- "PerformanceMonitor.Analysis": "[3.8.0, )"
+ "PerformanceMonitor.Analysis": "[3.9.0, )"
}
},
"performancemonitor.plananalysis": {
"type": "Project",
"dependencies": {
- "Microsoft.Data.SqlClient": "[7.1.0, )",
- "PerformanceMonitor.Common": "[3.8.0, )"
+ "Microsoft.Data.SqlClient": "[7.1.1, )",
+ "PerformanceMonitor.Common": "[3.9.0, )"
}
},
"performancemonitor.ui": {
"type": "Project",
"dependencies": {
- "PerformanceMonitor.Common": "[3.8.0, )",
- "PerformanceMonitor.PlanAnalysis": "[3.8.0, )",
+ "PerformanceMonitor.Common": "[3.9.0, )",
+ "PerformanceMonitor.PlanAnalysis": "[3.9.0, )",
"ScottPlot.WPF": "[5.1.59, )"
}
},
@@ -538,13 +538,13 @@
},
"Microsoft.Data.SqlClient": {
"type": "CentralTransitive",
- "requested": "[7.1.0, )",
- "resolved": "7.1.0",
- "contentHash": "yKPkjufNvrX5VDv49XHaGbUNPL4kDx9ALCrqDhYu+ThFQxjM8zGMzYxPv9iiwRU0ZL3vhgq0F2KEeoV3PT2utQ==",
+ "requested": "[7.1.1, )",
+ "resolved": "7.1.1",
+ "contentHash": "Moz4+bba/OdEM9I4jXWggduJ4sqoNcgVAQyXG/dNnGGvnS/YDr2fTjP83fg5YX3drgRki6fRAhR94P8dAZgZtQ==",
"dependencies": {
"Microsoft.Bcl.Cryptography": "9.0.18",
- "Microsoft.Data.SqlClient.Extensions.Abstractions": "[7.1.0, 8.0.0)",
- "Microsoft.Data.SqlClient.Internal.Logging": "[7.1.0, 8.0.0)",
+ "Microsoft.Data.SqlClient.Extensions.Abstractions": "[7.1.1, 8.0.0)",
+ "Microsoft.Data.SqlClient.Internal.Logging": "[7.1.1, 8.0.0)",
"Microsoft.Data.SqlClient.SNI.runtime": "[7.1.0, 8.0.0)",
"Microsoft.Extensions.Caching.Memory": "9.0.18",
"Microsoft.IdentityModel.JsonWebTokens": "8.16.0",
@@ -555,14 +555,14 @@
},
"Microsoft.Data.SqlClient.Extensions.Azure": {
"type": "CentralTransitive",
- "requested": "[7.1.0, )",
- "resolved": "7.1.0",
- "contentHash": "ZMfBGoTa2D1VugOSh8y6Klv9mh8hefgjygAFiSHAl/1dw+EehRrBl6YFbB8IX27XUHj1tQ0I+bdGMZ5DtwMOlw==",
+ "requested": "[7.1.1, )",
+ "resolved": "7.1.1",
+ "contentHash": "3/Z+6JJKGUw8+shNtftUSGOagt7XCbuhXknZjzXQfDr5aA4lUEMtU/MMNSiwMIrkviYslNKvFh1yWOArudDxQA==",
"dependencies": {
"Azure.Core": "1.51.1",
"Azure.Identity": "1.18.0",
- "Microsoft.Data.SqlClient.Extensions.Abstractions": "[7.1.0, 8.0.0)",
- "Microsoft.Data.SqlClient.Internal.Logging": "[7.1.0, 8.0.0)",
+ "Microsoft.Data.SqlClient.Extensions.Abstractions": "[7.1.1, 8.0.0)",
+ "Microsoft.Data.SqlClient.Internal.Logging": "[7.1.1, 8.0.0)",
"Microsoft.Extensions.Caching.Memory": "8.0.1",
"Microsoft.Identity.Client": "4.84.2",
"Microsoft.Identity.Client.Broker": "4.84.2"
diff --git a/Directory.Packages.props b/Directory.Packages.props
index ae75c1eb19..ebfcf0dfbd 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -8,15 +8,15 @@
true
-
-
+
+
-
-
+
+
@@ -30,7 +30,7 @@
-
+
\ No newline at end of file
diff --git a/Lite.Tests/packages.lock.json b/Lite.Tests/packages.lock.json
index 474dce1ef1..f47c20ee5c 100644
--- a/Lite.Tests/packages.lock.json
+++ b/Lite.Tests/packages.lock.json
@@ -92,16 +92,16 @@
},
"Microsoft.Data.SqlClient.Extensions.Abstractions": {
"type": "Transitive",
- "resolved": "7.1.0",
- "contentHash": "8xqe8rxLEJealAw21Rh/w+Kis4TtgAZQ+e7HrTkG/gOeiy3CbCtcMXJr3P5H9txhUQNNxkoR9qy1FIk5T85FSg==",
+ "resolved": "7.1.1",
+ "contentHash": "qP/zUOCJwfKqWNC9o+TWqltfNbLnZi1APNpJZgj0yeEJhoI+h+eoQLX+331yZHNOlDxdafZLP5arW/yrS1jAAw==",
"dependencies": {
- "Microsoft.Data.SqlClient.Internal.Logging": "[7.1.0, 8.0.0)"
+ "Microsoft.Data.SqlClient.Internal.Logging": "[7.1.1, 8.0.0)"
}
},
"Microsoft.Data.SqlClient.Internal.Logging": {
"type": "Transitive",
- "resolved": "7.1.0",
- "contentHash": "z+uzJ+/xqZlYSlv0slMhiHd3TRZr20ijZmjUG2qOaNjKzMMO5cx8itHQ0s/YguqMq/8BMlXZe5n6XSgswe+73g=="
+ "resolved": "7.1.1",
+ "contentHash": "bVqbATLnHQngue7U9K7GRnzvfdDa7IyiBU2vijfU2vBMBSZrAnQp/wXqNSfaVonckSaMXUk07wfujTqy9IVMdw=="
},
"Microsoft.Data.SqlClient.SNI.runtime": {
"type": "Transitive",
@@ -735,8 +735,8 @@
"performancemonitor.alerting": {
"type": "Project",
"dependencies": {
- "PerformanceMonitor.Common": "[3.8.0, )",
- "PerformanceMonitor.Notifications": "[3.8.0, )"
+ "PerformanceMonitor.Common": "[3.9.0, )",
+ "PerformanceMonitor.Notifications": "[3.9.0, )"
}
},
"performancemonitor.analysis": {
@@ -745,7 +745,7 @@
"performancemonitor.collectors": {
"type": "Project",
"dependencies": {
- "PerformanceMonitor.Common": "[3.8.0, )"
+ "PerformanceMonitor.Common": "[3.9.0, )"
}
},
"performancemonitor.common": {
@@ -760,21 +760,21 @@
"type": "Project",
"dependencies": {
"Microsoft.Extensions.Logging.Abstractions": "[10.0.12, )",
- "PerformanceMonitor.Analysis": "[3.8.0, )"
+ "PerformanceMonitor.Analysis": "[3.9.0, )"
}
},
"performancemonitor.plananalysis": {
"type": "Project",
"dependencies": {
- "Microsoft.Data.SqlClient": "[7.1.0, )",
- "PerformanceMonitor.Common": "[3.8.0, )"
+ "Microsoft.Data.SqlClient": "[7.1.1, )",
+ "PerformanceMonitor.Common": "[3.9.0, )"
}
},
"performancemonitor.ui": {
"type": "Project",
"dependencies": {
- "PerformanceMonitor.Common": "[3.8.0, )",
- "PerformanceMonitor.PlanAnalysis": "[3.8.0, )",
+ "PerformanceMonitor.Common": "[3.9.0, )",
+ "PerformanceMonitor.PlanAnalysis": "[3.9.0, )",
"ScottPlot.WPF": "[5.1.59, )"
}
},
@@ -785,21 +785,21 @@
"DuckDB.NET.Bindings.Full": "[1.5.5, )",
"DuckDB.NET.Data": "[1.5.5, )",
"Hardcodet.NotifyIcon.Wpf": "[2.0.1, )",
- "Microsoft.Data.SqlClient": "[7.1.0, )",
- "Microsoft.Data.SqlClient.Extensions.Azure": "[7.1.0, )",
+ "Microsoft.Data.SqlClient": "[7.1.1, )",
+ "Microsoft.Data.SqlClient.Extensions.Azure": "[7.1.1, )",
"Microsoft.Extensions.Hosting": "[10.0.12, )",
"Microsoft.Extensions.Logging": "[10.0.12, )",
"ModelContextProtocol": "[2.2.0, )",
"ModelContextProtocol.AspNetCore": "[2.2.0, )",
- "PerformanceMonitor.Alerting": "[3.8.0, )",
- "PerformanceMonitor.Analysis": "[3.8.0, )",
- "PerformanceMonitor.Collectors": "[3.8.0, )",
- "PerformanceMonitor.Common": "[3.8.0, )",
- "PerformanceMonitor.Notifications": "[3.8.0, )",
- "PerformanceMonitor.PlanAnalysis": "[3.8.0, )",
- "PerformanceMonitor.Ui": "[3.8.0, )",
+ "PerformanceMonitor.Alerting": "[3.9.0, )",
+ "PerformanceMonitor.Analysis": "[3.9.0, )",
+ "PerformanceMonitor.Collectors": "[3.9.0, )",
+ "PerformanceMonitor.Common": "[3.9.0, )",
+ "PerformanceMonitor.Notifications": "[3.9.0, )",
+ "PerformanceMonitor.PlanAnalysis": "[3.9.0, )",
+ "PerformanceMonitor.Ui": "[3.9.0, )",
"ScottPlot.WPF": "[5.1.59, )",
- "Velopack": "[1.2.158, )"
+ "Velopack": "[1.2.161, )"
}
},
"CredentialManagement": {
@@ -832,13 +832,13 @@
},
"Microsoft.Data.SqlClient": {
"type": "CentralTransitive",
- "requested": "[7.1.0, )",
- "resolved": "7.1.0",
- "contentHash": "yKPkjufNvrX5VDv49XHaGbUNPL4kDx9ALCrqDhYu+ThFQxjM8zGMzYxPv9iiwRU0ZL3vhgq0F2KEeoV3PT2utQ==",
+ "requested": "[7.1.1, )",
+ "resolved": "7.1.1",
+ "contentHash": "Moz4+bba/OdEM9I4jXWggduJ4sqoNcgVAQyXG/dNnGGvnS/YDr2fTjP83fg5YX3drgRki6fRAhR94P8dAZgZtQ==",
"dependencies": {
"Microsoft.Bcl.Cryptography": "9.0.18",
- "Microsoft.Data.SqlClient.Extensions.Abstractions": "[7.1.0, 8.0.0)",
- "Microsoft.Data.SqlClient.Internal.Logging": "[7.1.0, 8.0.0)",
+ "Microsoft.Data.SqlClient.Extensions.Abstractions": "[7.1.1, 8.0.0)",
+ "Microsoft.Data.SqlClient.Internal.Logging": "[7.1.1, 8.0.0)",
"Microsoft.Data.SqlClient.SNI.runtime": "[7.1.0, 8.0.0)",
"Microsoft.Extensions.Caching.Memory": "9.0.18",
"Microsoft.IdentityModel.JsonWebTokens": "8.16.0",
@@ -849,14 +849,14 @@
},
"Microsoft.Data.SqlClient.Extensions.Azure": {
"type": "CentralTransitive",
- "requested": "[7.1.0, )",
- "resolved": "7.1.0",
- "contentHash": "ZMfBGoTa2D1VugOSh8y6Klv9mh8hefgjygAFiSHAl/1dw+EehRrBl6YFbB8IX27XUHj1tQ0I+bdGMZ5DtwMOlw==",
+ "requested": "[7.1.1, )",
+ "resolved": "7.1.1",
+ "contentHash": "3/Z+6JJKGUw8+shNtftUSGOagt7XCbuhXknZjzXQfDr5aA4lUEMtU/MMNSiwMIrkviYslNKvFh1yWOArudDxQA==",
"dependencies": {
"Azure.Core": "1.51.1",
"Azure.Identity": "1.18.0",
- "Microsoft.Data.SqlClient.Extensions.Abstractions": "[7.1.0, 8.0.0)",
- "Microsoft.Data.SqlClient.Internal.Logging": "[7.1.0, 8.0.0)",
+ "Microsoft.Data.SqlClient.Extensions.Abstractions": "[7.1.1, 8.0.0)",
+ "Microsoft.Data.SqlClient.Internal.Logging": "[7.1.1, 8.0.0)",
"Microsoft.Extensions.Caching.Memory": "8.0.1",
"Microsoft.Identity.Client": "4.84.2",
"Microsoft.Identity.Client.Broker": "4.84.2"
@@ -968,9 +968,9 @@
},
"Velopack": {
"type": "CentralTransitive",
- "requested": "[1.2.158, )",
- "resolved": "1.2.158",
- "contentHash": "wQN38beVrAf0WzFMnZtl7UmwxqwaJ7xVYypqITdOI4YUTYch2UvE+xAjg9msfaitVDtgfWeIcGsjRNQ65C03ZQ=="
+ "requested": "[1.2.161, )",
+ "resolved": "1.2.161",
+ "contentHash": "OpHNacIumnkhaaX3ZlQ/TCR4YPOJvud2/PAOoWb6o+mIrLwIVA275I5wAgt4q/1O2VrJeDwR1qFQyqzG4lUArg=="
}
}
}
diff --git a/Lite/packages.lock.json b/Lite/packages.lock.json
index 1204fc27b2..2aead05b50 100644
--- a/Lite/packages.lock.json
+++ b/Lite/packages.lock.json
@@ -32,13 +32,13 @@
},
"Microsoft.Data.SqlClient": {
"type": "Direct",
- "requested": "[7.1.0, )",
- "resolved": "7.1.0",
- "contentHash": "yKPkjufNvrX5VDv49XHaGbUNPL4kDx9ALCrqDhYu+ThFQxjM8zGMzYxPv9iiwRU0ZL3vhgq0F2KEeoV3PT2utQ==",
+ "requested": "[7.1.1, )",
+ "resolved": "7.1.1",
+ "contentHash": "Moz4+bba/OdEM9I4jXWggduJ4sqoNcgVAQyXG/dNnGGvnS/YDr2fTjP83fg5YX3drgRki6fRAhR94P8dAZgZtQ==",
"dependencies": {
"Microsoft.Bcl.Cryptography": "9.0.18",
- "Microsoft.Data.SqlClient.Extensions.Abstractions": "[7.1.0, 8.0.0)",
- "Microsoft.Data.SqlClient.Internal.Logging": "[7.1.0, 8.0.0)",
+ "Microsoft.Data.SqlClient.Extensions.Abstractions": "[7.1.1, 8.0.0)",
+ "Microsoft.Data.SqlClient.Internal.Logging": "[7.1.1, 8.0.0)",
"Microsoft.Data.SqlClient.SNI.runtime": "[7.1.0, 8.0.0)",
"Microsoft.Extensions.Caching.Memory": "9.0.18",
"Microsoft.IdentityModel.JsonWebTokens": "8.16.0",
@@ -49,14 +49,14 @@
},
"Microsoft.Data.SqlClient.Extensions.Azure": {
"type": "Direct",
- "requested": "[7.1.0, )",
- "resolved": "7.1.0",
- "contentHash": "ZMfBGoTa2D1VugOSh8y6Klv9mh8hefgjygAFiSHAl/1dw+EehRrBl6YFbB8IX27XUHj1tQ0I+bdGMZ5DtwMOlw==",
+ "requested": "[7.1.1, )",
+ "resolved": "7.1.1",
+ "contentHash": "3/Z+6JJKGUw8+shNtftUSGOagt7XCbuhXknZjzXQfDr5aA4lUEMtU/MMNSiwMIrkviYslNKvFh1yWOArudDxQA==",
"dependencies": {
"Azure.Core": "1.51.1",
"Azure.Identity": "1.18.0",
- "Microsoft.Data.SqlClient.Extensions.Abstractions": "[7.1.0, 8.0.0)",
- "Microsoft.Data.SqlClient.Internal.Logging": "[7.1.0, 8.0.0)",
+ "Microsoft.Data.SqlClient.Extensions.Abstractions": "[7.1.1, 8.0.0)",
+ "Microsoft.Data.SqlClient.Internal.Logging": "[7.1.1, 8.0.0)",
"Microsoft.Extensions.Caching.Memory": "8.0.1",
"Microsoft.Identity.Client": "4.84.2",
"Microsoft.Identity.Client.Broker": "4.84.2"
@@ -137,9 +137,9 @@
},
"Velopack": {
"type": "Direct",
- "requested": "[1.2.158, )",
- "resolved": "1.2.158",
- "contentHash": "wQN38beVrAf0WzFMnZtl7UmwxqwaJ7xVYypqITdOI4YUTYch2UvE+xAjg9msfaitVDtgfWeIcGsjRNQ65C03ZQ=="
+ "requested": "[1.2.161, )",
+ "resolved": "1.2.161",
+ "contentHash": "OpHNacIumnkhaaX3ZlQ/TCR4YPOJvud2/PAOoWb6o+mIrLwIVA275I5wAgt4q/1O2VrJeDwR1qFQyqzG4lUArg=="
},
"Apache.Arrow": {
"type": "Transitive",
@@ -217,16 +217,16 @@
},
"Microsoft.Data.SqlClient.Extensions.Abstractions": {
"type": "Transitive",
- "resolved": "7.1.0",
- "contentHash": "8xqe8rxLEJealAw21Rh/w+Kis4TtgAZQ+e7HrTkG/gOeiy3CbCtcMXJr3P5H9txhUQNNxkoR9qy1FIk5T85FSg==",
+ "resolved": "7.1.1",
+ "contentHash": "qP/zUOCJwfKqWNC9o+TWqltfNbLnZi1APNpJZgj0yeEJhoI+h+eoQLX+331yZHNOlDxdafZLP5arW/yrS1jAAw==",
"dependencies": {
- "Microsoft.Data.SqlClient.Internal.Logging": "[7.1.0, 8.0.0)"
+ "Microsoft.Data.SqlClient.Internal.Logging": "[7.1.1, 8.0.0)"
}
},
"Microsoft.Data.SqlClient.Internal.Logging": {
"type": "Transitive",
- "resolved": "7.1.0",
- "contentHash": "z+uzJ+/xqZlYSlv0slMhiHd3TRZr20ijZmjUG2qOaNjKzMMO5cx8itHQ0s/YguqMq/8BMlXZe5n6XSgswe+73g=="
+ "resolved": "7.1.1",
+ "contentHash": "bVqbATLnHQngue7U9K7GRnzvfdDa7IyiBU2vijfU2vBMBSZrAnQp/wXqNSfaVonckSaMXUk07wfujTqy9IVMdw=="
},
"Microsoft.Data.SqlClient.SNI.runtime": {
"type": "Transitive",
@@ -752,8 +752,8 @@
"performancemonitor.alerting": {
"type": "Project",
"dependencies": {
- "PerformanceMonitor.Common": "[3.8.0, )",
- "PerformanceMonitor.Notifications": "[3.8.0, )"
+ "PerformanceMonitor.Common": "[3.9.0, )",
+ "PerformanceMonitor.Notifications": "[3.9.0, )"
}
},
"performancemonitor.analysis": {
@@ -762,7 +762,7 @@
"performancemonitor.collectors": {
"type": "Project",
"dependencies": {
- "PerformanceMonitor.Common": "[3.8.0, )"
+ "PerformanceMonitor.Common": "[3.9.0, )"
}
},
"performancemonitor.common": {
@@ -777,21 +777,21 @@
"type": "Project",
"dependencies": {
"Microsoft.Extensions.Logging.Abstractions": "[10.0.12, )",
- "PerformanceMonitor.Analysis": "[3.8.0, )"
+ "PerformanceMonitor.Analysis": "[3.9.0, )"
}
},
"performancemonitor.plananalysis": {
"type": "Project",
"dependencies": {
- "Microsoft.Data.SqlClient": "[7.1.0, )",
- "PerformanceMonitor.Common": "[3.8.0, )"
+ "Microsoft.Data.SqlClient": "[7.1.1, )",
+ "PerformanceMonitor.Common": "[3.9.0, )"
}
},
"performancemonitor.ui": {
"type": "Project",
"dependencies": {
- "PerformanceMonitor.Common": "[3.8.0, )",
- "PerformanceMonitor.PlanAnalysis": "[3.8.0, )",
+ "PerformanceMonitor.Common": "[3.9.0, )",
+ "PerformanceMonitor.PlanAnalysis": "[3.9.0, )",
"ScottPlot.WPF": "[5.1.59, )"
}
},
@@ -836,13 +836,13 @@
},
"Microsoft.Data.SqlClient": {
"type": "Direct",
- "requested": "[7.1.0, )",
- "resolved": "7.1.0",
- "contentHash": "yKPkjufNvrX5VDv49XHaGbUNPL4kDx9ALCrqDhYu+ThFQxjM8zGMzYxPv9iiwRU0ZL3vhgq0F2KEeoV3PT2utQ==",
+ "requested": "[7.1.1, )",
+ "resolved": "7.1.1",
+ "contentHash": "Moz4+bba/OdEM9I4jXWggduJ4sqoNcgVAQyXG/dNnGGvnS/YDr2fTjP83fg5YX3drgRki6fRAhR94P8dAZgZtQ==",
"dependencies": {
"Microsoft.Bcl.Cryptography": "9.0.18",
- "Microsoft.Data.SqlClient.Extensions.Abstractions": "[7.1.0, 8.0.0)",
- "Microsoft.Data.SqlClient.Internal.Logging": "[7.1.0, 8.0.0)",
+ "Microsoft.Data.SqlClient.Extensions.Abstractions": "[7.1.1, 8.0.0)",
+ "Microsoft.Data.SqlClient.Internal.Logging": "[7.1.1, 8.0.0)",
"Microsoft.Data.SqlClient.SNI.runtime": "[7.1.0, 8.0.0)",
"Microsoft.Extensions.Caching.Memory": "9.0.18",
"Microsoft.IdentityModel.JsonWebTokens": "8.16.0",
diff --git a/deprecated/Dashboard.Tests/packages.lock.json b/deprecated/Dashboard.Tests/packages.lock.json
index 390ab418a8..97acde463a 100644
--- a/deprecated/Dashboard.Tests/packages.lock.json
+++ b/deprecated/Dashboard.Tests/packages.lock.json
@@ -74,16 +74,16 @@
},
"Microsoft.Data.SqlClient.Extensions.Abstractions": {
"type": "Transitive",
- "resolved": "7.1.0",
- "contentHash": "8xqe8rxLEJealAw21Rh/w+Kis4TtgAZQ+e7HrTkG/gOeiy3CbCtcMXJr3P5H9txhUQNNxkoR9qy1FIk5T85FSg==",
+ "resolved": "7.1.1",
+ "contentHash": "qP/zUOCJwfKqWNC9o+TWqltfNbLnZi1APNpJZgj0yeEJhoI+h+eoQLX+331yZHNOlDxdafZLP5arW/yrS1jAAw==",
"dependencies": {
- "Microsoft.Data.SqlClient.Internal.Logging": "[7.1.0, 8.0.0)"
+ "Microsoft.Data.SqlClient.Internal.Logging": "[7.1.1, 8.0.0)"
}
},
"Microsoft.Data.SqlClient.Internal.Logging": {
"type": "Transitive",
- "resolved": "7.1.0",
- "contentHash": "z+uzJ+/xqZlYSlv0slMhiHd3TRZr20ijZmjUG2qOaNjKzMMO5cx8itHQ0s/YguqMq/8BMlXZe5n6XSgswe+73g=="
+ "resolved": "7.1.1",
+ "contentHash": "bVqbATLnHQngue7U9K7GRnzvfdDa7IyiBU2vijfU2vBMBSZrAnQp/wXqNSfaVonckSaMXUk07wfujTqy9IVMdw=="
},
"Microsoft.Data.SqlClient.SNI.runtime": {
"type": "Transitive",
@@ -285,8 +285,7 @@
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.12",
"Microsoft.Extensions.Logging": "10.0.12",
"Microsoft.Extensions.Logging.Abstractions": "10.0.12",
- "Microsoft.Extensions.Options": "10.0.12",
- "System.Diagnostics.EventLog": "10.0.12"
+ "Microsoft.Extensions.Options": "10.0.12"
}
},
"Microsoft.Extensions.Logging.EventSource": {
@@ -623,11 +622,6 @@
"System.Memory.Data": "10.0.1"
}
},
- "System.Diagnostics.EventLog": {
- "type": "Transitive",
- "resolved": "10.0.12",
- "contentHash": "sOZM+VyRj1pg/ItlHsvrSbjws6oGqZveTzTNBGZ9ziwRDyKsOF3ub6vhvOdxotCIqtlnyHAIwP5sLNvgiZC+pQ=="
- },
"System.IdentityModel.Tokens.Jwt": {
"type": "Transitive",
"resolved": "8.16.0",
@@ -723,15 +717,15 @@
"installer.core": {
"type": "Project",
"dependencies": {
- "Microsoft.Data.SqlClient": "[7.1.0, )",
- "Microsoft.Data.SqlClient.Extensions.Azure": "[7.1.0, )"
+ "Microsoft.Data.SqlClient": "[7.1.1, )",
+ "Microsoft.Data.SqlClient.Extensions.Azure": "[7.1.1, )"
}
},
"performancemonitor.alerting": {
"type": "Project",
"dependencies": {
- "PerformanceMonitor.Common": "[3.8.0, )",
- "PerformanceMonitor.Notifications": "[3.8.0, )"
+ "PerformanceMonitor.Common": "[3.9.0, )",
+ "PerformanceMonitor.Notifications": "[3.9.0, )"
}
},
"performancemonitor.analysis": {
@@ -749,22 +743,21 @@
"type": "Project",
"dependencies": {
"Microsoft.Extensions.Logging.Abstractions": "[10.0.12, )",
- "PerformanceMonitor.Analysis": "[3.8.0, )"
+ "PerformanceMonitor.Analysis": "[3.9.0, )"
}
},
"performancemonitor.plananalysis": {
"type": "Project",
"dependencies": {
- "Microsoft.Data.SqlClient": "[7.1.0, )",
- "PerformanceMonitor.Common": "[3.8.0, )",
- "System.Security.Cryptography.ProtectedData": "[10.0.11, )"
+ "Microsoft.Data.SqlClient": "[7.1.1, )",
+ "PerformanceMonitor.Common": "[3.9.0, )"
}
},
"performancemonitor.ui": {
"type": "Project",
"dependencies": {
- "PerformanceMonitor.Common": "[3.8.0, )",
- "PerformanceMonitor.PlanAnalysis": "[3.8.0, )",
+ "PerformanceMonitor.Common": "[3.9.0, )",
+ "PerformanceMonitor.PlanAnalysis": "[3.9.0, )",
"ScottPlot.WPF": "[5.1.59, )"
}
},
@@ -773,22 +766,22 @@
"dependencies": {
"CredentialManagement": "[1.0.2, )",
"Hardcodet.NotifyIcon.Wpf": "[2.0.1, )",
- "Installer.Core": "[3.8.0, )",
- "Microsoft.Data.SqlClient": "[7.1.0, )",
- "Microsoft.Data.SqlClient.Extensions.Azure": "[7.1.0, )",
+ "Installer.Core": "[3.9.0, )",
+ "Microsoft.Data.SqlClient": "[7.1.1, )",
+ "Microsoft.Data.SqlClient.Extensions.Azure": "[7.1.1, )",
"Microsoft.Extensions.Configuration": "[10.0.12, )",
"Microsoft.Extensions.Configuration.Json": "[10.0.12, )",
"Microsoft.Extensions.Hosting": "[10.0.12, )",
"ModelContextProtocol": "[2.2.0, )",
"ModelContextProtocol.AspNetCore": "[2.2.0, )",
- "PerformanceMonitor.Alerting": "[3.8.0, )",
- "PerformanceMonitor.Analysis": "[3.8.0, )",
- "PerformanceMonitor.Common": "[3.8.0, )",
- "PerformanceMonitor.Notifications": "[3.8.0, )",
- "PerformanceMonitor.PlanAnalysis": "[3.8.0, )",
- "PerformanceMonitor.Ui": "[3.8.0, )",
+ "PerformanceMonitor.Alerting": "[3.9.0, )",
+ "PerformanceMonitor.Analysis": "[3.9.0, )",
+ "PerformanceMonitor.Common": "[3.9.0, )",
+ "PerformanceMonitor.Notifications": "[3.9.0, )",
+ "PerformanceMonitor.PlanAnalysis": "[3.9.0, )",
+ "PerformanceMonitor.Ui": "[3.9.0, )",
"ScottPlot.WPF": "[5.1.59, )",
- "Velopack": "[1.2.158, )"
+ "Velopack": "[1.2.161, )"
}
},
"CredentialManagement": {
@@ -805,13 +798,13 @@
},
"Microsoft.Data.SqlClient": {
"type": "CentralTransitive",
- "requested": "[7.1.0, )",
- "resolved": "7.1.0",
- "contentHash": "yKPkjufNvrX5VDv49XHaGbUNPL4kDx9ALCrqDhYu+ThFQxjM8zGMzYxPv9iiwRU0ZL3vhgq0F2KEeoV3PT2utQ==",
+ "requested": "[7.1.1, )",
+ "resolved": "7.1.1",
+ "contentHash": "Moz4+bba/OdEM9I4jXWggduJ4sqoNcgVAQyXG/dNnGGvnS/YDr2fTjP83fg5YX3drgRki6fRAhR94P8dAZgZtQ==",
"dependencies": {
"Microsoft.Bcl.Cryptography": "9.0.18",
- "Microsoft.Data.SqlClient.Extensions.Abstractions": "[7.1.0, 8.0.0)",
- "Microsoft.Data.SqlClient.Internal.Logging": "[7.1.0, 8.0.0)",
+ "Microsoft.Data.SqlClient.Extensions.Abstractions": "[7.1.1, 8.0.0)",
+ "Microsoft.Data.SqlClient.Internal.Logging": "[7.1.1, 8.0.0)",
"Microsoft.Data.SqlClient.SNI.runtime": "[7.1.0, 8.0.0)",
"Microsoft.Extensions.Caching.Memory": "9.0.18",
"Microsoft.IdentityModel.JsonWebTokens": "8.16.0",
@@ -822,14 +815,14 @@
},
"Microsoft.Data.SqlClient.Extensions.Azure": {
"type": "CentralTransitive",
- "requested": "[7.1.0, )",
- "resolved": "7.1.0",
- "contentHash": "ZMfBGoTa2D1VugOSh8y6Klv9mh8hefgjygAFiSHAl/1dw+EehRrBl6YFbB8IX27XUHj1tQ0I+bdGMZ5DtwMOlw==",
+ "requested": "[7.1.1, )",
+ "resolved": "7.1.1",
+ "contentHash": "3/Z+6JJKGUw8+shNtftUSGOagt7XCbuhXknZjzXQfDr5aA4lUEMtU/MMNSiwMIrkviYslNKvFh1yWOArudDxQA==",
"dependencies": {
"Azure.Core": "1.51.1",
"Azure.Identity": "1.18.0",
- "Microsoft.Data.SqlClient.Extensions.Abstractions": "[7.1.0, 8.0.0)",
- "Microsoft.Data.SqlClient.Internal.Logging": "[7.1.0, 8.0.0)",
+ "Microsoft.Data.SqlClient.Extensions.Abstractions": "[7.1.1, 8.0.0)",
+ "Microsoft.Data.SqlClient.Internal.Logging": "[7.1.1, 8.0.0)",
"Microsoft.Extensions.Caching.Memory": "8.0.1",
"Microsoft.Identity.Client": "4.84.2",
"Microsoft.Identity.Client.Broker": "4.84.2"
@@ -939,17 +932,11 @@
"SkiaSharp.Views.WPF": "3.119.0"
}
},
- "System.Security.Cryptography.ProtectedData": {
- "type": "CentralTransitive",
- "requested": "[10.0.12, )",
- "resolved": "10.0.11",
- "contentHash": "PNoxCTPb+Tlux+GJyq4c89ddYdpioVSqfGx8pqOF6shCSKwUNNctXhQtRkCICjbJmGrJJsW8NY52kVYO/b8mlQ=="
- },
"Velopack": {
"type": "CentralTransitive",
- "requested": "[1.2.158, )",
- "resolved": "1.2.158",
- "contentHash": "wQN38beVrAf0WzFMnZtl7UmwxqwaJ7xVYypqITdOI4YUTYch2UvE+xAjg9msfaitVDtgfWeIcGsjRNQ65C03ZQ=="
+ "requested": "[1.2.161, )",
+ "resolved": "1.2.161",
+ "contentHash": "OpHNacIumnkhaaX3ZlQ/TCR4YPOJvud2/PAOoWb6o+mIrLwIVA275I5wAgt4q/1O2VrJeDwR1qFQyqzG4lUArg=="
}
}
}
diff --git a/deprecated/Dashboard/packages.lock.json b/deprecated/Dashboard/packages.lock.json
index 84b2704054..8cfb573eb9 100644
--- a/deprecated/Dashboard/packages.lock.json
+++ b/deprecated/Dashboard/packages.lock.json
@@ -16,13 +16,13 @@
},
"Microsoft.Data.SqlClient": {
"type": "Direct",
- "requested": "[7.1.0, )",
- "resolved": "7.1.0",
- "contentHash": "yKPkjufNvrX5VDv49XHaGbUNPL4kDx9ALCrqDhYu+ThFQxjM8zGMzYxPv9iiwRU0ZL3vhgq0F2KEeoV3PT2utQ==",
+ "requested": "[7.1.1, )",
+ "resolved": "7.1.1",
+ "contentHash": "Moz4+bba/OdEM9I4jXWggduJ4sqoNcgVAQyXG/dNnGGvnS/YDr2fTjP83fg5YX3drgRki6fRAhR94P8dAZgZtQ==",
"dependencies": {
"Microsoft.Bcl.Cryptography": "9.0.18",
- "Microsoft.Data.SqlClient.Extensions.Abstractions": "[7.1.0, 8.0.0)",
- "Microsoft.Data.SqlClient.Internal.Logging": "[7.1.0, 8.0.0)",
+ "Microsoft.Data.SqlClient.Extensions.Abstractions": "[7.1.1, 8.0.0)",
+ "Microsoft.Data.SqlClient.Internal.Logging": "[7.1.1, 8.0.0)",
"Microsoft.Data.SqlClient.SNI.runtime": "[7.1.0, 8.0.0)",
"Microsoft.Extensions.Caching.Memory": "9.0.18",
"Microsoft.IdentityModel.JsonWebTokens": "8.16.0",
@@ -33,14 +33,14 @@
},
"Microsoft.Data.SqlClient.Extensions.Azure": {
"type": "Direct",
- "requested": "[7.1.0, )",
- "resolved": "7.1.0",
- "contentHash": "ZMfBGoTa2D1VugOSh8y6Klv9mh8hefgjygAFiSHAl/1dw+EehRrBl6YFbB8IX27XUHj1tQ0I+bdGMZ5DtwMOlw==",
+ "requested": "[7.1.1, )",
+ "resolved": "7.1.1",
+ "contentHash": "3/Z+6JJKGUw8+shNtftUSGOagt7XCbuhXknZjzXQfDr5aA4lUEMtU/MMNSiwMIrkviYslNKvFh1yWOArudDxQA==",
"dependencies": {
"Azure.Core": "1.51.1",
"Azure.Identity": "1.18.0",
- "Microsoft.Data.SqlClient.Extensions.Abstractions": "[7.1.0, 8.0.0)",
- "Microsoft.Data.SqlClient.Internal.Logging": "[7.1.0, 8.0.0)",
+ "Microsoft.Data.SqlClient.Extensions.Abstractions": "[7.1.1, 8.0.0)",
+ "Microsoft.Data.SqlClient.Internal.Logging": "[7.1.1, 8.0.0)",
"Microsoft.Extensions.Caching.Memory": "8.0.1",
"Microsoft.Identity.Client": "4.84.2",
"Microsoft.Identity.Client.Broker": "4.84.2"
@@ -132,9 +132,9 @@
},
"Velopack": {
"type": "Direct",
- "requested": "[1.2.158, )",
- "resolved": "1.2.158",
- "contentHash": "wQN38beVrAf0WzFMnZtl7UmwxqwaJ7xVYypqITdOI4YUTYch2UvE+xAjg9msfaitVDtgfWeIcGsjRNQ65C03ZQ=="
+ "requested": "[1.2.161, )",
+ "resolved": "1.2.161",
+ "contentHash": "OpHNacIumnkhaaX3ZlQ/TCR4YPOJvud2/PAOoWb6o+mIrLwIVA275I5wAgt4q/1O2VrJeDwR1qFQyqzG4lUArg=="
},
"Azure.Core": {
"type": "Transitive",
@@ -194,16 +194,16 @@
},
"Microsoft.Data.SqlClient.Extensions.Abstractions": {
"type": "Transitive",
- "resolved": "7.1.0",
- "contentHash": "8xqe8rxLEJealAw21Rh/w+Kis4TtgAZQ+e7HrTkG/gOeiy3CbCtcMXJr3P5H9txhUQNNxkoR9qy1FIk5T85FSg==",
+ "resolved": "7.1.1",
+ "contentHash": "qP/zUOCJwfKqWNC9o+TWqltfNbLnZi1APNpJZgj0yeEJhoI+h+eoQLX+331yZHNOlDxdafZLP5arW/yrS1jAAw==",
"dependencies": {
- "Microsoft.Data.SqlClient.Internal.Logging": "[7.1.0, 8.0.0)"
+ "Microsoft.Data.SqlClient.Internal.Logging": "[7.1.1, 8.0.0)"
}
},
"Microsoft.Data.SqlClient.Internal.Logging": {
"type": "Transitive",
- "resolved": "7.1.0",
- "contentHash": "z+uzJ+/xqZlYSlv0slMhiHd3TRZr20ijZmjUG2qOaNjKzMMO5cx8itHQ0s/YguqMq/8BMlXZe5n6XSgswe+73g=="
+ "resolved": "7.1.1",
+ "contentHash": "bVqbATLnHQngue7U9K7GRnzvfdDa7IyiBU2vijfU2vBMBSZrAnQp/wXqNSfaVonckSaMXUk07wfujTqy9IVMdw=="
},
"Microsoft.Data.SqlClient.SNI.runtime": {
"type": "Transitive",
@@ -405,8 +405,7 @@
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.12",
"Microsoft.Extensions.Logging": "10.0.12",
"Microsoft.Extensions.Logging.Abstractions": "10.0.12",
- "Microsoft.Extensions.Options": "10.0.12",
- "System.Diagnostics.EventLog": "10.0.12"
+ "Microsoft.Extensions.Options": "10.0.12"
}
},
"Microsoft.Extensions.Logging.EventSource": {
@@ -708,11 +707,6 @@
"System.Memory.Data": "10.0.1"
}
},
- "System.Diagnostics.EventLog": {
- "type": "Transitive",
- "resolved": "10.0.12",
- "contentHash": "sOZM+VyRj1pg/ItlHsvrSbjws6oGqZveTzTNBGZ9ziwRDyKsOF3ub6vhvOdxotCIqtlnyHAIwP5sLNvgiZC+pQ=="
- },
"System.IdentityModel.Tokens.Jwt": {
"type": "Transitive",
"resolved": "8.16.0",
@@ -735,15 +729,15 @@
"installer.core": {
"type": "Project",
"dependencies": {
- "Microsoft.Data.SqlClient": "[7.1.0, )",
- "Microsoft.Data.SqlClient.Extensions.Azure": "[7.1.0, )"
+ "Microsoft.Data.SqlClient": "[7.1.1, )",
+ "Microsoft.Data.SqlClient.Extensions.Azure": "[7.1.1, )"
}
},
"performancemonitor.alerting": {
"type": "Project",
"dependencies": {
- "PerformanceMonitor.Common": "[3.8.0, )",
- "PerformanceMonitor.Notifications": "[3.8.0, )"
+ "PerformanceMonitor.Common": "[3.9.0, )",
+ "PerformanceMonitor.Notifications": "[3.9.0, )"
}
},
"performancemonitor.analysis": {
@@ -761,22 +755,21 @@
"type": "Project",
"dependencies": {
"Microsoft.Extensions.Logging.Abstractions": "[10.0.12, )",
- "PerformanceMonitor.Analysis": "[3.8.0, )"
+ "PerformanceMonitor.Analysis": "[3.9.0, )"
}
},
"performancemonitor.plananalysis": {
"type": "Project",
"dependencies": {
- "Microsoft.Data.SqlClient": "[7.1.0, )",
- "PerformanceMonitor.Common": "[3.8.0, )",
- "System.Security.Cryptography.ProtectedData": "[10.0.11, )"
+ "Microsoft.Data.SqlClient": "[7.1.1, )",
+ "PerformanceMonitor.Common": "[3.9.0, )"
}
},
"performancemonitor.ui": {
"type": "Project",
"dependencies": {
- "PerformanceMonitor.Common": "[3.8.0, )",
- "PerformanceMonitor.PlanAnalysis": "[3.8.0, )",
+ "PerformanceMonitor.Common": "[3.9.0, )",
+ "PerformanceMonitor.PlanAnalysis": "[3.9.0, )",
"ScottPlot.WPF": "[5.1.59, )"
}
},
@@ -799,12 +792,6 @@
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.12"
}
- },
- "System.Security.Cryptography.ProtectedData": {
- "type": "CentralTransitive",
- "requested": "[10.0.12, )",
- "resolved": "10.0.11",
- "contentHash": "PNoxCTPb+Tlux+GJyq4c89ddYdpioVSqfGx8pqOF6shCSKwUNNctXhQtRkCICjbJmGrJJsW8NY52kVYO/b8mlQ=="
}
}
}
diff --git a/deprecated/Installer.Core/packages.lock.json b/deprecated/Installer.Core/packages.lock.json
index 30b856c66b..17b4a4e816 100644
--- a/deprecated/Installer.Core/packages.lock.json
+++ b/deprecated/Installer.Core/packages.lock.json
@@ -4,13 +4,13 @@
"net10.0": {
"Microsoft.Data.SqlClient": {
"type": "Direct",
- "requested": "[7.1.0, )",
- "resolved": "7.1.0",
- "contentHash": "yKPkjufNvrX5VDv49XHaGbUNPL4kDx9ALCrqDhYu+ThFQxjM8zGMzYxPv9iiwRU0ZL3vhgq0F2KEeoV3PT2utQ==",
+ "requested": "[7.1.1, )",
+ "resolved": "7.1.1",
+ "contentHash": "Moz4+bba/OdEM9I4jXWggduJ4sqoNcgVAQyXG/dNnGGvnS/YDr2fTjP83fg5YX3drgRki6fRAhR94P8dAZgZtQ==",
"dependencies": {
"Microsoft.Bcl.Cryptography": "9.0.18",
- "Microsoft.Data.SqlClient.Extensions.Abstractions": "[7.1.0, 8.0.0)",
- "Microsoft.Data.SqlClient.Internal.Logging": "[7.1.0, 8.0.0)",
+ "Microsoft.Data.SqlClient.Extensions.Abstractions": "[7.1.1, 8.0.0)",
+ "Microsoft.Data.SqlClient.Internal.Logging": "[7.1.1, 8.0.0)",
"Microsoft.Data.SqlClient.SNI.runtime": "[7.1.0, 8.0.0)",
"Microsoft.Extensions.Caching.Memory": "9.0.18",
"Microsoft.IdentityModel.JsonWebTokens": "8.16.0",
@@ -23,14 +23,14 @@
},
"Microsoft.Data.SqlClient.Extensions.Azure": {
"type": "Direct",
- "requested": "[7.1.0, )",
- "resolved": "7.1.0",
- "contentHash": "ZMfBGoTa2D1VugOSh8y6Klv9mh8hefgjygAFiSHAl/1dw+EehRrBl6YFbB8IX27XUHj1tQ0I+bdGMZ5DtwMOlw==",
+ "requested": "[7.1.1, )",
+ "resolved": "7.1.1",
+ "contentHash": "3/Z+6JJKGUw8+shNtftUSGOagt7XCbuhXknZjzXQfDr5aA4lUEMtU/MMNSiwMIrkviYslNKvFh1yWOArudDxQA==",
"dependencies": {
"Azure.Core": "1.51.1",
"Azure.Identity": "1.18.0",
- "Microsoft.Data.SqlClient.Extensions.Abstractions": "[7.1.0, 8.0.0)",
- "Microsoft.Data.SqlClient.Internal.Logging": "[7.1.0, 8.0.0)",
+ "Microsoft.Data.SqlClient.Extensions.Abstractions": "[7.1.1, 8.0.0)",
+ "Microsoft.Data.SqlClient.Internal.Logging": "[7.1.1, 8.0.0)",
"Microsoft.Extensions.Caching.Memory": "8.0.1",
"Microsoft.Identity.Client": "4.84.2",
"Microsoft.Identity.Client.Broker": "4.84.2"
@@ -70,16 +70,16 @@
},
"Microsoft.Data.SqlClient.Extensions.Abstractions": {
"type": "Transitive",
- "resolved": "7.1.0",
- "contentHash": "8xqe8rxLEJealAw21Rh/w+Kis4TtgAZQ+e7HrTkG/gOeiy3CbCtcMXJr3P5H9txhUQNNxkoR9qy1FIk5T85FSg==",
+ "resolved": "7.1.1",
+ "contentHash": "qP/zUOCJwfKqWNC9o+TWqltfNbLnZi1APNpJZgj0yeEJhoI+h+eoQLX+331yZHNOlDxdafZLP5arW/yrS1jAAw==",
"dependencies": {
- "Microsoft.Data.SqlClient.Internal.Logging": "[7.1.0, 8.0.0)"
+ "Microsoft.Data.SqlClient.Internal.Logging": "[7.1.1, 8.0.0)"
}
},
"Microsoft.Data.SqlClient.Internal.Logging": {
"type": "Transitive",
- "resolved": "7.1.0",
- "contentHash": "z+uzJ+/xqZlYSlv0slMhiHd3TRZr20ijZmjUG2qOaNjKzMMO5cx8itHQ0s/YguqMq/8BMlXZe5n6XSgswe+73g=="
+ "resolved": "7.1.1",
+ "contentHash": "bVqbATLnHQngue7U9K7GRnzvfdDa7IyiBU2vijfU2vBMBSZrAnQp/wXqNSfaVonckSaMXUk07wfujTqy9IVMdw=="
},
"Microsoft.Data.SqlClient.SNI.runtime": {
"type": "Transitive",
diff --git a/deprecated/Installer.Tests/packages.lock.json b/deprecated/Installer.Tests/packages.lock.json
index 4d02aefd00..01bc2f08a4 100644
--- a/deprecated/Installer.Tests/packages.lock.json
+++ b/deprecated/Installer.Tests/packages.lock.json
@@ -4,13 +4,13 @@
"net10.0": {
"Microsoft.Data.SqlClient": {
"type": "Direct",
- "requested": "[7.1.0, )",
- "resolved": "7.1.0",
- "contentHash": "yKPkjufNvrX5VDv49XHaGbUNPL4kDx9ALCrqDhYu+ThFQxjM8zGMzYxPv9iiwRU0ZL3vhgq0F2KEeoV3PT2utQ==",
+ "requested": "[7.1.1, )",
+ "resolved": "7.1.1",
+ "contentHash": "Moz4+bba/OdEM9I4jXWggduJ4sqoNcgVAQyXG/dNnGGvnS/YDr2fTjP83fg5YX3drgRki6fRAhR94P8dAZgZtQ==",
"dependencies": {
"Microsoft.Bcl.Cryptography": "9.0.18",
- "Microsoft.Data.SqlClient.Extensions.Abstractions": "[7.1.0, 8.0.0)",
- "Microsoft.Data.SqlClient.Internal.Logging": "[7.1.0, 8.0.0)",
+ "Microsoft.Data.SqlClient.Extensions.Abstractions": "[7.1.1, 8.0.0)",
+ "Microsoft.Data.SqlClient.Internal.Logging": "[7.1.1, 8.0.0)",
"Microsoft.Data.SqlClient.SNI.runtime": "[7.1.0, 8.0.0)",
"Microsoft.Extensions.Caching.Memory": "9.0.18",
"Microsoft.IdentityModel.JsonWebTokens": "8.16.0",
@@ -69,16 +69,16 @@
},
"Microsoft.Data.SqlClient.Extensions.Abstractions": {
"type": "Transitive",
- "resolved": "7.1.0",
- "contentHash": "8xqe8rxLEJealAw21Rh/w+Kis4TtgAZQ+e7HrTkG/gOeiy3CbCtcMXJr3P5H9txhUQNNxkoR9qy1FIk5T85FSg==",
+ "resolved": "7.1.1",
+ "contentHash": "qP/zUOCJwfKqWNC9o+TWqltfNbLnZi1APNpJZgj0yeEJhoI+h+eoQLX+331yZHNOlDxdafZLP5arW/yrS1jAAw==",
"dependencies": {
- "Microsoft.Data.SqlClient.Internal.Logging": "[7.1.0, 8.0.0)"
+ "Microsoft.Data.SqlClient.Internal.Logging": "[7.1.1, 8.0.0)"
}
},
"Microsoft.Data.SqlClient.Internal.Logging": {
"type": "Transitive",
- "resolved": "7.1.0",
- "contentHash": "z+uzJ+/xqZlYSlv0slMhiHd3TRZr20ijZmjUG2qOaNjKzMMO5cx8itHQ0s/YguqMq/8BMlXZe5n6XSgswe+73g=="
+ "resolved": "7.1.1",
+ "contentHash": "bVqbATLnHQngue7U9K7GRnzvfdDa7IyiBU2vijfU2vBMBSZrAnQp/wXqNSfaVonckSaMXUk07wfujTqy9IVMdw=="
},
"Microsoft.Data.SqlClient.SNI.runtime": {
"type": "Transitive",
@@ -404,20 +404,20 @@
"installer.core": {
"type": "Project",
"dependencies": {
- "Microsoft.Data.SqlClient": "[7.1.0, )",
- "Microsoft.Data.SqlClient.Extensions.Azure": "[7.1.0, )"
+ "Microsoft.Data.SqlClient": "[7.1.1, )",
+ "Microsoft.Data.SqlClient.Extensions.Azure": "[7.1.1, )"
}
},
"Microsoft.Data.SqlClient.Extensions.Azure": {
"type": "CentralTransitive",
- "requested": "[7.1.0, )",
- "resolved": "7.1.0",
- "contentHash": "ZMfBGoTa2D1VugOSh8y6Klv9mh8hefgjygAFiSHAl/1dw+EehRrBl6YFbB8IX27XUHj1tQ0I+bdGMZ5DtwMOlw==",
+ "requested": "[7.1.1, )",
+ "resolved": "7.1.1",
+ "contentHash": "3/Z+6JJKGUw8+shNtftUSGOagt7XCbuhXknZjzXQfDr5aA4lUEMtU/MMNSiwMIrkviYslNKvFh1yWOArudDxQA==",
"dependencies": {
"Azure.Core": "1.51.1",
"Azure.Identity": "1.18.0",
- "Microsoft.Data.SqlClient.Extensions.Abstractions": "[7.1.0, 8.0.0)",
- "Microsoft.Data.SqlClient.Internal.Logging": "[7.1.0, 8.0.0)",
+ "Microsoft.Data.SqlClient.Extensions.Abstractions": "[7.1.1, 8.0.0)",
+ "Microsoft.Data.SqlClient.Internal.Logging": "[7.1.1, 8.0.0)",
"Microsoft.Extensions.Caching.Memory": "8.0.1",
"Microsoft.Identity.Client": "4.84.2",
"Microsoft.Identity.Client.Broker": "4.84.2"
diff --git a/deprecated/Installer/packages.lock.json b/deprecated/Installer/packages.lock.json
index b96828d7f1..278fcdd358 100644
--- a/deprecated/Installer/packages.lock.json
+++ b/deprecated/Installer/packages.lock.json
@@ -4,13 +4,13 @@
"net10.0": {
"Microsoft.Data.SqlClient": {
"type": "Direct",
- "requested": "[7.1.0, )",
- "resolved": "7.1.0",
- "contentHash": "yKPkjufNvrX5VDv49XHaGbUNPL4kDx9ALCrqDhYu+ThFQxjM8zGMzYxPv9iiwRU0ZL3vhgq0F2KEeoV3PT2utQ==",
+ "requested": "[7.1.1, )",
+ "resolved": "7.1.1",
+ "contentHash": "Moz4+bba/OdEM9I4jXWggduJ4sqoNcgVAQyXG/dNnGGvnS/YDr2fTjP83fg5YX3drgRki6fRAhR94P8dAZgZtQ==",
"dependencies": {
"Microsoft.Bcl.Cryptography": "9.0.18",
- "Microsoft.Data.SqlClient.Extensions.Abstractions": "[7.1.0, 8.0.0)",
- "Microsoft.Data.SqlClient.Internal.Logging": "[7.1.0, 8.0.0)",
+ "Microsoft.Data.SqlClient.Extensions.Abstractions": "[7.1.1, 8.0.0)",
+ "Microsoft.Data.SqlClient.Internal.Logging": "[7.1.1, 8.0.0)",
"Microsoft.Data.SqlClient.SNI.runtime": "[7.1.0, 8.0.0)",
"Microsoft.Extensions.Caching.Memory": "9.0.18",
"Microsoft.IdentityModel.JsonWebTokens": "8.16.0",
@@ -61,16 +61,16 @@
},
"Microsoft.Data.SqlClient.Extensions.Abstractions": {
"type": "Transitive",
- "resolved": "7.1.0",
- "contentHash": "8xqe8rxLEJealAw21Rh/w+Kis4TtgAZQ+e7HrTkG/gOeiy3CbCtcMXJr3P5H9txhUQNNxkoR9qy1FIk5T85FSg==",
+ "resolved": "7.1.1",
+ "contentHash": "qP/zUOCJwfKqWNC9o+TWqltfNbLnZi1APNpJZgj0yeEJhoI+h+eoQLX+331yZHNOlDxdafZLP5arW/yrS1jAAw==",
"dependencies": {
- "Microsoft.Data.SqlClient.Internal.Logging": "[7.1.0, 8.0.0)"
+ "Microsoft.Data.SqlClient.Internal.Logging": "[7.1.1, 8.0.0)"
}
},
"Microsoft.Data.SqlClient.Internal.Logging": {
"type": "Transitive",
- "resolved": "7.1.0",
- "contentHash": "z+uzJ+/xqZlYSlv0slMhiHd3TRZr20ijZmjUG2qOaNjKzMMO5cx8itHQ0s/YguqMq/8BMlXZe5n6XSgswe+73g=="
+ "resolved": "7.1.1",
+ "contentHash": "bVqbATLnHQngue7U9K7GRnzvfdDa7IyiBU2vijfU2vBMBSZrAnQp/wXqNSfaVonckSaMXUk07wfujTqy9IVMdw=="
},
"Microsoft.Data.SqlClient.SNI.runtime": {
"type": "Transitive",
@@ -288,20 +288,20 @@
"installer.core": {
"type": "Project",
"dependencies": {
- "Microsoft.Data.SqlClient": "[7.1.0, )",
- "Microsoft.Data.SqlClient.Extensions.Azure": "[7.1.0, )"
+ "Microsoft.Data.SqlClient": "[7.1.1, )",
+ "Microsoft.Data.SqlClient.Extensions.Azure": "[7.1.1, )"
}
},
"Microsoft.Data.SqlClient.Extensions.Azure": {
"type": "CentralTransitive",
- "requested": "[7.1.0, )",
- "resolved": "7.1.0",
- "contentHash": "ZMfBGoTa2D1VugOSh8y6Klv9mh8hefgjygAFiSHAl/1dw+EehRrBl6YFbB8IX27XUHj1tQ0I+bdGMZ5DtwMOlw==",
+ "requested": "[7.1.1, )",
+ "resolved": "7.1.1",
+ "contentHash": "3/Z+6JJKGUw8+shNtftUSGOagt7XCbuhXknZjzXQfDr5aA4lUEMtU/MMNSiwMIrkviYslNKvFh1yWOArudDxQA==",
"dependencies": {
"Azure.Core": "1.51.1",
"Azure.Identity": "1.18.0",
- "Microsoft.Data.SqlClient.Extensions.Abstractions": "[7.1.0, 8.0.0)",
- "Microsoft.Data.SqlClient.Internal.Logging": "[7.1.0, 8.0.0)",
+ "Microsoft.Data.SqlClient.Extensions.Abstractions": "[7.1.1, 8.0.0)",
+ "Microsoft.Data.SqlClient.Internal.Logging": "[7.1.1, 8.0.0)",
"Microsoft.Extensions.Caching.Memory": "8.0.1",
"Microsoft.Identity.Client": "4.84.2",
"Microsoft.Identity.Client.Broker": "4.84.2"
@@ -326,13 +326,13 @@
"net10.0/win-x64": {
"Microsoft.Data.SqlClient": {
"type": "Direct",
- "requested": "[7.1.0, )",
- "resolved": "7.1.0",
- "contentHash": "yKPkjufNvrX5VDv49XHaGbUNPL4kDx9ALCrqDhYu+ThFQxjM8zGMzYxPv9iiwRU0ZL3vhgq0F2KEeoV3PT2utQ==",
+ "requested": "[7.1.1, )",
+ "resolved": "7.1.1",
+ "contentHash": "Moz4+bba/OdEM9I4jXWggduJ4sqoNcgVAQyXG/dNnGGvnS/YDr2fTjP83fg5YX3drgRki6fRAhR94P8dAZgZtQ==",
"dependencies": {
"Microsoft.Bcl.Cryptography": "9.0.18",
- "Microsoft.Data.SqlClient.Extensions.Abstractions": "[7.1.0, 8.0.0)",
- "Microsoft.Data.SqlClient.Internal.Logging": "[7.1.0, 8.0.0)",
+ "Microsoft.Data.SqlClient.Extensions.Abstractions": "[7.1.1, 8.0.0)",
+ "Microsoft.Data.SqlClient.Internal.Logging": "[7.1.1, 8.0.0)",
"Microsoft.Data.SqlClient.SNI.runtime": "[7.1.0, 8.0.0)",
"Microsoft.Extensions.Caching.Memory": "9.0.18",
"Microsoft.IdentityModel.JsonWebTokens": "8.16.0",
From caaf0ed36427631b7f82d9aae9d2e456aa6c3faf Mon Sep 17 00:00:00 2001
From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com>
Date: Fri, 2 Oct 2026 15:21:46 -0400
Subject: [PATCH 007/399] On Azure SQL Database, the long-query trace runs only
in monitored databases and no longer drops a session another registration
still uses (#4944)
On Azure SQL Database, the long-query trace runs only in monitored databases, and turning it off no longer removes a trace that another registration of the same database still uses. Darling now re-creates a long-query trace session dropped from outside within an hour, instead of reading nothing until it reconnects, and waits an hour before it tries again after a failed hourly attempt. `--drop-xe-sessions` now also drops the long-query trace session from the databases a server excludes, and still leaves any database that is monitored as its own server or where another registration keeps the session. An excluded database it cannot open is reported in a note and does not change the exit code. Lite no longer drops the session from a database another registration keeps when the engine edition reads blank and then 5 during one cycle.
---
.../CollectorDatabaseScopeRungTests.cs | 11 +-
.../Darling.Tests/DropXeSessionsVerbTests.cs | 258 ++++++-
.../LongQueryBannerSessionScopeTests.cs | 99 +++
.../LongQueryTraceLifecycleTests.cs | 705 ++++++++++++++++++
.../PostgresEngineGateBehaviorTests.cs | 6 +-
.../Darling.Tests/RepoFileAdoptionTests.cs | 3 +
.../SwallowedItemFailureTests.cs | 16 +-
Darling/Darling.Tests/TargetProviderTests.cs | 27 +
.../Darling.Tests/TsqlConventionGuardTests.cs | 9 +
.../DarlingCliCommands.cs | 24 +-
.../DarlingCollectorRunner.cs | 72 +-
.../DarlingWorker.cs | 231 +++++-
.../DarlingXeSessionCleanup.cs | 194 ++++-
.../DarlingXeSessions.cs | 245 ++++--
.../ViewerServerTab.LongQueries.cs | 13 +
.../ViewerServerTab.xaml | 3 +-
Darling/README.md | 6 +-
.../LongQueryBannerSessionScopeTests.cs | 71 ++
.../LongQueryTraceLifecycleLiteTests.cs | 684 +++++++++++++++++
Lite/Controls/ServerTab.LongQueries.cs | 13 +
Lite/Controls/ServerTab.xaml | 3 +-
Lite/Services/KnownEngineEditions.cs | 27 +-
...teCollectorService.BlockedProcessReport.cs | 35 +-
...RemoteCollectorService.DefinitionRunner.cs | 7 +
...teCollectorService.LongQueryCompletions.cs | 363 +++++++--
Lite/Services/RemoteCollectorService.cs | 30 +-
Lite/Services/ScheduleManager.cs | 2 +-
Lite/config/collection_schedule.json | 2 +-
.../AzureSweepScope.cs | 16 +
.../CollectorDefinitionBase.cs | 2 +
.../CollectorScheduleDefaults.cs | 3 +-
.../ICollectorDefinition.cs | 8 +
.../LongQueryCompletionsCollector.cs | 17 +
.../LongQueryTraceDatabases.cs | 442 +++++++++++
34 files changed, 3440 insertions(+), 207 deletions(-)
create mode 100644 Darling/Darling.Tests/LongQueryBannerSessionScopeTests.cs
create mode 100644 Darling/Darling.Tests/LongQueryTraceLifecycleTests.cs
create mode 100644 Lite.Tests/LongQueryBannerSessionScopeTests.cs
create mode 100644 Lite.Tests/LongQueryTraceLifecycleLiteTests.cs
create mode 100644 PerformanceMonitor.Collectors/LongQueryTraceDatabases.cs
diff --git a/Darling/Darling.Tests/CollectorDatabaseScopeRungTests.cs b/Darling/Darling.Tests/CollectorDatabaseScopeRungTests.cs
index 3eb84e8d55..c00f190837 100644
--- a/Darling/Darling.Tests/CollectorDatabaseScopeRungTests.cs
+++ b/Darling/Darling.Tests/CollectorDatabaseScopeRungTests.cs
@@ -473,9 +473,11 @@ public void BothProviders_SpliceTheScopeIntoTheDatabaseListPlan_AndTheMaintenanc
///
/// The runner feeds ONE resolution per run to all three consumers — the dispatch probe, the
/// per-database loop's list plan, and the enumeration context — and the stated boundaries hold:
- /// the worker's delegate reads the SAME live overrides the cadence gate reads; the Azure XE
- /// session provisioner deliberately passes NO scope (session inventory is server-shaped — three
- /// collectors with three possible scopes share those sessions); and Lite's definition runner
+ /// the worker's delegate reads the SAME live overrides the cadence gate reads; the always-on Azure XE
+ /// session provisioner deliberately passes NO scope (session inventory is server-shaped — one list
+ /// provisions the deadlock and blocked-process sessions, whose collectors may carry different scopes), while the opt-in long-query trace
+ /// follows its own collector's scope through the runner's accessor (its lifecycle covers what its read
+ /// covers); and Lite's definition runner
/// never names the scope at all, so every Lite query stays byte-identical to the unscoped form.
///
[Fact]
@@ -494,7 +496,8 @@ public void TheRunnerResolvesOnceAndFeedsAllThreeConsumers_AndTheStatedBoundarie
worker, StringComparison.Ordinal);
var xe = RepoFile.ReadRepoFile("Darling", "PerformanceMonitor.Darling.Service", "DarlingXeSessions.cs");
- Assert.Equal(2, CountOf(xe, "GetAzureDatabaseListAsync(server, databaseScope: null"));
+ Assert.Equal(1, CountOf(xe, "GetAzureDatabaseListAsync(server, databaseScope: null"));
+ Assert.Contains("runner.DatabaseScopeFor(LongQueryCompletionsCollector.Instance.Name, server.ServerId)", xe, StringComparison.Ordinal);
var lite = RepoFile.ReadRepoFile("Lite", "Services", "RemoteCollectorService.DefinitionRunner.cs");
Assert.DoesNotContain("DatabaseScope", lite, StringComparison.Ordinal);
diff --git a/Darling/Darling.Tests/DropXeSessionsVerbTests.cs b/Darling/Darling.Tests/DropXeSessionsVerbTests.cs
index 1ae111b475..0c219aff4d 100644
--- a/Darling/Darling.Tests/DropXeSessionsVerbTests.cs
+++ b/Darling/Darling.Tests/DropXeSessionsVerbTests.cs
@@ -38,6 +38,209 @@ public sealed class DropXeSessionsVerbTests
/// a session (#4732).
private const string CaptureStopsMarker = "stops until this service reconnects to it, so remove the server next.";
+ // ---- Azure SQL Database: which databases the verb searches for which session -------------------------------------------
+
+ private const string AzureHost = "dropxe.database.windows.net";
+
+ private static SqlServerXeSessionCleanupTarget AzureTarget(MonitoredServer self, params MonitoredServer[] registry) =>
+ new(
+ new ServerRuntime
+ {
+ Config = self,
+ ConnectionString = $"Server=tcp:{AzureHost},1433;Initial Catalog=master;Encrypt=True",
+ Target = new CollectorTargetInfo { IsAzureSqlDb = true },
+ StorageName = AzureHost,
+ ServerId = self.ServerId,
+ },
+ sessionNames: null,
+ registry);
+
+ ///
+ /// The long-query session is searched in the databases the registration excludes, because a session created before the
+ /// database was excluded stays there with nothing that drops it. It is not searched in a database monitored as its own
+ /// server, which that server's registration owns, and never in master. The other sessions keep the monitored list.
+ ///
+ [Fact]
+ public void TheLongQuerySearch_VisitsAnExcludedDatabase_AndSkipsOneMonitoredAsItsOwnServer()
+ {
+ var self = new MonitoredServer { Name = "dropxe", Host = AzureHost, ExcludedDatabases = ["gamma"] };
+ var alphaOwnServer = new MonitoredServer { Name = "dropxe-alpha", Host = AzureHost, Database = "alpha" };
+ var target = AzureTarget(self, self, alphaOwnServer);
+
+ var plan = target.PlanAzureSearch(
+ monitored: ["master", "alpha", "beta"],
+ every: ["master", "alpha", "beta", "gamma"]);
+
+ Assert.Equal(new[] { "beta", "gamma" }, plan.LongQueryDatabases);
+ Assert.Equal(new[] { "alpha", "beta" }, plan.AlwaysOnDatabases);
+ Assert.True(plan.Reports("gamma", LongQuery));
+ Assert.False(plan.Reports("gamma", Deadlock));
+ Assert.False(plan.Reports("alpha", LongQuery));
+ Assert.True(plan.Reports("alpha", Blocked));
+ Assert.False(plan.Reports("master", LongQuery));
+ Assert.Equal(new[] { "alpha", "beta", "gamma" }, plan.Visited);
+ }
+
+ ///
+ /// The verb cannot read another registration's long-query schedule, so it counts every other registration of the logical
+ /// server as keeping the session: it leaves each database that registration would create the session in.
+ ///
+ [Fact]
+ public void TheLongQuerySearch_SkipsADatabaseAnotherRegistrationOfTheServerKeeps()
+ {
+ var self = new MonitoredServer { Name = "dropxe", Host = AzureHost, ExcludedDatabases = ["gamma"] };
+ var second = new MonitoredServer { Name = "dropxe-ro", Host = AzureHost, ReadOnlyIntent = true, ExcludedDatabases = ["delta"] };
+ var target = AzureTarget(self, self, second);
+
+ var plan = target.PlanAzureSearch(
+ monitored: ["master", "alpha", "delta"],
+ every: ["master", "alpha", "gamma", "delta"]);
+
+ Assert.Equal(new[] { "delta" }, plan.LongQueryDatabases);
+ }
+
+ [Fact]
+ public void ATargetWithItsOwnNames_SearchesNoDatabaseForTheLongQuerySession()
+ {
+ var self = new MonitoredServer { Name = "dropxe", Host = AzureHost, ExcludedDatabases = ["gamma"] };
+ var runtime = new ServerRuntime
+ {
+ Config = self,
+ ConnectionString = $"Server=tcp:{AzureHost},1433;Initial Catalog=master;Encrypt=True",
+ Target = new CollectorTargetInfo { IsAzureSqlDb = true },
+ StorageName = AzureHost,
+ ServerId = self.ServerId,
+ };
+ var target = new SqlServerXeSessionCleanupTarget(runtime, new[] { "dropxe_test_only" }, [self]);
+
+ var plan = target.PlanAzureSearch(["master", "alpha"], ["master", "alpha", "gamma"]);
+
+ Assert.Empty(plan.LongQueryDatabases);
+ Assert.Equal(new[] { "alpha" }, plan.AlwaysOnDatabases);
+ }
+
+ // ---- a database the search cannot open: a problem when it is monitored, a note when the registration excludes it ----------
+
+ /// The registration excludes gamma and monitors alpha as its own server: the plan visits alpha and beta for the
+ /// always-on sessions and beta and gamma for the long-query one, so gamma is searched for that session alone.
+ private static SqlServerXeSessionCleanupTarget.AzureSearchPlan ExcludedGammaPlan()
+ {
+ var self = new MonitoredServer { Name = "dropxe", Host = AzureHost, ExcludedDatabases = ["gamma"] };
+ var alphaOwnServer = new MonitoredServer { Name = "dropxe-alpha", Host = AzureHost, Database = "alpha" };
+ return AzureTarget(self, self, alphaOwnServer).PlanAzureSearch(
+ monitored: ["master", "alpha", "beta"],
+ every: ["master", "alpha", "beta", "gamma"]);
+ }
+
+ /// The target's real Azure search loop under the real executor, with the read of one database replaced: the
+ /// database refuses the connection, alpha holds the deadlock session and beta the long-query
+ /// one.
+ private sealed class AzureSearchTarget : IXeSessionCleanupTarget
+ {
+ private readonly SqlServerXeSessionCleanupTarget.AzureSearchPlan _plan;
+ private readonly string _refusing;
+
+ public AzureSearchTarget(SqlServerXeSessionCleanupTarget.AzureSearchPlan plan, string refusing)
+ {
+ _plan = plan;
+ _refusing = refusing;
+ }
+
+ public List Dropped { get; } = [];
+
+ public Task FindSessionsAsync(CancellationToken cancellationToken) =>
+ SqlServerXeSessionCleanupTarget.SearchAzureDatabasesAsync(_plan, NamesInDatabaseAsync, cancellationToken);
+
+ public Task DropAsync(XeSessionDrop drop, CancellationToken cancellationToken)
+ {
+ Dropped.Add(drop);
+ return Task.CompletedTask;
+ }
+
+ private Task> NamesInDatabaseAsync(string database, CancellationToken cancellationToken)
+ {
+ if (string.Equals(database, _refusing, StringComparison.OrdinalIgnoreCase))
+ {
+ throw new InvalidOperationException("login failed");
+ }
+
+ return Task.FromResult>(database switch
+ {
+ "alpha" => new[] { Deadlock },
+ "beta" => new[] { LongQuery },
+ _ => Array.Empty(),
+ });
+ }
+ }
+
+ private static async Task<(int Exit, string Error, AzureSearchTarget Target)> RunAzureSearchAsync(string refusing)
+ {
+ var target = new AzureSearchTarget(ExcludedGammaPlan(), refusing);
+ var error = new StringWriter();
+ var exit = await DarlingXeSessionCleanup.RunAsync("sql01", dryRun: false, target, new StringWriter(), error, CancellationToken.None);
+ return (exit, error.ToString(), target);
+ }
+
+ ///
+ /// The long-query search opens databases the registration excludes, which the verb never opened before it. An excluded
+ /// database the login cannot open (no user there, a paused serverless database) must not fail a verb that dropped every
+ /// session it found: a script that runs the verb and then removes the server would stop there. It is a note on stderr that
+ /// names the database, says it is excluded and says a session left there needs a manual drop.
+ ///
+ [Fact]
+ public async Task AnExcludedDatabaseTheLoginCannotOpen_IsANoteOnStderr_AndTheRunStillSucceeds()
+ {
+ var (exit, error, target) = await RunAzureSearchAsync(refusing: "gamma");
+
+ Assert.Equal(0, exit);
+ Assert.Equal(
+ [
+ ("alpha", Deadlock),
+ ("beta", LongQuery),
+ ],
+ target.Dropped.Select(drop => (drop.Session.Database!, drop.Session.Name)).ToArray());
+ Assert.Equal(
+ $"Database gamma is excluded from monitoring and could not be searched for the long-query completion session (login failed), so a {LongQuery} session left there needs a manual drop."
+ + Environment.NewLine,
+ error);
+ }
+
+ /// A database the registration monitors is searched for every session, so one the login cannot open still fails
+ /// the run with the unavailable code, as it did before the search reached excluded databases. This passes without the
+ /// exclusion rule too: it is the guard that the rule did not loosen the monitored case.
+ [Fact]
+ public async Task AMonitoredDatabaseTheLoginCannotOpen_StillFailsTheRun_WithTheUnavailableCode()
+ {
+ var (exit, error, target) = await RunAzureSearchAsync(refusing: "beta");
+
+ Assert.Equal(2, exit);
+ Assert.Equal(
+ "Could not search database beta for Extended Events sessions: login failed" + Environment.NewLine,
+ error);
+ Assert.DoesNotContain("excluded from monitoring", error, StringComparison.Ordinal);
+ Assert.Equal(new[] { ("alpha", Deadlock) }, target.Dropped.Select(drop => (drop.Session.Database!, drop.Session.Name)).ToArray());
+ }
+
+ [Fact]
+ public void ADatabaseSearchedOnlyForTheLongQuerySession_IsFiledAsANote_AndAMonitoredOneAsAProblem()
+ {
+ var plan = ExcludedGammaPlan();
+
+ var excluded = plan.Unsearched("gamma", "login failed");
+ Assert.False(excluded.IsProblem);
+ Assert.Contains("Database gamma is excluded from monitoring", excluded.Text, StringComparison.Ordinal);
+ Assert.Contains("could not be searched for the long-query completion session (login failed)", excluded.Text, StringComparison.Ordinal);
+ Assert.Contains($"{LongQuery} session left there needs a manual drop", excluded.Text, StringComparison.Ordinal);
+
+ var monitored = plan.Unsearched("beta", "login failed");
+ Assert.True(monitored.IsProblem);
+ Assert.Equal("Could not search database beta for Extended Events sessions: login failed", monitored.Text);
+
+ // Database names compare without regard to case, as the rest of the plan does.
+ Assert.True(plan.Unsearched("BETA", "login failed").IsProblem);
+ Assert.False(plan.Unsearched("GAMMA", "login failed").IsProblem);
+ }
+
// ---- classification, help and dispatch -----------------------------------------------------------------------------
[Theory]
@@ -328,6 +531,8 @@ private sealed class FakeTarget : IXeSessionCleanupTarget
public List Problems { get; } = [];
+ public List Notes { get; } = [];
+
public Exception? SearchFails { get; set; }
public HashSet Refused { get; } = [];
@@ -336,7 +541,7 @@ private sealed class FakeTarget : IXeSessionCleanupTarget
public Task FindSessionsAsync(CancellationToken cancellationToken) =>
SearchFails is null
- ? Task.FromResult(new XeSessionSearch(Found.ToList(), Problems.ToList()))
+ ? Task.FromResult(new XeSessionSearch(Found.ToList(), Problems.ToList()) { Notes = Notes.ToList() })
: throw SearchFails;
public Task DropAsync(XeSessionDrop drop, CancellationToken cancellationToken)
@@ -489,6 +694,35 @@ public async Task WhenADatabaseCouldNotBeSearched_NothingFound_DoesNotClaimTheSe
Assert.Contains("database hr", error, StringComparison.Ordinal);
}
+ [Fact]
+ public async Task ANoteFromTheSearch_IsPrintedOnStderr_AndLeavesTheExitCodeAndTheDropsAlone()
+ {
+ var target = new FakeTarget();
+ target.Found.Add(new ExistingXeSession(Deadlock, XeSessionScope.Server));
+ target.Notes.Add("Database hr is excluded from monitoring and could not be searched for the long-query completion session (login failed), so a session left there needs a manual drop.");
+
+ var (exit, output, error) = await RunAsync(target, dryRun: false);
+
+ Assert.Equal(DarlingCliCommands.DropXeSessionsExitCode.Success, exit);
+ Assert.Single(target.Dropped);
+ Assert.Contains($"[DROPPED] {Deadlock}", output, StringComparison.Ordinal);
+ Assert.Equal(target.Notes[0] + Environment.NewLine, error);
+ Assert.DoesNotContain("[FAILED]", error, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public async Task ANoteWithNothingFound_StillSaysNothingWasDroppedOnlyInThePlacesSearched()
+ {
+ var target = new FakeTarget();
+ target.Notes.Add("Database hr is excluded from monitoring and could not be searched for the long-query completion session (login failed), so a session left there needs a manual drop.");
+
+ var (exit, output, error) = await RunAsync(target, dryRun: false);
+
+ Assert.Equal(DarlingCliCommands.DropXeSessionsExitCode.Success, exit);
+ Assert.Contains("nothing to drop in the places searched", output, StringComparison.Ordinal);
+ Assert.Contains("database hr", error, StringComparison.OrdinalIgnoreCase);
+ }
+
[Fact]
public async Task ARefusedDrop_DoesNotStopTheNext_AndExitsWithTheUnavailableCode()
{
@@ -548,7 +782,7 @@ public async Task ANameTheTargetReturnsThatIsNotDarlings_IsNeverDropped()
// ---- the whole verb through the configuration, with the connection injected ------------------------------------------
- private static Task ThrowingConnect(MonitoredServer server, CancellationToken cancellationToken) =>
+ private static Task ThrowingConnect(MonitoredServer server, IReadOnlyList registry, CancellationToken cancellationToken) =>
throw new InvalidOperationException("the verb connected when it should not have");
private static string WriteConfig(DirectoryInfo root)
@@ -576,7 +810,7 @@ private static string WriteConfig(DirectoryInfo root)
var exit = await DarlingCliCommands.DropXeSessionsAsync(
rest,
- (server, _) =>
+ (server, _, _) =>
{
connected.Add(server.DisplayName);
return connectFails is not null
@@ -814,4 +1048,22 @@ public void TheReadmeSaysWhenToRunTheNamedForm_WhatItStops_WhatToRunAfterTheRemo
Assert.True(listAt >= 0, "the README no longer lists the verbs that open the store");
Assert.Contains("`--drop-xe-sessions `", readme[listAt..Math.Min(readme.Length, listAt + 400)], StringComparison.Ordinal);
}
+
+ [Fact]
+ public void TheUsageAndTheReadme_SayAnExcludedDatabaseThatCannotBeOpenedLeavesTheExitCodeAlone_AndAMonitoredOneExitsTwo()
+ {
+ var usage = DarlingCliCommands.DropXeSessionsUsageText();
+ Assert.Contains("An excluded database that cannot be opened is reported as a note and does not change the exit code", usage, StringComparison.Ordinal);
+ Assert.Contains("a monitored database that cannot be opened exits 2", usage, StringComparison.Ordinal);
+
+ var readme = RepoFile.ReadRepoFile("Darling", "README.md").Replace("\r\n", "\n", StringComparison.Ordinal);
+ var start = readme.IndexOf("### Drop the Extended Events sessions a removed server left behind", StringComparison.Ordinal);
+ Assert.True(start >= 0, "the README no longer has the --drop-xe-sessions section (#4732)");
+ var end = readme.IndexOf("\n---", start, StringComparison.Ordinal);
+ Assert.True(end > start, "the --drop-xe-sessions section no longer ends at a horizontal rule");
+ var section = readme[start..end];
+
+ Assert.Contains("is reported in a note on stderr and does not change the exit code", section, StringComparison.Ordinal);
+ Assert.Contains("a monitored database it cannot open makes the verb exit `2`", section, StringComparison.Ordinal);
+ }
}
diff --git a/Darling/Darling.Tests/LongQueryBannerSessionScopeTests.cs b/Darling/Darling.Tests/LongQueryBannerSessionScopeTests.cs
new file mode 100644
index 0000000000..8a193efad6
--- /dev/null
+++ b/Darling/Darling.Tests/LongQueryBannerSessionScopeTests.cs
@@ -0,0 +1,99 @@
+/*
+ * 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.IO;
+using System.Runtime.CompilerServices;
+using PerformanceMonitor.Collectors;
+using PerformanceMonitor.Darling.Viewer;
+using Xunit;
+
+namespace Darling.Tests;
+
+///
+/// The Viewer's Long Queries banner, shown while the long-query completion trace is off. Its last sentence says where
+/// turning the trace on creates the Extended Events session. On Azure SQL Database the session is per monitored
+/// database (LongQueryCompletionsCollector.RunsPerDatabase), so the banner says so for that target and keeps
+/// the server wording for every other one. The README's sentence about the session says the same.
+///
+public sealed class LongQueryBannerSessionScopeTests
+{
+ [Fact]
+ public void TheViewerBanner_TakesItsTextFromTheTarget_NotFromAFixedServerSentence()
+ {
+ var banner = BannerElement(File.ReadAllText(RepoPath("Darling", "PerformanceMonitor.Darling.Viewer", "ViewerServerTab.xaml")));
+ Assert.DoesNotContain("session on this server", banner, StringComparison.Ordinal);
+
+ var load = MethodBody(RepoPath("Darling", "PerformanceMonitor.Darling.Viewer", "ViewerServerTab.LongQueries.cs"), "private async Task LoadLongQueriesAsync(");
+ Assert.Contains("LongQueriesDisabledWarning.Text = LongQueriesDisabledText(_server.EngineEdition)", load, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void TheViewerBanner_OnAzureSqlDatabase_SaysEachMonitoredDatabase_NotThisServer()
+ {
+ var text = ViewerServerTab.LongQueriesDisabledText(CollectorEngineCapability.AzureSqlDatabaseEngineEdition);
+
+ Assert.EndsWith(
+ "Enabling it creates the Extended Events session in each monitored database. Disabling it drops the session from every database that has it, except where another registration of that database still has the trace on. A database that is also monitored as its own server follows that server's setting.",
+ text,
+ StringComparison.Ordinal);
+ Assert.DoesNotContain("session on this server", text, StringComparison.Ordinal);
+ Assert.Contains("Settings → Collection Schedule → Edit Collector Schedules…", text, StringComparison.Ordinal);
+ }
+
+ [Theory]
+ [InlineData(CollectorEngineCapability.UnknownEngineEdition)]
+ [InlineData(2)]
+ [InlineData(3)]
+ [InlineData(4)]
+ [InlineData(8)]
+ public void TheViewerBanner_OnEveryOtherEdition_KeepsTheServerSentence(int engineEdition)
+ {
+ var text = ViewerServerTab.LongQueriesDisabledText(engineEdition);
+
+ Assert.EndsWith(
+ "Enabling it creates the Extended Events session on this server. Disabling it drops the session.",
+ text,
+ StringComparison.Ordinal);
+ Assert.DoesNotContain("monitored database", text, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void TheReadme_SaysTheSessionIsPerDatabase_OnAzureSqlDatabase()
+ {
+ var readme = File.ReadAllText(RepoPath("Darling", "README.md"));
+
+ Assert.Contains(
+ "On Azure SQL Database the session is database-scoped. Enabling it creates the session in each monitored database and drops it from any other database that has it. Disabling it drops the session from every database that has it. Neither drop removes the session from a database where another registration of that database still has the trace on. A database that is also monitored as its own server is left to that registration.",
+ readme,
+ StringComparison.Ordinal);
+ }
+
+ /// The banner TextBlock's own markup, from its name to the end of its start tag.
+ internal static string BannerElement(string xaml)
+ {
+ var name = xaml.IndexOf("x:Name=\"LongQueriesDisabledWarning\"", StringComparison.Ordinal);
+ Assert.True(name >= 0, "the Long Queries tab has its disabled-trace banner");
+ var end = xaml.IndexOf("/>", name, StringComparison.Ordinal);
+ Assert.True(end > name, "the banner TextBlock is self-closing");
+ return xaml[name..end];
+ }
+
+ private static string MethodBody(string path, string signature)
+ {
+ var source = CSharpSourceWalker.StripCommentsAndStrings(File.ReadAllText(path));
+ var at = source.IndexOf(signature, StringComparison.Ordinal);
+ Assert.True(at >= 0, $"{signature} must exist");
+ return CSharpSourceWalker.BraceBalanced(source, source.IndexOf('{', at));
+ }
+
+ private static string RepoPath(params string[] parts) => Path.Combine([RepoRoot(), .. parts]);
+
+ private static string RepoRoot([CallerFilePath] string thisFile = "")
+ => Path.GetFullPath(Path.Combine(Path.GetDirectoryName(thisFile)!, "..", ".."));
+}
diff --git a/Darling/Darling.Tests/LongQueryTraceLifecycleTests.cs b/Darling/Darling.Tests/LongQueryTraceLifecycleTests.cs
new file mode 100644
index 0000000000..b2892e966e
--- /dev/null
+++ b/Darling/Darling.Tests/LongQueryTraceLifecycleTests.cs
@@ -0,0 +1,705 @@
+/*
+ * 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.Threading;
+using System.Threading.Tasks;
+using Npgsql;
+using PerformanceMonitor.Collectors;
+using PerformanceMonitor.Darling.Service;
+using Xunit;
+using static Darling.Tests.RepoFile;
+
+namespace Darling.Tests;
+
+///
+/// The long-query trace's session lifecycle on Azure SQL Database, where each database holds its own session: which
+/// databases a reconcile creates the session in and drops it from, when the worker counts it done, and how a failed
+/// drop is retried. Each test drives the worker's real reconcile with the database list and the per-database work
+/// replaced on the runner, so no server or store is needed.
+///
+public sealed class LongQueryTraceLifecycleTests : IAsyncDisposable
+{
+ private const string Host = "lqtrace.database.windows.net";
+
+ /* Never opened: the runner reaches the store only to write collected rows, and these tests collect nothing. */
+ private readonly NpgsqlDataSource _store = NpgsqlDataSource.Create("Host=127.0.0.1;Port=1;Database=unused;Username=unused");
+
+ public ValueTask DisposeAsync() => _store.DisposeAsync();
+
+ private sealed class Rig
+ {
+ public required DarlingCollectorRunner Runner { get; init; }
+ public required DarlingWorker.ServerLoopState State { get; init; }
+ public required MonitoredServer Config { get; init; }
+ public DarlingSelfAlertTests.CapturingLogger Logger { get; } = new();
+
+ /* What the logical server's master lists, before the registration's exclusions and scope. */
+ public List Listed { get; set; } = new() { "master", "alpha", "beta", "gamma" };
+ public List Scope { get; set; } = new();
+ public List Owned { get; set; } = new();
+ public List<(string Database, bool Create)> Calls { get; } = new();
+ public HashSet Refuse { get; } = new(StringComparer.OrdinalIgnoreCase);
+ public Exception? ListFailure { get; set; }
+ public int ListCalls { get; set; }
+
+ /* The other registrations of the logical server, and the databases monitored as their own servers as the
+ logical server's registration sees them. */
+ public List Others { get; } = new();
+ public List ServerOwned { get; set; } = new();
+
+ /* The time each reconcile runs at, and the databases that hold the session. */
+ public DateTime Clock { get; set; } = new(2026, 10, 2, 12, 0, 0, DateTimeKind.Utc);
+ public HashSet Sessions { get; } = new(StringComparer.OrdinalIgnoreCase);
+
+ public Task ReconcileAsync(bool enabled) =>
+ DarlingWorker.ReconcileLongQueryTraceAsync(State, Runner, enabled, Others, ServerOwned, Clock, Logger, CancellationToken.None);
+
+ public IEnumerable Dropped => Calls.Where(c => !c.Create).Select(c => c.Database);
+
+ public IEnumerable Created => Calls.Where(c => c.Create).Select(c => c.Database);
+ }
+
+ /// A logical-server registration (no database named) on an Azure SQL Database server.
+ private Rig BuildRig(params string[] excluded) =>
+ BuildRig(new MonitoredServer { Name = "lqtrace", Host = Host, ExcludedDatabases = excluded.ToList() }, ServerId);
+
+ /// A registration of one database of the same logical server, with read-only intent unless told otherwise.
+ private Rig BuildDatabaseRig(string database, bool readOnlyIntent = true)
+ {
+ var rig = BuildRig(
+ new MonitoredServer { Name = "lqtrace-" + database, Host = Host, Database = database, ReadOnlyIntent = readOnlyIntent },
+ DatabaseServerId);
+ rig.Listed = new List { database };
+ return rig;
+ }
+
+ private const int ServerId = 4944;
+ private const int DatabaseServerId = 8001;
+
+ private static LongQueryTraceRegistration LogicalServer(int id, bool traceOn, params string[] excluded) =>
+ new(id.ToString(System.Globalization.CultureInfo.InvariantCulture), Host, null, Enabled: true, traceOn, excluded, DatabaseScope: null);
+
+ private static LongQueryTraceRegistration OneDatabase(int id, string database, bool traceOn) =>
+ new(id.ToString(System.Globalization.CultureInfo.InvariantCulture), Host, database, Enabled: true, traceOn, Array.Empty(), DatabaseScope: null);
+
+ private Rig BuildRig(MonitoredServer config, int serverId)
+ {
+ var runtime = new ServerRuntime
+ {
+ Config = config,
+ ConnectionString = $"Server=tcp:{Host},1433;Initial Catalog={config.Database ?? "master"};Encrypt=True",
+ Target = new CollectorTargetInfo { IsAzureSqlDb = true },
+ StorageName = Host,
+ ServerId = serverId,
+ };
+
+ Rig? rig = null;
+ var runner = new DarlingCollectorRunner(
+ _store,
+ new CollectorDeltaCalculator(),
+ databaseScope: (_, _) => rig!.Scope.ToList(),
+ separatelyMonitoredDatabases: _ => rig!.Owned.ToList());
+
+ rig = new Rig
+ {
+ Runner = runner,
+ State = new DarlingWorker.ServerLoopState { Config = config, Runtime = runtime },
+ Config = config,
+ };
+
+ runner.LongQueryTraceListOverrideForTests = (_, allDatabases, scope, _) =>
+ {
+ rig.ListCalls++;
+ if (rig.ListFailure is { } failure)
+ {
+ throw failure;
+ }
+
+ return Task.FromResult(allDatabases
+ ? rig.Listed.ToList()
+ : rig.Listed
+ .Where(d => !config.ExcludedDatabases.Contains(d, StringComparer.OrdinalIgnoreCase))
+ .Where(d => scope is null || scope.Count == 0 || scope.Contains(d, StringComparer.OrdinalIgnoreCase))
+ .ToList());
+ };
+
+ runner.LongQueryTraceDatabaseOverrideForTests = (_, database, create, _) =>
+ {
+ rig.Calls.Add((database, create));
+ if (rig.Refuse.Contains(database))
+ {
+ return Task.FromException(new InvalidOperationException($"The drop was refused in {database}."));
+ }
+
+ if (create)
+ {
+ rig.Sessions.Add(database);
+ }
+ else
+ {
+ rig.Sessions.Remove(database);
+ }
+
+ return Task.CompletedTask;
+ };
+
+ return rig;
+ }
+
+ /* ── M1: a failed listing or drop is retried, with a cap ── */
+
+ [Fact]
+ public async Task Off_AFailedListing_IsNotMarkedApplied_AndTheNextSweepListsAgain()
+ {
+ var rig = BuildRig();
+ rig.ListFailure = new InvalidOperationException("master is not reachable.");
+
+ await rig.ReconcileAsync(enabled: false);
+
+ Assert.Null(rig.State.LongQueryTraceApplied);
+
+ rig.ListFailure = null;
+ await rig.ReconcileAsync(enabled: false);
+
+ Assert.Equal(2, rig.ListCalls);
+ Assert.Equal(new[] { "alpha", "beta", "gamma" }, rig.Dropped);
+ Assert.False(rig.State.LongQueryTraceApplied);
+ }
+
+ [Fact]
+ public async Task On_AFailedListing_IsNotMarkedApplied()
+ {
+ var rig = BuildRig();
+ rig.ListFailure = new InvalidOperationException("master is not reachable.");
+
+ await rig.ReconcileAsync(enabled: true);
+
+ Assert.Null(rig.State.LongQueryTraceApplied);
+ Assert.Empty(rig.Calls);
+ }
+
+ [Fact]
+ public async Task Off_OneDatabaseRefusesTheDrop_TheOthersAreStillDropped_AndTheNextSweepRetries()
+ {
+ var rig = BuildRig();
+ rig.Refuse.Add("beta");
+
+ await rig.ReconcileAsync(enabled: false);
+
+ Assert.Equal(new[] { "alpha", "beta", "gamma" }, rig.Dropped);
+ Assert.Null(rig.State.LongQueryTraceApplied);
+
+ rig.Calls.Clear();
+ await rig.ReconcileAsync(enabled: false);
+
+ Assert.Contains("beta", rig.Dropped);
+ }
+
+ [Fact]
+ public async Task Off_TheFifthFailedPassInARow_IsMarkedApplied_WithOneWarningThatNamesTheDatabase()
+ {
+ var rig = BuildRig();
+ rig.Refuse.Add("beta");
+
+ for (var pass = 1; pass < LongQueryTraceDatabases.DropAttemptCap; pass++)
+ {
+ await rig.ReconcileAsync(enabled: false);
+ Assert.Null(rig.State.LongQueryTraceApplied);
+ }
+
+ await rig.ReconcileAsync(enabled: false);
+ Assert.False(rig.State.LongQueryTraceApplied);
+
+ /* Done: the next sweep does not try again. */
+ rig.Calls.Clear();
+ await rig.ReconcileAsync(enabled: false);
+ Assert.Empty(rig.Calls);
+
+ var giveUp = Assert.Single(rig.Logger.Entries, e => e.Message.Contains("Stopped retrying", StringComparison.Ordinal));
+ Assert.Equal(Microsoft.Extensions.Logging.LogLevel.Warning, giveUp.Level);
+ Assert.Contains("beta", giveUp.Message, StringComparison.Ordinal);
+ }
+
+ /* ── After the cap: one attempt an hour ── */
+
+ [Fact]
+ public async Task Off_AfterTheCap_TriesAgainOnceAnHour_AtDebug_WithNoSecondWarning()
+ {
+ var rig = BuildRig();
+ rig.Refuse.Add("beta");
+
+ for (var pass = 1; pass <= LongQueryTraceDatabases.DropAttemptCap; pass++)
+ {
+ await rig.ReconcileAsync(enabled: false);
+ }
+
+ Assert.False(rig.State.LongQueryTraceApplied);
+ var giveUp = Assert.Single(rig.Logger.Entries, e => e.Message.Contains("Stopped retrying", StringComparison.Ordinal));
+ Assert.EndsWith(
+ "It tries again once an hour, and right away after a restart, a change to the trace's settings, or a change to"
+ + " another registration of these databases. It also tries again after it reconnects.",
+ giveUp.Message,
+ StringComparison.Ordinal);
+ var warnings = Warnings(rig);
+
+ /* Within the hour: nothing. */
+ rig.Calls.Clear();
+ rig.Clock += LongQueryTraceDatabases.RetryInterval - TimeSpan.FromMinutes(1);
+ await rig.ReconcileAsync(enabled: false);
+ Assert.Empty(rig.Calls);
+
+ /* An hour after the cap: one attempt, and the sweep after it runs nothing. */
+ rig.Clock += TimeSpan.FromMinutes(1);
+ await rig.ReconcileAsync(enabled: false);
+ await rig.ReconcileAsync(enabled: false);
+ Assert.Equal(new[] { "alpha", "beta", "gamma" }, rig.Dropped);
+
+ /* It fails again an hour later. Both failures log at Debug, and the cap's warning stays the only one. */
+ rig.Clock += LongQueryTraceDatabases.RetryInterval;
+ await rig.ReconcileAsync(enabled: false);
+ Assert.Equal(new[] { "alpha", "beta", "gamma", "alpha", "beta", "gamma" }, rig.Dropped);
+
+ Assert.Equal(warnings, Warnings(rig));
+ Assert.Equal(2, rig.Logger.Entries.Count(e =>
+ e.Level == Microsoft.Extensions.Logging.LogLevel.Debug
+ && e.Message.Contains("The next attempt is in an hour.", StringComparison.Ordinal)));
+ Assert.Equal(2, rig.Logger.Entries.Count(e =>
+ e.Level == Microsoft.Extensions.Logging.LogLevel.Debug
+ && e.Message.Contains("[beta]", StringComparison.Ordinal)));
+ Assert.False(rig.State.LongQueryTraceApplied);
+ }
+
+ [Fact]
+ public async Task AfterTheCap_AnAttemptThatSucceeds_StartsTheCountAgain_AndEndsTheHourlyAttempts()
+ {
+ var rig = BuildRig();
+ rig.Refuse.Add("beta");
+
+ for (var pass = 1; pass <= LongQueryTraceDatabases.DropAttemptCap; pass++)
+ {
+ await rig.ReconcileAsync(enabled: false);
+ }
+
+ rig.Refuse.Clear();
+ rig.Calls.Clear();
+ rig.Clock += LongQueryTraceDatabases.RetryInterval;
+ await rig.ReconcileAsync(enabled: false);
+
+ Assert.Equal(new[] { "alpha", "beta", "gamma" }, rig.Dropped);
+ Assert.Null(rig.State.LongQueryTraceDropRetry.NextAttemptUtc);
+
+ /* Done, with nothing left to retry: an hour later nothing runs. */
+ rig.Calls.Clear();
+ rig.Clock += LongQueryTraceDatabases.RetryInterval;
+ await rig.ReconcileAsync(enabled: false);
+ Assert.Empty(rig.Calls);
+ }
+
+ /* ── While the trace is on: the create side again once an hour ── */
+
+ [Fact]
+ public async Task On_ASessionDroppedFromOutside_IsCreatedAgainAnHourLater_AndNothingIsDropped()
+ {
+ /* gamma is excluded, so a pass that ran the drop side would drop it again. */
+ var rig = BuildRig("gamma");
+ await rig.ReconcileAsync(enabled: true);
+
+ Assert.True(rig.State.LongQueryTraceApplied);
+ Assert.Equal(new[] { "gamma" }, rig.Dropped);
+ Assert.Contains("beta", rig.Sessions);
+
+ /* The session is dropped outside the app. The read returns no rows for it, the same as for a quiet trace. */
+ rig.Sessions.Remove("beta");
+ rig.Calls.Clear();
+ rig.Clock += LongQueryTraceDatabases.RetryInterval;
+ await rig.ReconcileAsync(enabled: true);
+
+ Assert.Contains("beta", rig.Sessions);
+ Assert.Equal(new[] { "alpha", "beta" }, rig.Created);
+ Assert.Empty(rig.Dropped);
+ Assert.True(rig.State.LongQueryTraceApplied);
+ }
+
+ [Fact]
+ public async Task On_WithinTheHour_TheLatchedReconcileRunsNothing()
+ {
+ var rig = BuildRig();
+ await rig.ReconcileAsync(enabled: true);
+
+ rig.Calls.Clear();
+ var listCalls = rig.ListCalls;
+ rig.Clock += LongQueryTraceDatabases.RetryInterval - TimeSpan.FromSeconds(1);
+ await rig.ReconcileAsync(enabled: true);
+
+ Assert.Empty(rig.Calls);
+ Assert.Equal(listCalls, rig.ListCalls);
+ }
+
+ [Fact]
+ public async Task On_AnHourlyCreatePassThatThrows_RunsAgainAnHourLater_NotOnEverySweep()
+ {
+ var rig = BuildRig();
+ await rig.ReconcileAsync(enabled: true);
+
+ /* The hourly create pass is due, and the listing fails, so the pass throws. */
+ rig.Clock += LongQueryTraceDatabases.RetryInterval;
+ rig.ListFailure = new InvalidOperationException("master is not reachable.");
+ rig.ListCalls = 0;
+ await rig.ReconcileAsync(enabled: true);
+ Assert.Equal(1, rig.ListCalls);
+ Assert.NotNull(rig.State.LongQueryTraceFault);
+ var failedAt = rig.Clock;
+ var warnings = Warnings(rig);
+
+ /* The sweeps within the hour run nothing, and the fault stays set until a pass succeeds. */
+ foreach (var minutes in new[] { 1, 10, 59 })
+ {
+ rig.Clock = failedAt + TimeSpan.FromMinutes(minutes);
+ await rig.ReconcileAsync(enabled: true);
+ }
+
+ Assert.Equal(1, rig.ListCalls);
+ Assert.Equal(warnings, Warnings(rig));
+ Assert.NotNull(rig.State.LongQueryTraceFault);
+
+ /* An hour after it threw, the pass runs again, and a pass that succeeds clears the fault. */
+ rig.ListFailure = null;
+ rig.Clock = failedAt + LongQueryTraceDatabases.RetryInterval;
+ await rig.ReconcileAsync(enabled: true);
+ Assert.Equal(2, rig.ListCalls);
+ Assert.Null(rig.State.LongQueryTraceFault);
+ }
+
+ [Fact]
+ public async Task On_TheAttemptAfterTheCap_ThatThrowsAnotherError_RunsAgainAnHourLater_NotOnEverySweep()
+ {
+ /* The drop outside the monitored set is refused in the excluded database until the cap gives up. */
+ var rig = BuildRig("gamma");
+ rig.Refuse.Add("gamma");
+ for (var pass = 1; pass <= LongQueryTraceDatabases.DropAttemptCap; pass++)
+ {
+ await rig.ReconcileAsync(enabled: true);
+ }
+
+ /* The attempt after the cap is due, and the listing fails: it throws something other than a failed drop. */
+ rig.Clock += LongQueryTraceDatabases.RetryInterval;
+ rig.ListFailure = new InvalidOperationException("master is not reachable.");
+ rig.ListCalls = 0;
+ await rig.ReconcileAsync(enabled: true);
+ Assert.Equal(1, rig.ListCalls);
+ var failedAt = rig.Clock;
+
+ foreach (var minutes in new[] { 1, 10, 59 })
+ {
+ rig.Clock = failedAt + TimeSpan.FromMinutes(minutes);
+ await rig.ReconcileAsync(enabled: true);
+ }
+
+ Assert.Equal(1, rig.ListCalls);
+
+ rig.Clock = failedAt + LongQueryTraceDatabases.RetryInterval;
+ await rig.ReconcileAsync(enabled: true);
+ Assert.Equal(2, rig.ListCalls);
+ }
+
+ private static int Warnings(Rig rig) =>
+ rig.Logger.Entries.Count(e => e.Level == Microsoft.Extensions.Logging.LogLevel.Warning);
+
+ /* ── M2: the trace follows the monitored set ── */
+
+ [Fact]
+ public async Task On_AScopeChange_DropsTheSessionFromTheDatabaseNowOutOfScope()
+ {
+ var rig = BuildRig();
+
+ await rig.ReconcileAsync(enabled: true);
+ Assert.Equal(new[] { "alpha", "beta", "gamma" }, rig.Created);
+
+ rig.Scope = new List { "alpha", "beta" };
+ rig.Calls.Clear();
+ await rig.ReconcileAsync(enabled: true);
+
+ Assert.Equal(new[] { "gamma" }, rig.Dropped);
+ Assert.DoesNotContain("gamma", rig.Created);
+ }
+
+ [Fact]
+ public async Task Off_DropsTheSessionEverywhere_ExcludedAndOutOfScopeDatabasesIncluded()
+ {
+ var rig = BuildRig("gamma");
+ rig.Scope = new List { "alpha" };
+
+ await rig.ReconcileAsync(enabled: false);
+
+ Assert.Equal(new[] { "alpha", "beta", "gamma" }, rig.Dropped);
+ }
+
+ /* ── L3: a database monitored as its own server belongs to that registration ── */
+
+ [Fact]
+ public async Task Off_LeavesTheSessionOfADatabaseMonitoredAsItsOwnServer()
+ {
+ var rig = BuildRig();
+ rig.Owned = new List { "beta" };
+
+ await rig.ReconcileAsync(enabled: false);
+
+ Assert.Equal(new[] { "alpha", "gamma" }, rig.Dropped);
+ }
+
+ [Fact]
+ public async Task On_DoesNotCreateTheSessionInADatabaseMonitoredAsItsOwnServer()
+ {
+ var rig = BuildRig();
+ rig.Owned = new List { "beta" };
+
+ await rig.ReconcileAsync(enabled: true);
+
+ Assert.Equal(new[] { "alpha", "gamma" }, rig.Created);
+ Assert.DoesNotContain("beta", rig.Dropped);
+ }
+
+ ///
+ /// The trace ON leaves a database monitored as its own server even when this registration's scope leaves it
+ /// out, which would otherwise put it in the drop outside the set. Only the plan's own-server rule keeps it: the
+ /// other registration's trace is off, so no other registration keeps the session there.
+ ///
+ [Fact]
+ public async Task On_ADatabaseMonitoredAsItsOwnServer_AndScopedOut_IsNotDropped()
+ {
+ var rig = BuildRig();
+ rig.Owned = new List { "beta" };
+ rig.ServerOwned = new List { "beta" };
+ rig.Scope = new List { "alpha", "gamma" };
+ rig.Others.Add(OneDatabase(DatabaseServerId, "beta", traceOn: false));
+
+ await rig.ReconcileAsync(enabled: true);
+
+ Assert.DoesNotContain("beta", rig.Dropped);
+ }
+
+ /* ── M1: a drop leaves a database where another registration of the server keeps the session ── */
+
+ [Fact]
+ public async Task Off_LeavesTheDatabaseOfAReadOnlyRegistration_WhileItsTraceIsOn()
+ {
+ var rig = BuildRig();
+ rig.Others.Add(OneDatabase(DatabaseServerId, "beta", traceOn: true));
+
+ await rig.ReconcileAsync(enabled: false);
+
+ Assert.Equal(new[] { "alpha", "gamma" }, rig.Dropped);
+ }
+
+ [Fact]
+ public async Task ReadOnlyRegistration_Off_LeavesItsDatabase_WhileTheLogicalServersTraceIsOnAndCoversIt()
+ {
+ var rig = BuildDatabaseRig("beta");
+ rig.Others.Add(LogicalServer(ServerId, traceOn: true));
+
+ await rig.ReconcileAsync(enabled: false);
+
+ Assert.Empty(rig.Dropped);
+ Assert.False(rig.State.LongQueryTraceApplied);
+ }
+
+ [Fact]
+ public async Task ReadOnlyRegistration_Off_DropsItsDatabase_WhenTheLogicalServerExcludesIt()
+ {
+ var rig = BuildDatabaseRig("beta");
+ rig.Others.Add(LogicalServer(ServerId, traceOn: true, "beta"));
+
+ await rig.ReconcileAsync(enabled: false);
+
+ Assert.Equal(new[] { "beta" }, rig.Dropped);
+ }
+
+ ///
+ /// A registration of one database without read-only intent is monitored as its own server, so the logical
+ /// server's registration never creates the session there and does not keep it. Turning that database's own
+ /// trace off still drops it. The list comes from the worker's own adapter, which counts the registration itself.
+ ///
+ [Fact]
+ public async Task DatabaseRegistration_Off_DropsItsDatabase_WhileTheLogicalServerLeavesItToIt()
+ {
+ var rig = BuildDatabaseRig("beta", readOnlyIntent: false);
+ rig.Others.Add(LogicalServer(ServerId, traceOn: true));
+ rig.ServerOwned = DarlingWorker.LongQueryTraceServerSeparatelyMonitored(
+ Host,
+ new[]
+ {
+ new MonitoredServer { Name = "lqtrace", Host = Host },
+ rig.Config,
+ }).ToList();
+
+ await rig.ReconcileAsync(enabled: false);
+
+ Assert.Equal(new[] { "beta" }, rig.ServerOwned);
+ Assert.Equal(new[] { "beta" }, rig.Dropped);
+ }
+
+ [Fact]
+ public async Task Off_LeavesTheDatabasesOfASecondLogicalServerRegistration_WhileItsTraceIsOn()
+ {
+ var rig = BuildRig();
+ rig.Others.Add(LogicalServer(8002, traceOn: true));
+
+ await rig.ReconcileAsync(enabled: false);
+
+ Assert.Empty(rig.Dropped);
+ }
+
+ [Fact]
+ public async Task On_TheOutsideDrop_LeavesAnExcludedDatabase_ThatASecondRegistrationsTraceCovers()
+ {
+ var rig = BuildRig("gamma");
+ rig.Others.Add(LogicalServer(8002, traceOn: true));
+
+ await rig.ReconcileAsync(enabled: true);
+
+ Assert.Equal(new[] { "alpha", "beta" }, rig.Created);
+ Assert.Empty(rig.Dropped);
+ }
+
+ [Fact]
+ public async Task On_TheOutsideDrop_StillDropsADatabase_ThatBothLogicalServerRegistrationsExclude()
+ {
+ var rig = BuildRig("gamma");
+ rig.Others.Add(LogicalServer(8002, traceOn: true, "gamma"));
+
+ await rig.ReconcileAsync(enabled: true);
+
+ Assert.Equal(new[] { "gamma" }, rig.Dropped);
+ }
+
+ [Fact]
+ public async Task Off_OnceTheOtherRegistrationTurnsItsTraceOff_TheNextPassDrops()
+ {
+ var rig = BuildRig();
+ rig.Others.Add(OneDatabase(DatabaseServerId, "beta", traceOn: true));
+
+ await rig.ReconcileAsync(enabled: false);
+ Assert.DoesNotContain("beta", rig.Dropped);
+
+ /* Nothing changed: the reconcile is done and does not run again. */
+ rig.Calls.Clear();
+ await rig.ReconcileAsync(enabled: false);
+ Assert.Empty(rig.Calls);
+
+ rig.Others[0] = rig.Others[0] with { TraceOn = false };
+ await rig.ReconcileAsync(enabled: false);
+
+ Assert.Contains("beta", rig.Dropped);
+ }
+
+ ///
+ /// The worker's adapter: each live registration of the same logical server, with its trace setting and its
+ /// trace scope, where an empty scope means every database.
+ ///
+ [Fact]
+ public void TheRegistrations_AreTheLiveOnesOfTheSameLogicalServer_WithTheirTraceSettingAndScope()
+ {
+ var master = new MonitoredServer { Name = "lqtrace", Host = Host, ExcludedDatabases = new List { "gamma" } };
+ var replica = new MonitoredServer { Name = "lqtrace-ro", Host = " LQTRACE.database.windows.net ", Database = "beta", ReadOnlyIntent = true };
+ var elsewhere = new MonitoredServer { Name = "other", Host = "other.database.windows.net", Database = "beta" };
+
+ var registrations = DarlingWorker.LongQueryTraceRegistrations(
+ Host,
+ new[] { master, replica, elsewhere },
+ traceOn: serverId => serverId == replica.ServerId,
+ databaseScope: serverId => serverId == master.ServerId ? new[] { "alpha" } : Array.Empty());
+
+ Assert.Equal(2, registrations.Count);
+
+ var first = registrations[0];
+ Assert.Equal(master.ServerId.ToString(System.Globalization.CultureInfo.InvariantCulture), first.Id);
+ Assert.False(first.TraceOn);
+ Assert.Equal(new[] { "gamma" }, first.ExcludedDatabases);
+ Assert.Equal(new[] { "alpha" }, first.DatabaseScope);
+
+ var second = registrations[1];
+ Assert.Equal("beta", second.Database);
+ Assert.True(second.TraceOn);
+ Assert.True(second.Enabled);
+ Assert.Null(second.DatabaseScope);
+ }
+
+ ///
+ /// The state key reads one way: a scope of alpha and beta with gamma excluded is not the same settings as a scope
+ /// of alpha with beta and gamma excluded, and the second leaves beta out of the monitored set.
+ ///
+ [Fact]
+ public void TheStateKey_TellsWhereTheScopeEndsAndTheExclusionsBegin()
+ {
+ var none = Array.Empty();
+
+ Assert.NotEqual(
+ LongQueryTraceDatabases.StateKey(true, new[] { "alpha", "beta" }, new[] { "gamma" }, none, none),
+ LongQueryTraceDatabases.StateKey(true, new[] { "alpha" }, new[] { "beta", "gamma" }, none, none));
+ }
+
+ /* ── the read follows the lifecycle; the always-on sessions do not change ── */
+
+ ///
+ /// The logical server's long-query read skips the databases monitored as their own servers, the same ones its
+ /// lifecycle leaves alone, and the worker hands the runner that list from the same live store set the alert sweep
+ /// uses. Pinned in the source: the read loop needs a live connection per database.
+ ///
+ [Fact]
+ public void TheLongQueryRead_SkipsTheDatabasesMonitoredAsTheirOwnServers()
+ {
+ var runner = ReadRepoFileLf("Darling", "PerformanceMonitor.Darling.Service", "DarlingCollectorRunner.cs");
+ var list = runner.IndexOf(": await GetAzureDatabaseListAsync(server, databaseScope, cancellationToken);", StringComparison.Ordinal);
+ var skip = runner.IndexOf("if (definition.SkipsSeparatelyMonitoredDatabases)", StringComparison.Ordinal);
+ Assert.True(list > 0 && skip > list, "the per-database read filters its list after listing it");
+
+ var worker = ReadRepoFileLf("Darling", "PerformanceMonitor.Darling.Service", "DarlingWorker.cs");
+ Assert.Contains("separatelyMonitoredDatabases: runtime => AzureMasterScope.SeparatelyMonitoredDatabases(", worker, StringComparison.Ordinal);
+ }
+
+ ///
+ /// The sweep hands the reconcile the other registrations and the server's own list of separately monitored
+ /// databases from the live registry, through the two adapters the tests above drive. The server's list is the
+ /// logical server's view for every registration, so it is not the runner's per-registration list, which is empty
+ /// for a registration that names a database. Pinned in the source: the sweep needs a live registry and store.
+ ///
+ [Fact]
+ public void TheSweep_PassesTheRegistrationsAndTheServersOwnList_FromTheLiveRegistry()
+ {
+ var worker = ReadRepoFileLf("Darling", "PerformanceMonitor.Darling.Service", "DarlingWorker.cs");
+ var sweep = worker.IndexOf("private async Task ReconcileLongQueryTraceAsync(ServerLoopState server, DarlingCollectorRunner runner, CancellationToken cancellationToken)", StringComparison.Ordinal);
+ var end = worker.IndexOf("await ReconcileLongQueryTraceAsync(server, runner, enabled, registrations, serverSeparatelyMonitored, DateTime.UtcNow, _logger,", sweep, StringComparison.Ordinal);
+ Assert.True(sweep > 0 && end > sweep, "the sweep's reconcile passes the registrations and the server's list");
+
+ var body = worker[sweep..end];
+ Assert.Contains("var live = _registryState.Read()?.Servers;", body, StringComparison.Ordinal);
+ Assert.Contains("registrations = LongQueryTraceRegistrations(", body, StringComparison.Ordinal);
+ Assert.Contains("serverSeparatelyMonitored = LongQueryTraceServerSeparatelyMonitored(server.Runtime.Config.Host, live);", body, StringComparison.Ordinal);
+ Assert.Contains("otherId => runner.DatabaseScopeFor(LongQueryCompletionsCollector.Instance.Name, otherId)", body, StringComparison.Ordinal);
+ }
+
+ ///
+ /// Guard, not RED first: the always-on deadlock and blocked-process sessions keep their inventory lifecycle. Their
+ /// ensure still lists every inventoried database with no database scope, so a scope or ownership change that moves
+ /// the long-query trace does not move them.
+ ///
+ [Fact]
+ public void TheAlwaysOnSessions_StillFollowTheInventory()
+ {
+ var source = ReadRepoFileLf("Darling", "PerformanceMonitor.Darling.Service", "DarlingXeSessions.cs");
+ var ensure = source.IndexOf("private static async Task EnsureDatabaseScopedAsync(", StringComparison.Ordinal);
+ Assert.True(ensure > 0, "the always-on database-scoped ensure is gone");
+ var body = source[ensure..source.IndexOf("\n }\n", ensure, StringComparison.Ordinal)];
+
+ Assert.Contains("databases = await runner.GetAzureDatabaseListAsync(server, databaseScope: null, cancellationToken);", body, StringComparison.Ordinal);
+ Assert.DoesNotContain("SeparatelyMonitored", body, StringComparison.Ordinal);
+ }
+}
diff --git a/Darling/Darling.Tests/PostgresEngineGateBehaviorTests.cs b/Darling/Darling.Tests/PostgresEngineGateBehaviorTests.cs
index a8671fccfb..246e55fd2b 100644
--- a/Darling/Darling.Tests/PostgresEngineGateBehaviorTests.cs
+++ b/Darling/Darling.Tests/PostgresEngineGateBehaviorTests.cs
@@ -237,7 +237,8 @@ public async Task ReconcileLongQueryCompletions_AgainstAPostgresTarget_ReturnsBe
{
/* runner is null on purpose: reaching it would mean the gate did not fire. */
await DarlingXeSessions.ReconcileLongQueryCompletionsAsync(
- PostgresRuntime(), runner: null!, enabled, NullLogger.Instance, CancellationToken.None);
+ PostgresRuntime(), runner: null!, enabled, LongQueryTracePass.Full, Array.Empty(), Array.Empty(),
+ NullLogger.Instance, CancellationToken.None);
}
///
@@ -254,7 +255,8 @@ public async Task ReconcileLongQueryCompletions_SameStringButSqlServerEngine_Thr
var ex = await Assert.ThrowsAsync(() =>
DarlingXeSessions.ReconcileLongQueryCompletionsAsync(
- ungated, runner: null!, enabled: true, NullLogger.Instance, CancellationToken.None));
+ ungated, runner: null!, enabled: true, LongQueryTracePass.Full, Array.Empty(), Array.Empty(),
+ NullLogger.Instance, CancellationToken.None));
/* The words from the sweep log, so a future reader can match this pin to that incident. */
Assert.Contains("Keyword not supported", ex.Message, StringComparison.Ordinal);
diff --git a/Darling/Darling.Tests/RepoFileAdoptionTests.cs b/Darling/Darling.Tests/RepoFileAdoptionTests.cs
index aee5d863de..7d4c0da948 100644
--- a/Darling/Darling.Tests/RepoFileAdoptionTests.cs
+++ b/Darling/Darling.Tests/RepoFileAdoptionTests.cs
@@ -159,6 +159,9 @@ chart counts are told apart from the constant's prose and the function from its
identical text; the clock-read twin pin likewise compares a multi-line const body one token apart. */
"LocalClockBucketKeyTests.cs",
"LockedModeRestoreCoverageTests.cs",
+ /* Its always-on guard pin slices EnsureDatabaseScopedAsync's body up to the method's closing brace, on an
+ anchor that runs from the line break before that brace to the line break after it. */
+ "LongQueryTraceLifecycleTests.cs",
/* #3541 A10: its top-level-key discriminator anchors on the line break BEFORE the key (a per-row
collection_time inside a Select is indented deeper and must not match), and its tool-body slicing
keys on attribute text either side of one. */
diff --git a/Darling/Darling.Tests/SwallowedItemFailureTests.cs b/Darling/Darling.Tests/SwallowedItemFailureTests.cs
index 95b67dd594..8c223d67f8 100644
--- a/Darling/Darling.Tests/SwallowedItemFailureTests.cs
+++ b/Darling/Darling.Tests/SwallowedItemFailureTests.cs
@@ -66,8 +66,10 @@ public void TheRunner_StampsTheDatabase_OnTheAllFailedRethrow()
///
/// The Azure arm of the long-query reconcile keeps the #2623 account over the databases it tried to
/// ENABLE in, throws the first refusal when every database refused (so the worker does not latch and
- /// the run classifies SESSION_MISSING), and returns the partial note when some did. The DROP arm is
- /// deliberately not scored: no run is dispatched while disabled.
+ /// the run classifies SESSION_MISSING), and returns the partial note when some did. Drops are
+ /// deliberately not scored: no run is dispatched while disabled, and a drop outside the monitored set is
+ /// not a capture outage. The scored loop runs only on the ENABLE side; drops go through the shared
+ /// LongQueryTraceDatabases.DropEachAsync.
///
[Fact]
public void TheAzureReconcile_ThrowsWhenEveryDatabaseRefused_AndReturnsThePartialNoteWhenSomeDid()
@@ -77,9 +79,10 @@ public void TheAzureReconcile_ThrowsWhenEveryDatabaseRefused_AndReturnsThePartia
Assert.Contains("public static async Task ReconcileLongQueryCompletionsAsync(", source, StringComparison.Ordinal);
Assert.Contains("private static async Task ReconcileLongQueryCompletionsAzureAsync(", source, StringComparison.Ordinal);
- /* Scored only while enabling. */
- Assert.Contains("if (enabled)\n {\n attempted++;\n }", source, StringComparison.Ordinal);
- Assert.Contains("if (enabled)\n {\n failed++;\n failedDatabases.Add(databaseName);\n CollectorFaultDatabase.Stamp(ex, databaseName);\n firstFailure ??= ex;\n }", source, StringComparison.Ordinal);
+ /* Scored only while enabling: the loop over the plan's create list counts, the drops do not. */
+ Assert.Contains("foreach (var databaseName in LongQueryTraceDatabases.Plan(enabled: true, Array.Empty(), monitored, separatelyMonitored, keptElsewhere: Array.Empty()).Create)\n {\n cancellationToken.ThrowIfCancellationRequested();\n attempted++;", source, StringComparison.Ordinal);
+ Assert.Contains("failed++;\n failedDatabases.Add(databaseName);\n CollectorFaultDatabase.Stamp(ex, databaseName);\n firstFailure ??= ex;", source, StringComparison.Ordinal);
+ Assert.Contains("LongQueryTraceDatabases.DropEachAsync(", source, StringComparison.Ordinal);
/* All refused: the first failure, raw, after the summary line. Same predicate as the runners'. */
Assert.Contains("if (attempted > 0 && failed == attempted && firstFailure is not null)", source, StringComparison.Ordinal);
@@ -87,9 +90,10 @@ public void TheAzureReconcile_ThrowsWhenEveryDatabaseRefused_AndReturnsThePartia
/* Some refused: the shared composer, so the wording is the runners'. */
Assert.Contains(
- "return EnumeratedCollectorDriver.BuildPartialFailureNote(failed, attempted, failedDatabases, firstFailure?.Message);",
+ "var partialNote = EnumeratedCollectorDriver.BuildPartialFailureNote(failed, attempted, failedDatabases, firstFailure?.Message);",
source,
StringComparison.Ordinal);
+ Assert.Contains("return partialNote;", source, StringComparison.Ordinal);
/* The per-database warning that names the refusing database is KEPT - the account rides beside
it, it does not replace it. */
diff --git a/Darling/Darling.Tests/TargetProviderTests.cs b/Darling/Darling.Tests/TargetProviderTests.cs
index a0a9781e29..9ca5b2d65d 100644
--- a/Darling/Darling.Tests/TargetProviderTests.cs
+++ b/Darling/Darling.Tests/TargetProviderTests.cs
@@ -12,6 +12,7 @@
using Microsoft.Data.SqlClient;
using Npgsql;
using PerformanceMonitor.Collectors;
+using PerformanceMonitor.Darling.Service;
using PerformanceMonitor.Darling.Service.Targets;
using Xunit;
@@ -288,6 +289,32 @@ even though nobody thought to assert against that particular name. */
Assert.Contains("survivors ONLY", note, StringComparison.Ordinal);
}
+ ///
+ /// The runner's Azure database list is the provider's plan with the server's exclusions only when asked for. The
+ /// long-query trace's drops ask for none, so a session created before a database was excluded is still found there.
+ /// Both lifecycle rigs replace the listing, so this reads the plan the real listing runs.
+ ///
+ [Fact]
+ public void TheRunnersAzureDatabaseList_AppliesTheServersExclusionsOnlyWhenAsked()
+ {
+ var server = new ServerRuntime
+ {
+ Config = new MonitoredServer { Name = "excl", Host = "excl.database.windows.net", ExcludedDatabases = ["scratch", "tempdb_clone"] },
+ ConnectionString = "Server=tcp:excl.database.windows.net,1433;Initial Catalog=master;Encrypt=True",
+ Target = new CollectorTargetInfo { IsAzureSqlDb = true },
+ StorageName = "excl.database.windows.net",
+ ServerId = 1,
+ };
+
+ var (_, every) = DarlingCollectorRunner.AzureDatabaseListPlan(server, databaseScope: null, applyExclusions: false);
+ Assert.DoesNotContain("NOT IN", every.Text, StringComparison.Ordinal);
+ Assert.Empty(every.Parameters);
+
+ var (_, monitored) = DarlingCollectorRunner.AzureDatabaseListPlan(server, databaseScope: null, applyExclusions: true);
+ Assert.Contains("name NOT IN (@excl_db_0, @excl_db_1)", monitored.Text, StringComparison.Ordinal);
+ Assert.Equal(2, monitored.Parameters.Count);
+ }
+
///
/// The exclusion list has to reach the right column name on each engine, parameterized rather than
/// interpolated — a database named with a quote must not be able to alter the enumeration query.
diff --git a/Darling/Darling.Tests/TsqlConventionGuardTests.cs b/Darling/Darling.Tests/TsqlConventionGuardTests.cs
index 3c5d132d26..463e3a1368 100644
--- a/Darling/Darling.Tests/TsqlConventionGuardTests.cs
+++ b/Darling/Darling.Tests/TsqlConventionGuardTests.cs
@@ -1457,6 +1457,12 @@ of that kind here. */
"Darling/PerformanceMonitor.Darling.Service/Hosting/DarlingWebFailureLog.cs IsStatementTimeout",
"Darling/PerformanceMonitor.Darling.Service/DarlingConfig.cs ToSettings",
"Darling/PerformanceMonitor.Darling.Service/DarlingConfig.cs IsConfigured",
+ /* The long-query trace's database listing: an expression-bodied member whose body opens with a property
+ pattern (`LongQueryTraceListOverrideForTests is { } listOverride`) before its ternary. The walk's brace
+ match closes the range at the pattern's own closing brace, stranding the ternary's arms, which are calls
+ to the test hook and to GetAzureDatabaseListAsync. They hold no string literal, so no census reads a site
+ of that kind here. The Lite twin (RemoteCollectorService.LongQueryCompletions.cs, below) has the same shape. */
+ "Darling/PerformanceMonitor.Darling.Service/DarlingCollectorRunner.cs ListLongQueryTraceDatabasesAsync",
/* #4253: an expression-bodied member whose body opens with a property pattern
(`marker is { State: ... }`) before the rest of the expression. The walk's brace match closes the
range at the pattern's own closing brace, stranding the trailing `&& !cancellationToken.
@@ -1520,6 +1526,9 @@ Lite twin (LocalDataService.FinOps.IndexObjects.cs, below) derives the same four
"Lite/Services/LocalDataService.FinOps.IndexObjects.cs Growth30dMb",
"Lite/Services/LocalDataService.FinOps.IndexObjects.cs Growth7dMb",
"Lite/Services/LocalDataService.FinOps.IndexObjects.cs GrowthOverAvailableHistoryMb",
+ /* The Lite twin of DarlingCollectorRunner's ListLongQueryTraceDatabasesAsync above: the same
+ `is { } listOverride ? … : …` shape, and the same absence of any string literal. */
+ "Lite/Services/RemoteCollectorService.LongQueryCompletions.cs ListLongQueryTraceDatabasesAsync",
/* #4766: the history rows' expression-bodied formatter, `naiveUtc is not { } instant ? "" : ...`. The
property pattern's braces are where the walk stops. What it strands is the "yyyy-MM-dd HH:mm:ss" grid
format and nothing else: not T-SQL and not a tempdb label, so no census reads a site of that kind here. */
diff --git a/Darling/PerformanceMonitor.Darling.Service/DarlingCliCommands.cs b/Darling/PerformanceMonitor.Darling.Service/DarlingCliCommands.cs
index 124beb505e..d2b84b55d4 100644
--- a/Darling/PerformanceMonitor.Darling.Service/DarlingCliCommands.cs
+++ b/Darling/PerformanceMonitor.Darling.Service/DarlingCliCommands.cs
@@ -5386,7 +5386,7 @@ public static string CollectorToggleUsageText() =>
"server's own row instead (its display name or storage name, as the Viewer and the MCP tools show it)." + Environment.NewLine +
"Only the enabled flag is written; a frequency or retention override already on the row is kept." + Environment.NewLine +
Environment.NewLine +
- "Known collectors (\"ships OFF\" = opt-in, like long_query_completions, whose enabling creates its Extended Events session on the monitored servers and whose disabling drops it):" + Environment.NewLine +
+ "Known collectors (\"ships OFF\" = opt-in, like long_query_completions, whose enabling creates its Extended Events session on the monitored servers (on Azure SQL Database, in each monitored database) and whose disabling drops it):" + Environment.NewLine +
KnownCollectorsText();
///
@@ -5886,14 +5886,15 @@ govern. A failure HERE is reported as what it is — the write is already commit
/// can tell its own mistake from a server that is down.
public static class DropXeSessionsExitCode
{
- /// Every session found was dropped (listed, with --dry-run), none was there, or --print-sql printed.
+ /// Every session found was dropped (listed, with --dry-run), none was there, or --print-sql printed. An
+ /// excluded database that could not be searched for the long-query session is a note on stderr and does not change it.
public const int Success = 0;
/// Bad arguments, a configuration that is missing or invalid, a store setting that cannot be used, or a server name
/// that matches no server (the message names --print-sql) or more than one.
public const int UsageOrConfig = 1;
- /// The server cannot be connected to, cannot be searched (all of it, or one database of it), or refused a DROP.
+ /// The server cannot be connected to, cannot be searched (all of it, or one monitored database of it), or refused a DROP.
public const int TargetUnavailable = 2;
}
@@ -5903,7 +5904,9 @@ public static string DropXeSessionsUsageText() =>
" --drop-xe-sessions [--dry-run] [--config ]" + Environment.NewLine +
" Connect to the named server (resolved from the configuration exactly as --validate-config resolves it) and drop the" + Environment.NewLine +
$" Darling Extended Events sessions on it: {DarlingXeSessionCleanup.SessionNamesPhrase()}, server scope, and on" + Environment.NewLine +
- " Azure SQL Database the database-scoped copies in each monitored database. --dry-run lists them and drops nothing." + Environment.NewLine +
+ " Azure SQL Database the database-scoped copies in each monitored database, and for the long query completions session in the" + Environment.NewLine +
+ " databases the server excludes too (not in a database another registration of the server keeps it in). --dry-run lists them and drops nothing." + Environment.NewLine +
+ " An excluded database that cannot be opened is reported as a note and does not change the exit code (a session left in it needs a manual drop); a monitored database that cannot be opened exits 2." + Environment.NewLine +
" Run it just before you remove the server (it finds only a server this service still monitors, and stops that server's deadlock and blocked-process capture until this service reconnects); after the removal, use --print-sql." + Environment.NewLine +
" --drop-xe-sessions --print-sql" + Environment.NewLine +
" Print guarded DROP statements for each of those sessions in both scopes and connect to nothing, for a server that is no longer configured." + Environment.NewLine +
@@ -6050,19 +6053,20 @@ public static Task DropXeSessionsAsync(
string[] rest, TextWriter output, TextWriter error, CancellationToken cancellationToken) =>
DropXeSessionsAsync(rest, ConnectXeSessionCleanupTargetAsync, output, error, cancellationToken);
- /// The connector the public verb uses: the shared server connector, wrapped as the cleanup target. Throws when the
- /// server cannot be reached.
+ /// The connector the public verb uses: the shared server connector, wrapped as the cleanup target, with the servers
+ /// the verb resolved the target in so the target knows the other registrations of an Azure SQL Database server. Throws when
+ /// the server cannot be reached.
private static async Task ConnectXeSessionCleanupTargetAsync(
- MonitoredServer server, CancellationToken cancellationToken)
+ MonitoredServer server, IReadOnlyList registry, CancellationToken cancellationToken)
{
var runtime = await DarlingServerConnector.ConnectAsync(server, logger: null, cancellationToken);
- return new SqlServerXeSessionCleanupTarget(runtime);
+ return new SqlServerXeSessionCleanupTarget(runtime, sessionNames: null, registry);
}
/// The verb with the connection injected, so a test drives the connect-and-drop path without a SQL Server.
internal static async Task DropXeSessionsAsync(
string[] rest,
- Func> connect,
+ Func, CancellationToken, Task> connect,
TextWriter output,
TextWriter error,
CancellationToken cancellationToken)
@@ -6149,7 +6153,7 @@ internal static async Task DropXeSessionsAsync(
IXeSessionCleanupTarget target;
try
{
- target = await connect(server, cancellationToken);
+ target = await connect(server, targets, cancellationToken);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
diff --git a/Darling/PerformanceMonitor.Darling.Service/DarlingCollectorRunner.cs b/Darling/PerformanceMonitor.Darling.Service/DarlingCollectorRunner.cs
index 23a84da7f4..3250b028d7 100644
--- a/Darling/PerformanceMonitor.Darling.Service/DarlingCollectorRunner.cs
+++ b/Darling/PerformanceMonitor.Darling.Service/DarlingCollectorRunner.cs
@@ -388,6 +388,20 @@ write to config_collector_schedules.databases is honored on the collector's NEXT
resolved ONCE per run, at dispatch, so one run cannot see two different scopes. Empty = unscoped. */
private readonly Func> _databaseScope;
+ /* The databases monitored as their own servers, for a runtime on an Azure SQL Database logical server
+ (AzureMasterScope.SeparatelyMonitoredDatabases). Empty for every other runtime. */
+ private readonly Func> _separatelyMonitoredDatabases;
+
+ ///
+ /// The #3477 database scope for one collector on one server: per-server row, then fleet row, then unscoped (empty).
+ /// The same delegate the read loop resolves at dispatch, so the long-query trace's lifecycle and its read cannot
+ /// disagree about which databases are in scope.
+ ///
+ internal IReadOnlyList DatabaseScopeFor(string collectorName, int serverId) => _databaseScope(collectorName, serverId);
+
+ /// The databases monitored as their own servers, for this runtime. Empty unless it is an Azure SQL Database logical server.
+ internal IReadOnlyList SeparatelyMonitoredDatabasesFor(ServerRuntime server) => _separatelyMonitoredDatabases(server);
+
///
/// Per-(server, collector) cycle counter for the #2862 plan-capture cadence. In-memory, and lost on a
/// service restart — deliberately, and harmlessly, which is the whole reason this needs no stored
@@ -694,7 +708,7 @@ private bool ShouldCapturePlanForCollector(string collectorName, int serverId)
/// every cycle and therefore the pre-#2862 collector. Every existing caller and test keeps the
/// collector it already had without naming the knob.
///
- public DarlingCollectorRunner(NpgsqlDataSource postgres, CollectorDeltaCalculator deltas, ILogger? logger = null, Func? capturePlans = null, Func? collectSchemaChanges = null, Func? textBudgetMb = null, Func? compressPlanContent = null, Func? procedureStatsPlanCycleInterval = null, Func>? databaseScope = null, PgLogHashKey? logHashKey = null, QueryStoreWriteFence? queryStoreWriteFence = null)
+ public DarlingCollectorRunner(NpgsqlDataSource postgres, CollectorDeltaCalculator deltas, ILogger? logger = null, Func? capturePlans = null, Func? collectSchemaChanges = null, Func? textBudgetMb = null, Func? compressPlanContent = null, Func? procedureStatsPlanCycleInterval = null, Func>? databaseScope = null, PgLogHashKey? logHashKey = null, QueryStoreWriteFence? queryStoreWriteFence = null, Func>? separatelyMonitoredDatabases = null)
{
_postgres = postgres ?? throw new ArgumentNullException(nameof(postgres));
_queryStoreWriteFence = queryStoreWriteFence;
@@ -715,6 +729,7 @@ public DarlingCollectorRunner(NpgsqlDataSource postgres, CollectorDeltaCalculato
/* Null provider = no scope for any collector = every database the server enumerates, which is
what Lite's twin and every pre-#3477 test constructs. */
_databaseScope = databaseScope ?? ((_, _) => Array.Empty());
+ _separatelyMonitoredDatabases = separatelyMonitoredDatabases ?? (_ => Array.Empty());
/* #4004: the store's log-hash key, loaded once by the worker at start and shared by every run that hashes log
text (pg_log_events on both transports). Null = none could be used: those runs refuse, with the reason. */
_logHashKey = logHashKey;
@@ -2217,6 +2232,13 @@ turn a permissions problem into a silent one-database collection. */
? await listOverride(server, cancellationToken)
: await GetAzureDatabaseListAsync(server, databaseScope, cancellationToken);
+ /* The long-query trace leaves a database monitored as its own server to that registration, so its
+ read does too (LongQueryCompletionsCollector.SkipsSeparatelyMonitoredDatabases). */
+ if (definition.SkipsSeparatelyMonitoredDatabases)
+ {
+ databases = AzureSweepScope.WithoutSeparatelyMonitored(databases, SeparatelyMonitoredDatabasesFor(server));
+ }
+
var attempted = 0;
var failed = 0;
Exception? firstFailure = null;
@@ -6513,6 +6535,30 @@ real watermark may have moved by means this cache never saw (a re-add under the
/// Replaces the Azure per-database list. Null in production.
internal Func>>? AzureDatabaseListOverrideForTests { get; set; }
+ ///
+ /// Replaces the database list the long-query trace reads on Azure SQL Database. Called with allDatabases true
+ /// for every online database, false for the monitored ones narrowed by the given database scope. Null in production.
+ ///
+ internal Func?, CancellationToken, Task>>? LongQueryTraceListOverrideForTests { get; set; }
+
+ ///
+ /// Replaces the long-query trace's work in one Azure SQL Database database: called with create true to create
+ /// the session there, false to drop it. Null in production.
+ ///
+ internal Func? LongQueryTraceDatabaseOverrideForTests { get; set; }
+
+ ///
+ /// The databases the long-query trace works in on Azure SQL Database. With , every
+ /// online database with no exclusions and no scope; otherwise the monitored ones, narrowed by
+ /// . A registration that names a database gets that database either way.
+ ///
+ internal async Task> ListLongQueryTraceDatabasesAsync(ServerRuntime server, bool allDatabases, IReadOnlyList? databaseScope, CancellationToken cancellationToken) =>
+ LongQueryTraceListOverrideForTests is { } listOverride
+ ? await listOverride(server, allDatabases, allDatabases ? null : databaseScope, cancellationToken)
+ : allDatabases
+ ? await GetAzureDatabaseListAsync(server, databaseScope: null, applyExclusions: false, cancellationToken)
+ : await GetAzureDatabaseListAsync(server, databaseScope, cancellationToken);
+
/// Called with the database name just before its batch is written, inside the loop's try. Null in production.
internal Action? PerDatabaseWriteFaultForTests { get; set; }
@@ -6534,7 +6580,15 @@ real watermark may have moved by means this cache never saw (a re-add under the
/// server into whichever registration ran the sweep — N registrations of N databases meant N² collection
/// with every registration's history contaminated by its siblings'.
///
- internal async Task> GetAzureDatabaseListAsync(ServerRuntime server, IReadOnlyList? databaseScope, CancellationToken cancellationToken)
+ internal Task> GetAzureDatabaseListAsync(ServerRuntime server, IReadOnlyList? databaseScope, CancellationToken cancellationToken) =>
+ GetAzureDatabaseListAsync(server, databaseScope, applyExclusions: true, cancellationToken);
+
+ ///
+ /// , with a choice about the
+ /// server's excluded databases. The long-query trace's drop passes false: a session
+ /// created before a database was excluded must still be dropped there.
+ ///
+ internal async Task> GetAzureDatabaseListAsync(ServerRuntime server, IReadOnlyList? databaseScope, bool applyExclusions, CancellationToken cancellationToken)
{
var targetDb = new SqlConnectionStringBuilder(server.ConnectionString).InitialCatalog;
@@ -6576,8 +6630,7 @@ every cycle is the cheaper failure. (Review caught me reintroducing exactly this
/* The query and the hop to master both come from the provider, so the enumeration set is defined
in exactly one place per engine. What stays here is the failure policy below, which is the
part that is genuinely Azure-specific. */
- var (masterConnectionString, enumerationQuery) = SqlServerTargetProvider.Instance.BuildDatabaseListPlan(
- server.ConnectionString, server.Config.ExcludedDatabases, databaseScope);
+ var (masterConnectionString, enumerationQuery) = AzureDatabaseListPlan(server, databaseScope, applyExclusions);
var databases = new List();
try
@@ -6604,6 +6657,17 @@ mapping as every other command so a type cannot be mapped two ways. */
}
}
+ ///
+ /// The master hop and the enumeration query behind .
+ /// The server's excluded databases are in the query only when is true, so a drop that
+ /// passes false lists the databases the registration excludes too. Its own member so a test reads the query, not a stand-in
+ /// for the list.
+ ///
+ internal static (string ConnectionString, CollectorQuery Query) AzureDatabaseListPlan(
+ ServerRuntime server, IReadOnlyList? databaseScope, bool applyExclusions) =>
+ SqlServerTargetProvider.Instance.BuildDatabaseListPlan(
+ server.ConnectionString, applyExclusions ? server.Config.ExcludedDatabases : null, databaseScope);
+
///
/// True while a recent master-inaccessible verdict still stands. It expires so a server whose
/// access was restored recovers on its own rather than staying degraded until restart (#1506).
diff --git a/Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs b/Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs
index eb24ef8073..48a054f3cb 100644
--- a/Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs
+++ b/Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs
@@ -1267,7 +1267,7 @@ public DarlingWorker(ILogger logger, ILoggerFactory loggerFactory
_readLatency = readLatency;
}
- private sealed class ServerLoopState
+ internal sealed class ServerLoopState
{
/* Settable so the reconcile can replace a still-connected server's definition on a config
change (host/auth/excluded-dbs/cost) — paired with dropping Runtime to force a reconnect. */
@@ -1444,14 +1444,31 @@ LongQueryTracePartialNote is set when the Azure reconcile succeeded in some data
refused in others: the run's read of the survivors is real and stays SUCCESS, and this - the
#2623 partial note naming the refused databases - is merged onto its row so the row cannot pass
for a server that is quiet. It persists while LongQueryTraceApplied is latched true, because the
- refused databases are not retried until the next (re)connect resets the latch; the note is a
- standing claim about the sessions as they were last reconciled, which is also what it says.
+ refused databases are retried only by the hourly create pass (LongQueryTraceAppliedAtUtc) or the
+ next (re)connect; the note is a standing claim about the sessions as they were last reconciled,
+ which is also what it says.
Both reset with the latch on every (re)connect, and both written only by the per-server body on
the pool thread (INV-2), like the latch itself. */
public string? LongQueryTraceFault { get; set; }
public string? LongQueryTracePartialNote { get; set; }
+
+ /* The state key the last applied reconcile ran under (LongQueryTraceDatabases.StateKey). On Azure SQL
+ Database the trace's databases depend on the #3477 scope, the exclusions and the databases monitored
+ as their own servers, so a change to any of them re-runs the reconcile. Null on every other engine.
+ Reset with the latch on every (re)connect. */
+ public string? LongQueryTraceAppliedKey { get; set; }
+
+ /* When the last reconcile that created the session ran. While the trace is on and latched, the create side
+ runs again once this is LongQueryTraceDatabases.RetryInterval old, so a session dropped from outside
+ comes back: the read returns no rows for an absent session, the same as for a quiet one. Reset with the
+ latch on every (re)connect. */
+ public DateTime? LongQueryTraceAppliedAtUtc { get; set; }
+
+ /* Failed cleanup passes in a row, for LongQueryTraceDatabases.DropAttemptCap, and the clock for the hourly
+ attempts after the cap. Reset on every (re)connect. */
+ public LongQueryTraceDropRetry LongQueryTraceDropRetry { get; } = new();
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
@@ -2640,7 +2657,15 @@ was dispatched under can never come from two different reloads. */
databaseScope: (collectorName, serverId) => StoreConfigProvider.ResolveDatabaseScope(collectorName, serverId, _scheduleOverrides),
logHashKey: logHashKey,
/* #4659: shared with the alert read adapter (BuildAlertEngine). */
- queryStoreWriteFence: _queryStoreWriteFence);
+ queryStoreWriteFence: _queryStoreWriteFence,
+ /* The databases monitored as their own servers, from the same live store set the alert sweep uses:
+ the long-query trace leaves them to their own registrations, in its lifecycle and its read. */
+ separatelyMonitoredDatabases: runtime => AzureMasterScope.SeparatelyMonitoredDatabases(
+ runtime.Target.IsAzureSqlDb,
+ runtime.ServerId.ToString(CultureInfo.InvariantCulture),
+ runtime.Config.Host,
+ runtime.Config.Database,
+ LiveAlertTargets(_registryState.Read()?.Servers)));
var servers = new List();
/* #1581 cold-start stagger: capture ONE startup instant so every initial server's first-sweep offset is
measured from the same base — the deterministic per-server ColdStartFirstSweepDue then spreads the
@@ -3995,9 +4020,13 @@ retries on its next sweep exactly like a failed collector. */
///
/// Reconciles the OPT-IN long-query completion XE session (#1496) to its resolved enabled flag, once
/// the desired state differs from what was last applied to this server (tracked in
- /// so steady state — a default-off collector —
- /// opens no connection at all). Enabling creates the server-side session; disabling drops it. A
- /// failure leaves the applied state unchanged so the next sweep retries, and never breaks the sweep.
+ /// , and on Azure SQL Database in
+ /// too, so steady state — a default-off
+ /// collector — opens no connection at all). Enabling creates the session; disabling drops it. While
+ /// the trace is on, the create side runs again once an hour, so a session dropped from outside comes
+ /// back. A failure leaves the applied state unchanged so the next sweep retries (a failed drop on Azure
+ /// SQL Database up to times in a row, then once
+ /// an hour), and never breaks the sweep.
///
private async Task ReconcileLongQueryTraceAsync(ServerLoopState server, DarlingCollectorRunner runner, CancellationToken cancellationToken)
{
@@ -4020,15 +4049,146 @@ EVERY sweep forever (~1,440 warnings/day/server, the same order as the defect th
var serverId = server.Config.ServerId;
var enabled = StoreConfigProvider.ResolveSchedule("long_query_completions", serverId, _scheduleOverrides).Enabled;
- if (server.LongQueryTraceApplied == enabled)
+ /* Azure SQL Database: the other registrations of this logical server, so a drop leaves a database where one
+ of them keeps the session. Read from the same live registry the separately monitored list comes from. */
+ IReadOnlyList registrations = Array.Empty();
+ IReadOnlyList serverSeparatelyMonitored = Array.Empty();
+ if (server.Runtime.Target.IsAzureSqlDb)
+ {
+ var live = _registryState.Read()?.Servers;
+ registrations = LongQueryTraceRegistrations(
+ server.Runtime.Config.Host,
+ live,
+ otherId => StoreConfigProvider.ResolveSchedule("long_query_completions", otherId, _scheduleOverrides).Enabled,
+ otherId => runner.DatabaseScopeFor(LongQueryCompletionsCollector.Instance.Name, otherId));
+ serverSeparatelyMonitored = LongQueryTraceServerSeparatelyMonitored(server.Runtime.Config.Host, live);
+ }
+
+ await ReconcileLongQueryTraceAsync(server, runner, enabled, registrations, serverSeparatelyMonitored, DateTime.UtcNow, _logger, cancellationToken);
+ }
+
+ ///
+ /// The registrations of one logical server, for : each live
+ /// registration on , with whether its long-query trace is on, its exclusions and the
+ /// trace's database scope (empty = every database). The registry holds only monitored servers, so each one is
+ /// enabled.
+ ///
+ internal static IReadOnlyList LongQueryTraceRegistrations(
+ string host,
+ IReadOnlyList? live,
+ Func traceOn,
+ Func> databaseScope)
+ {
+ if (live is null)
+ {
+ return Array.Empty();
+ }
+
+ var hostKey = host.Trim();
+ return live
+ .Where(other => string.Equals(other.Host.Trim(), hostKey, StringComparison.OrdinalIgnoreCase))
+ .Select(other =>
+ {
+ var scope = databaseScope(other.ServerId);
+ return new LongQueryTraceRegistration(
+ other.ServerId.ToString(CultureInfo.InvariantCulture),
+ other.Host,
+ other.Database,
+ Enabled: true,
+ TraceOn: traceOn(other.ServerId),
+ other.ExcludedDatabases.ToList(),
+ scope.Count == 0 ? null : scope);
+ })
+ .ToList();
+ }
+
+ ///
+ /// The databases monitored as their own servers on 's logical server, as that server's
+ /// registration sees them (). The same list for
+ /// every registration of the server, including one that names a database: it says which databases a logical
+ /// server's registration leaves out, for . No registration's
+ /// id is empty, so the empty self id leaves none of them out.
+ ///
+ internal static IReadOnlyList LongQueryTraceServerSeparatelyMonitored(string host, IReadOnlyList? live) =>
+ AzureMasterScope.SeparatelyMonitoredDatabases(
+ isAzureSqlDb: true, selfId: string.Empty, host, database: null, LiveAlertTargets(live));
+
+ ///
+ /// The half of that
+ /// runs after the engine gate and the schedule: it decides whether the trace needs reconciling, runs it, and records
+ /// the outcome on the loop state. Static, with the enabled flag and the time passed in, so a test can drive it.
+ ///
+ internal static async Task ReconcileLongQueryTraceAsync(
+ ServerLoopState server,
+ DarlingCollectorRunner runner,
+ bool enabled,
+ IReadOnlyList registrations,
+ IReadOnlyList serverSeparatelyMonitored,
+ DateTime utcNow,
+ ILogger logger,
+ CancellationToken cancellationToken)
+ {
+ if (server.Runtime is null)
+ {
+ return;
+ }
+
+ /* On Azure SQL Database the trace follows the monitored set, so the latch also holds what that set
+ depended on: the scope the read loop resolves (the runner's own accessor, not a copy), the exclusions,
+ and the databases monitored as their own servers. Any change re-runs the reconcile, which drops the
+ session from a database newly left out. The co-owners are there too: a drop skipped because another
+ registration kept the session runs once that one turns its trace off. */
+ var stateKey = server.Runtime.Target.IsAzureSqlDb
+ ? LongQueryTraceDatabases.StateKey(
+ enabled,
+ runner.DatabaseScopeFor(LongQueryCompletionsCollector.Instance.Name, server.Runtime.ServerId),
+ server.Runtime.Config.ExcludedDatabases,
+ runner.SeparatelyMonitoredDatabasesFor(server.Runtime),
+ LongQueryTraceDatabases.CoOwners(
+ server.Runtime.ServerId.ToString(CultureInfo.InvariantCulture),
+ server.Runtime.Config.Host,
+ registrations,
+ serverSeparatelyMonitored))
+ : null;
+
+ /* Latched, the reconcile runs again only on the hour. The whole pass runs when the cleanup's attempt after the
+ cap is due. Otherwise, while the trace is on, the create side runs alone, so a session dropped from outside
+ comes back: the read returns no rows for an absent session, the same as for a quiet one. The drop side
+ runs once per connect, per change of the state key, or per attempt after the cap. */
+ LongQueryTracePass pass;
+ if (server.LongQueryTraceApplied != enabled
+ || !string.Equals(server.LongQueryTraceAppliedKey, stateKey, StringComparison.Ordinal))
+ {
+ pass = LongQueryTracePass.Full;
+ }
+ else if (server.LongQueryTraceDropRetry.RetryDue(stateKey ?? string.Empty, utcNow))
+ {
+ pass = LongQueryTracePass.RetryAfterCap;
+ }
+ else if (enabled
+ && server.LongQueryTraceAppliedAtUtc is { } appliedAt
+ && utcNow - appliedAt >= LongQueryTraceDatabases.RetryInterval)
+ {
+ pass = LongQueryTracePass.CreateOnly;
+ }
+ else
{
return;
}
try
{
- var partialNote = await DarlingXeSessions.ReconcileLongQueryCompletionsAsync(server.Runtime, runner, enabled, _logger, cancellationToken);
+ var partialNote = await DarlingXeSessions.ReconcileLongQueryCompletionsAsync(
+ server.Runtime, runner, enabled, pass, registrations, serverSeparatelyMonitored, logger, cancellationToken);
server.LongQueryTraceApplied = enabled;
+ server.LongQueryTraceAppliedKey = stateKey;
+ server.LongQueryTraceAppliedAtUtc = utcNow;
+
+ /* Only a pass that ran the cleanup ends its retries: the create side alone leaves the hourly attempt. */
+ if (pass != LongQueryTracePass.CreateOnly)
+ {
+ server.LongQueryTraceDropRetry.Reset();
+ }
/* #3754: a reconcile that returned is one the session exists after - everywhere, or (Azure)
everywhere it could. Clear the fault, and carry the partial note if there was one. Nulled
@@ -4038,11 +4198,59 @@ and the next reconcile (which runs first, in this same sweep) has already replac
server.LongQueryTraceFault = null;
server.LongQueryTracePartialNote = enabled ? partialNote : null;
}
+ catch (LongQueryTraceDropException ex)
+ {
+ /* Azure SQL Database: a drop failed, or the databases could not be listed for it. While enabling, the
+ create side had finished, so the fault clears and its partial note stands. The latch stays unset so
+ the next sweep tries again, until the cap: then the reconcile counts as applied, and one warning
+ names the databases where the session may remain. After that, one attempt an hour, logged at Debug,
+ so the warning is not repeated. */
+ if (enabled)
+ {
+ server.LongQueryTraceFault = null;
+ server.LongQueryTracePartialNote = ex.CreateNote;
+ server.LongQueryTraceAppliedAtUtc = utcNow;
+ }
+
+ switch (server.LongQueryTraceDropRetry.RecordFailure(stateKey ?? string.Empty, utcNow))
+ {
+ case LongQueryTraceDropOutcome.GaveUp:
+ server.LongQueryTraceApplied = enabled;
+ server.LongQueryTraceAppliedKey = stateKey;
+ logger.LogWarning("[{Server}] {Message}", server.Config.DisplayName,
+ LongQueryTraceDatabases.GiveUpWarning(ex.Databases, " It also tries again after it reconnects."));
+ break;
+ case LongQueryTraceDropOutcome.TryAgainInAnHour:
+ logger.LogDebug("[{Server}] {Message} The next attempt is in an hour.", server.Config.DisplayName, ex.Message);
+ break;
+ default:
+ logger.LogWarning("[{Server}] {Message} The next sweep tries again.", server.Config.DisplayName, ex.Message);
+ break;
+ }
+ }
catch (Exception ex) when (ex is not OperationCanceledException)
{
- _logger.LogWarning("[{Server}] Failed to reconcile the long-query completion XE session: {Message}",
+ logger.LogWarning("[{Server}] Failed to reconcile the long-query completion XE session: {Message}",
server.Config.DisplayName, ex.Message);
+ /* A pass that ran and threw must move its hourly clock, or every later sweep is another pass and another
+ warning, with no cap. The full pass keeps its retry on each sweep: the latch is still unset. The hourly
+ create pass waits an hour. The attempt after the cap waits an hour too, and the create side it ran
+ counts as the hourly create pass, as in the drop-failure arm above. The fault below stays set until a
+ pass succeeds. */
+ if (pass == LongQueryTracePass.CreateOnly)
+ {
+ server.LongQueryTraceAppliedAtUtc = utcNow;
+ }
+ else if (pass == LongQueryTracePass.RetryAfterCap)
+ {
+ server.LongQueryTraceDropRetry.RecordFailure(stateKey ?? string.Empty, utcNow);
+ if (enabled)
+ {
+ server.LongQueryTraceAppliedAtUtc = utcNow;
+ }
+ }
+
/* #3754: while ENABLING, a throw means the session could not be created where the collector
will read - the one CREATE on-prem, or every database on Azure SQL DB (the Azure arm throws
only for all-refused). Record it so this sweep's run of the collector is classified
@@ -10313,6 +10521,9 @@ connection. A fault left standing here would classify the first post-reconnect r
name databases the reconnect may have just fixed. */
server.LongQueryTraceFault = null;
server.LongQueryTracePartialNote = null;
+ server.LongQueryTraceAppliedKey = null;
+ server.LongQueryTraceAppliedAtUtc = null;
+ server.LongQueryTraceDropRetry.Reset();
/* Capture the id once, while the connection is freshly established and non-null: an on-load
RunOneAsync below can drop server.Runtime on a mid-collection connection-level failure, so any
later read of server.Runtime.ServerId (the schedule resolve, the connection edge) would NRE. */
diff --git a/Darling/PerformanceMonitor.Darling.Service/DarlingXeSessionCleanup.cs b/Darling/PerformanceMonitor.Darling.Service/DarlingXeSessionCleanup.cs
index beea654273..aedd49acff 100644
--- a/Darling/PerformanceMonitor.Darling.Service/DarlingXeSessionCleanup.cs
+++ b/Darling/PerformanceMonitor.Darling.Service/DarlingXeSessionCleanup.cs
@@ -8,11 +8,13 @@
using System;
using System.Collections.Generic;
+using System.Globalization;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Data.SqlClient;
+using PerformanceMonitor.Alerting;
using PerformanceMonitor.Collectors;
using PerformanceMonitor.Darling.Service.Targets;
@@ -44,9 +46,18 @@ public sealed record XeSessionDrop(ExistingXeSession Session)
public string Statement => DarlingXeSessionCleanup.DropStatement(Session.Name, Session.Scope);
}
-/// What a search of one target found: the Darling sessions that exist, and a sentence for every place that could
-/// not be searched (an Azure SQL Database database that refused the connection, say).
-public sealed record XeSessionSearch(IReadOnlyList Sessions, IReadOnlyList Problems);
+/// What a search of one target found: the Darling sessions that exist, and a sentence for every monitored place
+/// that could not be searched (an Azure SQL Database database that refused the connection, say), each of which fails the
+/// run. holds the sentences for a place the registration excludes, which the search opened only for the
+/// long-query session and could not search: it says where a session may be left and does not fail the run.
+public sealed record XeSessionSearch(IReadOnlyList Sessions, IReadOnlyList Problems)
+{
+ /// A sentence for each excluded database that could not be searched for the long-query session. The verb prints
+ /// each on stderr and does not change its exit code for it: before the search reached excluded databases, the verb never
+ /// opened one, so an excluded database the login cannot open is not a reason to stop a script that removes the server
+ /// next. A session left in such a database needs a manual drop.
+ public IReadOnlyList Notes { get; init; } = Array.Empty();
+}
///
/// The seam between the cleanup verb's decisions and the SQL Server it runs against, so the whole verb is testable
@@ -55,7 +66,9 @@ public sealed record XeSessionSearch(IReadOnlyList Sessions,
public interface IXeSessionCleanupTarget
{
/// Which of Darling's sessions exist on the target. Throws when the target itself cannot be searched; a
- /// place inside it that cannot be searched comes back as a entry instead.
+ /// monitored place inside it that cannot be searched comes back as a entry instead,
+ /// and an excluded database that cannot be searched for the long-query session as a
+ /// entry.
Task FindSessionsAsync(CancellationToken cancellationToken);
/// Runs one planned drop. Throws when the server refuses it.
@@ -111,7 +124,9 @@ private static string JoinAsPhrase(IReadOnlyList items) =>
/// The one warning both modes print, without its prefix. It says only what the code does: Lite ensures a missing
/// session on every collection cycle, and creates a missing session
/// (server-scoped, or in each database on Azure SQL Database) when a Darling service next connects to the server.
- /// Both create the long-query completion session only for a server whose collector is turned on. The deprecated Full
+ /// While its long-query trace is on, a Darling service also creates a missing long-query completion session within an
+ /// hour (DarlingWorker.ReconcileLongQueryTraceAsync). Both apps create the long-query completion session only for
+ /// a server whose collector is turned on. The deprecated Full
/// Dashboard installer names the same deadlock and blocked-process sessions, and the collection procedures it installs
/// (install/22_collect_blocked_processes.sql, install/24_collect_deadlock_xml.sql) create a missing one at
/// the top of every run; it has no long-query completion session (#4732).
@@ -119,8 +134,9 @@ private static string JoinAsPhrase(IReadOnlyList items) =>
public const string SharedNamesWarning =
"a Lite app, a deprecated Full Dashboard install or another Darling service that still monitors this server uses "
+ "the same session names and creates a missing session again (Lite on its next collection cycle, the Dashboard on "
- + "its next collection run, Darling on its next connect to the server; the long query completions session only "
- + "where that collector is turned on, and never by the Dashboard), "
+ + "its next collection run, Darling on its next connect to the server or, for the long query completions session, "
+ + "within an hour; the long query completions session only where that collector is turned on, and never by the "
+ + "Dashboard), "
+ "so run this only once nothing else monitors it.";
/// What each session captures, in the words the note after a drop uses. A name with no entry reads as itself, so
@@ -346,8 +362,9 @@ public static string GuardedDropScript()
/// dropped) and the shared-names warning. Returns
/// when everything found was dropped (or listed, in a dry
/// run) or nothing was there, and when the target
- /// could not be searched, part of it could not be searched, or a drop was refused. A refused drop does not stop the ones
- /// after it.
+ /// could not be searched, a monitored part of it could not be searched, or a drop was refused. A refused drop does not
+ /// stop the ones after it. An excluded database that could not be searched for the long-query session is printed on
+ /// stderr as a note () and does not change the exit code.
///
internal static async Task RunAsync(
string serverLabel,
@@ -376,10 +393,17 @@ internal static async Task RunAsync(
exitCode = DarlingCliCommands.DropXeSessionsExitCode.TargetUnavailable;
}
+ /* A note is not a failure: it names an excluded database the search opened only for the long-query session and could not
+ open, which the verb never searched before that session was added to the search. Exit code untouched. */
+ foreach (var note in search.Notes)
+ {
+ error.WriteLine(note);
+ }
+
var drops = PlanDrops(search.Sessions);
if (drops.Count == 0)
{
- /* "In the places searched": a database that could not be searched (a Problems entry, exit 2) may hold a session. */
+ /* "In the places searched": a database that could not be searched (a Problems entry, exit 2, or a note) may hold a session. */
output.WriteLine($"No Darling Extended Events sessions ({string.Join(", ", SessionNames)}) were found on '{serverLabel}'; nothing to drop in the places searched.");
}
@@ -430,9 +454,14 @@ internal static async Task RunAsync(
///
/// over a connected SQL Server target: the ServerRuntime the shared connector produced
-/// (#4732). On Azure SQL Database it visits the same databases would, by the
-/// same rule (a registration that names a database is that database alone; one that names none enumerates master through the
-/// provider's own plan, honoring the server's excluded databases), and skips master, which cannot host a session.
+/// (#4732). On Azure SQL Database it visits two sets, and skips master, which cannot host a session. For the deadlock and
+/// blocked-process sessions it visits the same databases would, by the same rule
+/// (a registration that names a database is that database alone; one that names none enumerates master through the provider's
+/// own plan, honoring the server's excluded databases). For the long-query completion session it visits every online database,
+/// the exclusions not applied, as the trace's off-side reconcile does, except a database monitored as its own server and a
+/// database where another registration of the logical server keeps the session (). A database the
+/// search cannot open is a problem when the registration monitors it and a note when only the long-query search reaches it
+/// (), because the verb never opened an excluded database before that search did.
///
internal sealed class SqlServerXeSessionCleanupTarget : IXeSessionCleanupTarget
{
@@ -442,15 +471,21 @@ internal sealed class SqlServerXeSessionCleanupTarget : IXeSessionCleanupTarget
private readonly string[] _sessionNames;
+ private readonly IReadOnlyList _registry;
+
/// The connected server.
/// The names to search for and to drop, copied here. Every product caller leaves this null, which
/// is . The live test passes a test-only name so the real find and drop
/// run against a real server without ever naming one of Darling's own sessions. Either way the target composes its find
/// and drop text through the same methods the plan's constants and statements are built with, so the live test sends the
/// text the product sends, and a test pins that a copy of Darling's names sends exactly the plan's text (#4732).
- public SqlServerXeSessionCleanupTarget(ServerRuntime server, IReadOnlyList? sessionNames = null)
+ /// The servers this service monitors, from the same list the verb resolved the target in. On Azure SQL
+ /// Database it says which databases belong to another registration of the same logical server.
+ public SqlServerXeSessionCleanupTarget(
+ ServerRuntime server, IReadOnlyList? sessionNames = null, IReadOnlyList? registry = null)
{
_server = server ?? throw new ArgumentNullException(nameof(server));
+ _registry = registry ?? Array.Empty();
_sessionNames = (sessionNames ?? DarlingXeSessionCleanup.SessionNames).ToArray();
if (_sessionNames.Length == 0 || _sessionNames.Any(string.IsNullOrWhiteSpace))
{
@@ -484,6 +519,68 @@ internal string StatementFor(XeSessionDrop drop)
return DarlingXeSessionCleanup.ComposeDropStatement(session.Name, session.Scope);
}
+ ///
+ /// Where a search of an Azure SQL Database server looks, and which sessions it reports in each database.
+ ///
+ /// The databases searched for every session but the long-query one: the registration's
+ /// monitored databases.
+ /// The databases searched for the long-query session.
+ internal sealed record AzureSearchPlan(IReadOnlyList AlwaysOnDatabases, IReadOnlyList LongQueryDatabases)
+ {
+ /// Every database the search opens, once, in order.
+ public IReadOnlyList Visited { get; } =
+ AlwaysOnDatabases.Concat(LongQueryDatabases).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
+
+ /// Whether a session of this name, found in this database, belongs in the result.
+ public bool Reports(string database, string sessionName) =>
+ (string.Equals(sessionName, LongQueryCompletionsCollector.XeSessionName, StringComparison.OrdinalIgnoreCase)
+ ? LongQueryDatabases
+ : AlwaysOnDatabases).Contains(database, StringComparer.OrdinalIgnoreCase);
+
+ ///
+ /// The sentence for a database the search could not open, and whether it fails the run. A monitored database is
+ /// searched for every session, so one that cannot be opened is a problem (, exit
+ /// 2). A database in and not in is one the
+ /// registration excludes, opened only because a long-query session created before the exclusion stays there; the verb
+ /// never opened it before that search, so a login with no user in it, or a paused serverless database, is a note
+ /// () that does not change the exit code.
+ ///
+ public (string Text, bool IsProblem) Unsearched(string database, string reason) =>
+ AlwaysOnDatabases.Contains(database, StringComparer.OrdinalIgnoreCase)
+ ? ($"Could not search database {database} for Extended Events sessions: {reason}", true)
+ : ($"Database {database} is excluded from monitoring and could not be searched for the long-query completion session ({reason}), so a {LongQueryCompletionsCollector.XeSessionName} session left there needs a manual drop.", false);
+ }
+
+ ///
+ /// The databases the search visits. is the registration's monitored databases;
+ /// is every online database, with no exclusions.
+ ///
+ internal AzureSearchPlan PlanAzureSearch(IReadOnlyList monitored, IReadOnlyList every)
+ {
+ var alwaysOn = monitored.Where(database => !string.Equals(database, "master", StringComparison.OrdinalIgnoreCase)).ToList();
+ if (!_sessionNames.Contains(LongQueryCompletionsCollector.XeSessionName, StringComparer.OrdinalIgnoreCase))
+ {
+ return new AzureSearchPlan(alwaysOn, Array.Empty());
+ }
+
+ /* The long-query session follows the worker's rule for a trace that is off (LongQueryTraceDatabases.Plan), the one
+ the off-side reconcile applies: every listed database, the registration's exclusions not applied, because a session
+ created before a database was excluded stays there. Never master, never a database monitored as its own server, and
+ never a database where another registration of the logical server keeps the session. The verb cannot read another
+ registration's long-query schedule, so it counts every other registration as keeping it. */
+ var host = _server.Config.Host;
+ var selfId = _server.ServerId.ToString(CultureInfo.InvariantCulture);
+ var registrations = DarlingWorker.LongQueryTraceRegistrations(
+ host, _registry, traceOn: _ => true, databaseScope: _ => Array.Empty());
+ var separatelyMonitored = AzureMasterScope.SeparatelyMonitoredDatabases(
+ isAzureSqlDb: true, selfId, host, _server.Config.Database, DarlingWorker.LiveAlertTargets(_registry));
+ var keptElsewhere = LongQueryTraceDatabases.KeptElsewhere(
+ selfId, host, every, registrations, DarlingWorker.LongQueryTraceServerSeparatelyMonitored(host, _registry));
+
+ var off = LongQueryTraceDatabases.Plan(enabled: false, every, Array.Empty(), separatelyMonitored, keptElsewhere);
+ return new AzureSearchPlan(alwaysOn, off.Drop);
+ }
+
public async Task FindSessionsAsync(CancellationToken cancellationToken)
{
var found = new List();
@@ -501,30 +598,60 @@ public async Task FindSessionsAsync(CancellationToken cancellat
return new XeSessionSearch(found, problems);
}
- foreach (var database in await ListAzureDatabasesAsync(cancellationToken))
+ var monitored = await ListAzureDatabasesAsync(applyExclusions: true, cancellationToken);
+
+ /* The second listing only where exclusions could make it differ, and only for a search that includes the long-query
+ session. */
+ var every = _server.Config.ExcludedDatabases.Count > 0
+ && _sessionNames.Contains(LongQueryCompletionsCollector.XeSessionName, StringComparer.OrdinalIgnoreCase)
+ ? await ListAzureDatabasesAsync(applyExclusions: false, cancellationToken)
+ : monitored;
+ return await SearchAzureDatabasesAsync(PlanAzureSearch(monitored, every), NamesInDatabaseAsync, cancellationToken);
+ }
+
+ ///
+ /// The Azure SQL Database search over the plan's databases, with the read of one database passed in so a test can make one
+ /// refuse the connection without a server. A database that cannot be searched is filed by :
+ /// a monitored one as a problem, an excluded one as a note.
+ ///
+ internal static async Task SearchAzureDatabasesAsync(
+ AzureSearchPlan plan,
+ Func>> namesInDatabase,
+ CancellationToken cancellationToken)
+ {
+ var found = new List();
+ var problems = new List();
+ var notes = new List();
+
+ foreach (var database in plan.Visited)
{
cancellationToken.ThrowIfCancellationRequested();
- if (string.Equals(database, "master", StringComparison.OrdinalIgnoreCase))
- {
- continue;
- }
try
{
- using var connection = new SqlConnection(SqlServerTargetProvider.Instance.WithDatabase(_server.ConnectionString, database));
- await connection.OpenAsync(cancellationToken);
- foreach (var name in await ReadNamesAsync(connection, FindDatabaseSql, cancellationToken))
+ foreach (var name in await namesInDatabase(database, cancellationToken))
{
- found.Add(new ExistingXeSession(name, XeSessionScope.Database, database));
+ if (plan.Reports(database, name))
+ {
+ found.Add(new ExistingXeSession(name, XeSessionScope.Database, database));
+ }
}
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
- problems.Add($"Could not search database {database} for Extended Events sessions: {ex.Message}");
+ var (text, isProblem) = plan.Unsearched(database, ex.Message);
+ (isProblem ? problems : notes).Add(text);
}
}
- return new XeSessionSearch(found, problems);
+ return new XeSessionSearch(found, problems) { Notes = notes };
+ }
+
+ private async Task> NamesInDatabaseAsync(string database, CancellationToken cancellationToken)
+ {
+ using var connection = new SqlConnection(SqlServerTargetProvider.Instance.WithDatabase(_server.ConnectionString, database));
+ await connection.OpenAsync(cancellationToken);
+ return await ReadNamesAsync(connection, FindDatabaseSql, cancellationToken);
}
public async Task DropAsync(XeSessionDrop drop, CancellationToken cancellationToken)
@@ -557,13 +684,15 @@ private static async Task> ReadNamesAsync(SqlConnection connection,
}
///
- /// The databases visits on Azure SQL Database, by the collector runner's
- /// rule (see its GetAzureDatabaseListAsync): a registration that names a database sweeps that database alone
- /// (#2220), and one that names none enumerates master through the provider's own plan. Unlike the runner there is no
- /// fallback when master cannot be read (a registration that names no database has nothing to fall back to), so the
- /// failure propagates and the verb reports the server as unavailable.
+ /// The databases the search lists on Azure SQL Database, by the collector runner's rule (see its
+ /// GetAzureDatabaseListAsync): a registration that names a database sweeps that database alone (#2220), and one
+ /// that names none enumerates master through the provider's own plan. With the list is
+ /// the one visits, minus the registration's excluded databases; without it,
+ /// every online database, as the long-query trace's off-side reconcile lists them. Unlike the runner there is no fallback
+ /// when master cannot be read (a registration that names no database has nothing to fall back to), so the failure
+ /// propagates and the verb reports the server as unavailable.
///
- private async Task> ListAzureDatabasesAsync(CancellationToken cancellationToken)
+ private async Task> ListAzureDatabasesAsync(bool applyExclusions, CancellationToken cancellationToken)
{
var own = AzureSweepScope.OwnDatabaseOrEmpty(new SqlConnectionStringBuilder(_server.ConnectionString).InitialCatalog);
if (own.Count > 0)
@@ -571,8 +700,7 @@ private async Task> ListAzureDatabasesAsync(CancellationTo
return own;
}
- var (masterConnectionString, query) = SqlServerTargetProvider.Instance.BuildDatabaseListPlan(
- _server.ConnectionString, _server.Config.ExcludedDatabases, databaseScope: null);
+ var (masterConnectionString, query) = DarlingCollectorRunner.AzureDatabaseListPlan(_server, databaseScope: null, applyExclusions);
var databases = new List();
using var connection = new SqlConnection(masterConnectionString);
diff --git a/Darling/PerformanceMonitor.Darling.Service/DarlingXeSessions.cs b/Darling/PerformanceMonitor.Darling.Service/DarlingXeSessions.cs
index 975c3c2fdb..7dc81f5767 100644
--- a/Darling/PerformanceMonitor.Darling.Service/DarlingXeSessions.cs
+++ b/Darling/PerformanceMonitor.Darling.Service/DarlingXeSessions.cs
@@ -9,6 +9,7 @@
using System;
using System.Collections.Generic;
using System.Data;
+using System.Globalization;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Data.SqlClient;
@@ -110,12 +111,13 @@ private static async Task EnsureDatabaseScopedAsync(ServerRuntime server, Darlin
List databases;
try
{
- /* No #3477 scope here, deliberately: this list provisions SESSIONS for three collectors
- (deadlocks, blocked_process_report, long_query_completions), each of which may carry a
- DIFFERENT scope — the scope is a COLLECTION predicate on each collector's own read
- fan-out, while the session inventory stays server-shaped. An unread session's ring
- buffer is bounded server-side; a session dropped because ONE collector was scoped
- would blind the other two. */
+ /* No #3477 scope here, deliberately: this list provisions the always-on SESSIONS for two
+ collectors (deadlocks, blocked_process_report), each of which may carry a DIFFERENT
+ scope — the scope is a COLLECTION predicate on each collector's own read fan-out, while
+ the session inventory stays server-shaped. An unread session's ring buffer is bounded
+ server-side; a session dropped because ONE collector was scoped would blind the other.
+ The opt-in long-query trace is the other rule: it follows its own collector's scope
+ (ReconcileLongQueryCompletionsAzureAsync). */
databases = await runner.GetAzureDatabaseListAsync(server, databaseScope: null, cancellationToken);
}
catch (Exception ex) when (ex is not OperationCanceledException)
@@ -565,11 +567,22 @@ ADD TARGET package0.ring_buffer
/// server-scoped arm has always thrown its one CREATE failure and as the runners' per-database loops
/// throw when all databases fail (#2623), so the worker does not latch and the run is classified
/// SESSION_MISSING; some databases refused returns the #2623 partial note naming them, which the
- /// worker merges onto the run's row. Null is the clean reconcile, and the disabled (DROP) arm's answer
- /// either way — a drop failure is not a capture outage, the collector is not dispatched while disabled,
- /// and the Lite sibling's drop swallows per database too.
+ /// worker merges onto the run's row. Null is the clean reconcile, and the disabled (DROP) arm's answer.
+ /// A drop failure is not a capture outage, so it is not scored; on Azure SQL DB it throws
+ /// after every database was tried, and the worker retries it with
+ /// a cap, as Lite does.
+ /// : which part runs (). It changes only the Azure
+ /// SQL DB arm, because the server-scoped arm has no drop while enabled.
///
- public static async Task ReconcileLongQueryCompletionsAsync(ServerRuntime server, DarlingCollectorRunner runner, bool enabled, ILogger? logger, CancellationToken cancellationToken)
+ public static async Task ReconcileLongQueryCompletionsAsync(
+ ServerRuntime server,
+ DarlingCollectorRunner runner,
+ bool enabled,
+ LongQueryTracePass pass,
+ IReadOnlyList registrations,
+ IReadOnlyList serverSeparatelyMonitored,
+ ILogger? logger,
+ CancellationToken cancellationToken)
{
/* Belt to the worker's braces: the caller gates on engine (a PostgreSQL target has no XE to
reconcile), but this method constructs a SqlConnection from the engine-ambiguous connection
@@ -582,7 +595,7 @@ ADD TARGET package0.ring_buffer
if (server.Target.IsAzureSqlDb)
{
- return await ReconcileLongQueryCompletionsAzureAsync(server, runner, enabled, logger, cancellationToken);
+ return await ReconcileLongQueryCompletionsAzureAsync(server, runner, enabled, pass, registrations, serverSeparatelyMonitored, logger, cancellationToken);
}
using var connection = new SqlConnection(server.ConnectionString);
@@ -640,29 +653,84 @@ LEFT JOIN sys.dm_xe_sessions AS dxs
}
///
- /// The Azure SQL DB arm: one database-scoped session per monitored database. Returns the #2623 partial
- /// note when the ENABLE was refused in some databases, throws the first failure when it was refused in
- /// all of them, and returns null otherwise — see for
- /// why the middle state exists only here and what each answer makes the worker do (#3754).
+ /// The Azure SQL DB arm: one database-scoped session per monitored database, in the databases
+ /// names (shared with Lite). ENABLED: created in each monitored
+ /// database, then dropped from each listed database outside that set. DISABLED: dropped from every listed
+ /// database, exclusions and scope included. Neither touches a database monitored as its own server, and neither
+ /// drops the session from a database where another registration of the server keeps it
+ /// (). Returns the #2623 partial note when the ENABLE was refused in some databases, throws the first failure
+ /// when it was refused in all of them, and returns null otherwise — see
+ /// for why the middle state exists only here and what each
+ /// answer makes the worker do (#3754). A failed listing throws too. A failed drop throws
+ /// after every database was tried, carrying the partial note, so
+ /// the worker retries it up to times in a row, then once an
+ /// hour. A pass stops after the create side.
+ /// The create side has no cap, on purpose. Its listing failure rethrows as it is, so the worker leaves the
+ /// trace unapplied and a login that cannot read master on a logical server retries the create on every sweep, with a
+ /// warning each time: the fault is recorded again, and the run reads SESSION_MISSING. The CREATE path where
+ /// every database refuses has the same cadence. Only the drop side is capped, because its failures, the listing
+ /// included, arrive as .
///
- private static async Task ReconcileLongQueryCompletionsAzureAsync(ServerRuntime server, DarlingCollectorRunner runner, bool enabled, ILogger? logger, CancellationToken cancellationToken)
+ private static async Task ReconcileLongQueryCompletionsAzureAsync(
+ ServerRuntime server,
+ DarlingCollectorRunner runner,
+ bool enabled,
+ LongQueryTracePass pass,
+ IReadOnlyList registrations,
+ IReadOnlyList serverSeparatelyMonitored,
+ ILogger? logger,
+ CancellationToken cancellationToken)
{
- List databases;
+ /* The hourly attempt after the cap logs each failed drop at Debug, so the cap's one warning is not repeated. */
+ var afterTheCap = pass == LongQueryTracePass.RetryAfterCap;
+
+ /* Two lifecycle rules live in this class, on purpose. The always-on deadlock and blocked-process
+ sessions (EnsureDatabaseScopedAsync) follow the inventory: every database the server lists, with no
+ #3477 scope, because they are cheap, the alerts read them, and one list provisions both while each
+ collector may carry its own scope. This opt-in trace follows the monitored set: the inventory narrowed by the scope its
+ own read loop resolves (DarlingCollectorRunner.DatabaseScopeFor), minus the databases monitored as
+ their own servers, whose registrations own those sessions. The trace costs overhead, so it must not
+ run in a database the user left out. */
+ var separatelyMonitored = runner.SeparatelyMonitoredDatabasesFor(server);
+
+ /* The databases where another registration of this logical server keeps the session: a drop here would
+ remove it for that registration too, since the session is one object per database. */
+ IReadOnlyList KeptElsewhere(IEnumerable candidates) =>
+ LongQueryTraceDatabases.KeptElsewhere(
+ server.ServerId.ToString(CultureInfo.InvariantCulture), server.Config.Host, candidates, registrations, serverSeparatelyMonitored);
+
+ if (!enabled)
+ {
+ var listed = await ListEveryDatabaseForTheTraceAsync(server, runner, createNote: null, cancellationToken);
+ var off = LongQueryTraceDatabases.Plan(
+ enabled: false,
+ listed,
+ Array.Empty(),
+ separatelyMonitored,
+ KeptElsewhere(listed));
+ await DropLongQueryTraceInEachAsync(server, runner, off.Drop, createNote: null, afterTheCap, logger, cancellationToken);
+ return null;
+ }
+
+ List monitored;
try
{
- /* No #3477 scope, same reasoning as EnsureDatabaseScopedAsync: session lifecycle is
- inventory-driven; the scope narrows the collector's READ loop, not where the trace
- exists. */
- databases = await runner.GetAzureDatabaseListAsync(server, databaseScope: null, cancellationToken);
+ monitored = await runner.ListLongQueryTraceDatabasesAsync(
+ server,
+ allDatabases: false,
+ runner.DatabaseScopeFor(LongQueryCompletionsCollector.Instance.Name, server.ServerId),
+ cancellationToken);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
+ /* Thrown, not returned: a return here made the worker count the trace applied with no session
+ created, until the next reconnect. Now the next sweep tries again. */
logger?.LogWarning("[{Server}] Failed to enumerate databases for the long-query completion XE session: {Message}", server.Config.DisplayName, ex.Message);
- return null;
+ throw;
}
/* #3754: the per-database account, in the shape both runners' Azure loops keep (#2623) - attempted
- against failed, the names, the first exception whole. Only the ENABLE arm is scored: a database
+ against failed, the names, the first exception whole. Only the ENABLE side is scored: a database
whose session could not be created is a database whose completions will never be captured,
which is the fact the collector's run record has to carry. */
var attempted = 0;
@@ -670,39 +738,28 @@ which is the fact the collector's run record has to carry. */
var failedDatabases = new List();
Exception? firstFailure = null;
- foreach (var databaseName in databases)
+ foreach (var databaseName in LongQueryTraceDatabases.Plan(enabled: true, Array.Empty(), monitored, separatelyMonitored, keptElsewhere: Array.Empty()).Create)
{
cancellationToken.ThrowIfCancellationRequested();
-
- /* Logical master can't host database-scoped event sessions. */
- if (string.Equals(databaseName, "master", StringComparison.OrdinalIgnoreCase))
- {
- continue;
- }
-
- if (enabled)
- {
- attempted++;
- }
+ attempted++;
try
{
+ if (runner.LongQueryTraceDatabaseOverrideForTests is { } inDatabase)
+ {
+ await inDatabase(server, databaseName, true, cancellationToken);
+ continue;
+ }
+
using var connection = await runner.OpenAzureDatabaseConnectionAsync(server, databaseName, cancellationToken);
- if (enabled)
+ try
{
- try
- {
- await EnsureLongQueryCompletionsAzureAsync(connection, server, databaseName, logger, cancellationToken);
- }
- catch (SqlException ex) when (IsBenignXeSessionAlreadyPresent(ex))
- {
- /* Already present per the engine — the reader tolerates it (#1251). */
- }
+ await EnsureLongQueryCompletionsAzureAsync(connection, server, databaseName, logger, cancellationToken);
}
- else
+ catch (SqlException ex) when (IsBenignXeSessionAlreadyPresent(ex))
{
- await DropLongQueryCompletionsAsync(connection, databaseScoped: true, cancellationToken);
+ /* Already present per the engine — the reader tolerates it (#1251). */
}
}
catch (Exception ex) when (ex is not OperationCanceledException)
@@ -714,13 +771,10 @@ the failure reaches the run record and not only the service log. The database na
on the exception (#2997) for the all-failed rethrow: the worker composes the run's
SESSION_MISSING message from it, and its only other source of a name is the runtime's
connected database, which is master here and never the one that refused. */
- if (enabled)
- {
- failed++;
- failedDatabases.Add(databaseName);
- CollectorFaultDatabase.Stamp(ex, databaseName);
- firstFailure ??= ex;
- }
+ failed++;
+ failedDatabases.Add(databaseName);
+ CollectorFaultDatabase.Stamp(ex, databaseName);
+ firstFailure ??= ex;
logger?.LogWarning("[{Server}] [{Database}] Failed to reconcile the long-query completion XE session: {Message}",
server.Config.DisplayName, databaseName, ex.Message);
@@ -743,9 +797,71 @@ and number are what the worker's fault arms classify on - after the one summary
read - SUCCESS is right for it - but its row must say that the others are not in it. The shared
#2623 composer, so this loss is worded exactly as the runners word theirs; null when nothing
failed, which is the ordinary sweep. */
- return EnumeratedCollectorDriver.BuildPartialFailureNote(failed, attempted, failedDatabases, firstFailure?.Message);
+ var partialNote = EnumeratedCollectorDriver.BuildPartialFailureNote(failed, attempted, failedDatabases, firstFailure?.Message);
+
+ /* The worker's hourly pass while the trace is on stops here: it brings back a session dropped from outside,
+ and leaves the drop side to a connect, a change of the state key, or an attempt after the cap. */
+ if (pass == LongQueryTracePass.CreateOnly)
+ {
+ return partialNote;
+ }
+
+ /* Then drop the session from each listed database outside the monitored set: a database excluded, or
+ taken out of the scope, since the session was created there. */
+ var listedForTheDrop = await ListEveryDatabaseForTheTraceAsync(server, runner, partialNote, cancellationToken);
+ var outside = LongQueryTraceDatabases.Plan(
+ enabled: true,
+ listedForTheDrop,
+ monitored,
+ separatelyMonitored,
+ KeptElsewhere(listedForTheDrop));
+ await DropLongQueryTraceInEachAsync(server, runner, outside.Drop, partialNote, afterTheCap, logger, cancellationToken);
+
+ return partialNote;
}
+ ///
+ /// Every online database, with no exclusions and no scope, for a drop. A failure to list them throws
+ /// with no names, so the worker retries it like a failed drop.
+ ///
+ private static async Task> ListEveryDatabaseForTheTraceAsync(ServerRuntime server, DarlingCollectorRunner runner, string? createNote, CancellationToken cancellationToken)
+ {
+ try
+ {
+ return await runner.ListLongQueryTraceDatabasesAsync(server, allDatabases: true, databaseScope: null, cancellationToken);
+ }
+ catch (Exception ex) when (ex is not OperationCanceledException)
+ {
+ throw new LongQueryTraceDropException(Array.Empty(), ex, createNote);
+ }
+ }
+
+ ///
+ /// Drops the session in each database, tries every one, and logs a warning that names each database where the
+ /// drop failed (). The hourly attempt after the cap logs it at
+ /// Debug instead.
+ ///
+ private static Task DropLongQueryTraceInEachAsync(ServerRuntime server, DarlingCollectorRunner runner, IReadOnlyList databases, string? createNote, bool afterTheCap, ILogger? logger, CancellationToken cancellationToken) =>
+ LongQueryTraceDatabases.DropEachAsync(
+ databases,
+ async (databaseName, token) =>
+ {
+ if (runner.LongQueryTraceDatabaseOverrideForTests is { } inDatabase)
+ {
+ await inDatabase(server, databaseName, false, token);
+ return;
+ }
+
+ using var connection = await runner.OpenAzureDatabaseConnectionAsync(server, databaseName, token);
+ await DropLongQueryCompletionsAsync(connection, databaseScoped: true, token);
+ },
+ (databaseName, ex) => logger?.Log(
+ afterTheCap ? LogLevel.Debug : LogLevel.Warning,
+ "[{Server}] [{Database}] Failed to drop the long-query completion XE session: {Message}",
+ server.Config.DisplayName, databaseName, ex.Message),
+ createNote,
+ cancellationToken);
+
private static async Task EnsureLongQueryCompletionsAzureAsync(SqlConnection connection, ServerRuntime server, string databaseName, ILogger? logger, CancellationToken cancellationToken)
{
using (var cmd = new SqlCommand(@"
@@ -818,3 +934,24 @@ internal static bool IsBenignXeSessionAlreadyPresent(SqlException ex)
return true;
}
}
+
+///
+/// Which part of the long-query trace's reconcile runs ().
+/// The worker's latch decides, in DarlingWorker.ReconcileLongQueryTraceAsync.
+///
+public enum LongQueryTracePass
+{
+ /// The whole reconcile: after a connect, or after a change of the state key.
+ Full,
+
+ ///
+ /// The hourly pass while the trace is on: the create side alone, so a session dropped from outside comes back.
+ ///
+ CreateOnly,
+
+ ///
+ /// The hourly attempt after the cleanup's cap (): the whole reconcile, with
+ /// each failed drop logged at Debug.
+ ///
+ RetryAfterCap,
+}
diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.LongQueries.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.LongQueries.cs
index 598bde493d..5c83c318ef 100644
--- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.LongQueries.cs
+++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.LongQueries.cs
@@ -9,11 +9,23 @@
using System.ComponentModel;
using System.Threading.Tasks;
using System.Windows;
+using PerformanceMonitor.Collectors;
namespace PerformanceMonitor.Darling.Viewer;
public partial class ViewerServerTab
{
+ ///
+ /// The disabled-trace banner for a target of : where the switch is, then where
+ /// the session lives (). On Azure SQL Database the
+ /// session is per monitored database, so the banner must not say it is on the server.
+ ///
+ internal static string LongQueriesDisabledText(int engineEdition) =>
+ "The long-query completion trace is OFF for this server. It is opt-in because a completion trace adds overhead "
+ + "on busy servers. Turn it on in Settings → Collection Schedule → Edit Collector Schedules…, then tick "
+ + "the 'long_query_completions' Enabled box. "
+ + LongQueryCompletionsCollector.SessionScopeSentence(engineEdition == CollectorEngineCapability.AzureSqlDatabaseEngineEdition);
+
///
/// Long Queries inner tab (#1496): completed long-running queries (rpc/batch over the duration
/// threshold) plus attentions (cancels/timeouts) from the opt-in XE session, windowed on the toolbar's
@@ -24,6 +36,7 @@ public partial class ViewerServerTab
///
private async Task LoadLongQueriesAsync()
{
+ LongQueriesDisabledWarning.Text = LongQueriesDisabledText(_server.EngineEdition);
LongQueriesDisabledWarning.Visibility = await _dataService.GetLongQueryTraceEnabledAsync(_server.ServerId)
? Visibility.Collapsed
: Visibility.Visible;
diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.xaml b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.xaml
index 77db4508a5..61bb05e3ab 100644
--- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.xaml
+++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.xaml
@@ -3748,8 +3748,7 @@
+ Foreground="{DynamicResource WarningTextBrush}" FontSize="12" Margin="0,0,0,8" TextWrapping="Wrap"/>
`](#drop-the-extended-events-sessions-a-removed-server-left-behind---drop-xe-sessions) just before you remove the server, or run `--drop-xe-sessions --print-sql` afterwards and run the statements it prints on the server. Until [#3752](https://github.com/erikdarlingdata/PerformanceMonitor/issues/3752) the only writer of that flag was the Viewer's Collector Schedules window — an interactive desktop plus the `admin` role, which a remote viewer seat does not hold by default (`network.role` defaults to `viewer`). Two verbs, run on the **service host** (or, in bring-your-own mode, anywhere the store is reachable), close the gap:
+One collector ships **off**: `long_query_completions`, the long-running-query completion trace. A completion trace fires per statement or batch even though the duration predicate discards most of them, so it is opt-in per fleet — and opting in is a real action on the monitored servers: **enabling it creates the `PerformanceMonitor_LongQueryCompletions` Extended Events session on the next per-server sweep, and disabling it drops that session there** (the `CollectorScheduleDefaults` entry for it says exactly this, and `DarlingXeSessions.ReconcileLongQueryCompletionsAsync` is the code that follows the flag both ways). On Azure SQL Database the session is database-scoped. Enabling it creates the session in each monitored database and drops it from any other database that has it. Disabling it drops the session from every database that has it. Neither drop removes the session from a database where another registration of that database still has the trace on. A database that is also monitored as its own server is left to that registration. The service does that only on a server it still monitors: a server removed while the collector was on keeps the session. To drop it, run [`--drop-xe-sessions `](#drop-the-extended-events-sessions-a-removed-server-left-behind---drop-xe-sessions) just before you remove the server, or run `--drop-xe-sessions --print-sql` afterwards and run the statements it prints on the server. Until [#3752](https://github.com/erikdarlingdata/PerformanceMonitor/issues/3752) the only writer of that flag was the Viewer's Collector Schedules window — an interactive desktop plus the `admin` role, which a remote viewer seat does not hold by default (`network.role` defaults to `viewer`). Two verbs, run on the **service host** (or, in bring-your-own mode, anywhere the store is reachable), close the gap:
```
PerformanceMonitor.Darling.Service.exe --enable-collector long_query_completions
@@ -758,7 +758,7 @@ PerformanceMonitor.Darling.Service.exe --drop-xe-sessions sql01
PerformanceMonitor.Darling.Service.exe --drop-xe-sessions --print-sql
```
-`--drop-xe-sessions ` finds the server the way `--validate-config` does (the store's registry, or `darling.json`'s list when the store cannot be reached), connects with the same connector the service uses and the credentials in the configuration (none are ever taken from arguments), lists which of the three sessions exist, and drops each with `DROP EVENT SESSION [name] ON SERVER`. On Azure SQL Database it does the same for the database-scoped copies in each monitored database (`ON DATABASE`). `--dry-run` lists them and drops nothing; `--config ` names a `darling.json`. Only those three names are ever dropped.
+`--drop-xe-sessions ` finds the server the way `--validate-config` does (the store's registry, or `darling.json`'s list when the store cannot be reached), connects with the same connector the service uses and the credentials in the configuration (none are ever taken from arguments), lists which of the three sessions exist, and drops each with `DROP EVENT SESSION [name] ON SERVER`. On Azure SQL Database it does the same for the database-scoped copies in each monitored database (`ON DATABASE`). For the long query completions session it also searches the databases the server excludes, because a session created before a database was excluded stays there with nothing else to drop it. It leaves master, a database that is monitored as its own server, and a database where another registration of the same logical server keeps the session; it cannot read that registration's long query setting, so it counts every other registration of the logical server as keeping it. `--dry-run` lists them and drops nothing; `--config ` names a `darling.json`. Only those three names are ever dropped. A database the server excludes that the login cannot open (no user in it, a paused serverless database) is reported in a note on stderr and does not change the exit code, so a long query completions session left in it needs a manual drop; a monitored database it cannot open makes the verb exit `2`.
The order matters. The named form finds the server in the store's list of enabled servers, so it reaches only a server this service still monitors, and dropping the sessions on such a server stops its deadlock and blocked-process capture until this service reconnects to it (the service creates a missing session when it connects). Run the named form just before you remove the server, and remove the server next.
@@ -766,7 +766,7 @@ The login in the configuration needs the permission that [Microsoft Learn's `DRO
A server that is no longer configured has no definition and no credentials left to connect with, so the named form works only until `remove_server` (or the Manage Servers dialog) removes the server, and a name that matches nothing exits `1` and points at `--print-sql`: `--drop-xe-sessions --print-sql` connects to nothing and prints guarded statements (`IF EXISTS` against `sys.server_event_sessions`, and against `sys.database_event_sessions` for Azure SQL Database) for all three names in both scopes, to run on the server yourself. Exit codes: `0` when everything found was dropped (or listed) or nothing was there, `1` for a usage, configuration or unknown-name problem, `2` when the server cannot be reached or searched or a drop is refused.
-Both modes print one warning: a Lite app, a deprecated Full Dashboard install or another Darling service that still monitors the server uses the same names and creates a missing session again (Lite on its next collection cycle, the Dashboard on its next collection run, Darling on its next connect to the server; the long query completions session only where that collector is turned on, and never by the Dashboard), so run this only once nothing else monitors it. A run that drops at least one session also prints one `NOTE:` line before that warning: capture of deadlocks and blocked processes on that server stops until this service reconnects to it, so remove the server next. The line names only the captures whose session was dropped (`long query completions` when that session went too). `--dry-run` and `--print-sql` do not print it.
+Both modes print one warning: a Lite app, a deprecated Full Dashboard install or another Darling service that still monitors the server uses the same names and creates a missing session again (Lite on its next collection cycle, the Dashboard on its next collection run, Darling on its next connect to the server or, for the long query completions session, within an hour; the long query completions session only where that collector is turned on, and never by the Dashboard), so run this only once nothing else monitors it. A run that drops at least one session also prints one `NOTE:` line before that warning: capture of deadlocks and blocked processes on that server stops until this service reconnects to it, so remove the server next. The line names only the captures whose session was dropped (`long query completions` when that session went too). `--dry-run` and `--print-sql` do not print it.
---
diff --git a/Lite.Tests/LongQueryBannerSessionScopeTests.cs b/Lite.Tests/LongQueryBannerSessionScopeTests.cs
new file mode 100644
index 0000000000..5ff30c5787
--- /dev/null
+++ b/Lite.Tests/LongQueryBannerSessionScopeTests.cs
@@ -0,0 +1,71 @@
+/*
+ * Copyright (c) 2026 Erik Darling, Darling Data LLC
+ *
+ * This file is part of the SQL Server Performance Monitor Lite.
+ *
+ * Licensed under the MIT License. See LICENSE file in the project root for full license information.
+ */
+
+using System;
+using System.IO;
+using System.Runtime.CompilerServices;
+using Darling.Tests;
+using PerformanceMonitorLite.Controls;
+using Xunit;
+
+namespace Lite.Tests;
+
+///
+/// Lite's Long Queries banner, shown while the long-query completion trace is off. Its last sentence says where
+/// turning the trace on creates the Extended Events session. On Azure SQL Database the session is per monitored
+/// database (LongQueryCompletionsCollector.RunsPerDatabase), so the banner says so for that target and keeps
+/// the server wording for every other one.
+///
+public sealed class LongQueryBannerSessionScopeTests
+{
+ [Fact]
+ public void TheLiteBanner_TakesItsTextFromTheTarget_NotFromAFixedServerSentence()
+ {
+ var xaml = File.ReadAllText(RepoPath("Lite", "Controls", "ServerTab.xaml"));
+ var name = xaml.IndexOf("x:Name=\"LongQueriesDisabledWarning\"", StringComparison.Ordinal);
+ Assert.True(name >= 0, "the Long Queries tab has its disabled-trace banner");
+ var banner = xaml[name..xaml.IndexOf("/>", name, StringComparison.Ordinal)];
+ Assert.DoesNotContain("session on this server", banner, StringComparison.Ordinal);
+
+ var source = CSharpSourceWalker.StripCommentsAndStrings(File.ReadAllText(RepoPath("Lite", "Controls", "ServerTab.LongQueries.cs")));
+ var at = source.IndexOf("private async Task RefreshLongQueriesAsync(", StringComparison.Ordinal);
+ Assert.True(at >= 0, "RefreshLongQueriesAsync must exist");
+ var refresh = CSharpSourceWalker.BraceBalanced(source, source.IndexOf('{', at));
+ Assert.Contains("LongQueriesDisabledWarning.Text = LongQueriesDisabledText(_isAzureSqlDatabase)", refresh, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void TheLiteBanner_OnAzureSqlDatabase_SaysEachMonitoredDatabase_NotThisServer()
+ {
+ var text = ServerTab.LongQueriesDisabledText(isAzureSqlDatabase: true);
+
+ Assert.EndsWith(
+ "Enabling it creates the Extended Events session in each monitored database. Disabling it drops the session from every database that has it, except where another registration of that database still has the trace on. A database that is also monitored as its own server follows that server's setting.",
+ text,
+ StringComparison.Ordinal);
+ Assert.DoesNotContain("session on this server", text, StringComparison.Ordinal);
+ Assert.Contains("Settings → Collector Schedules → Edit (Default or per-server)", text, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void TheLiteBanner_OnAnyOtherTarget_KeepsTheServerSentence()
+ {
+ var text = ServerTab.LongQueriesDisabledText(isAzureSqlDatabase: false);
+
+ Assert.EndsWith(
+ "Enabling it creates the Extended Events session on this server. Disabling it drops the session.",
+ text,
+ StringComparison.Ordinal);
+ Assert.DoesNotContain("monitored database", text, StringComparison.Ordinal);
+ }
+
+ private static string RepoPath(params string[] parts) => Path.Combine([RepoRoot(), .. parts]);
+
+ private static string RepoRoot([CallerFilePath] string thisFile = "")
+ => Path.GetFullPath(Path.Combine(Path.GetDirectoryName(thisFile)!, ".."));
+}
diff --git a/Lite.Tests/LongQueryTraceLifecycleLiteTests.cs b/Lite.Tests/LongQueryTraceLifecycleLiteTests.cs
new file mode 100644
index 0000000000..241942de02
--- /dev/null
+++ b/Lite.Tests/LongQueryTraceLifecycleLiteTests.cs
@@ -0,0 +1,684 @@
+/*
+ * Copyright (c) 2026 Erik Darling, Darling Data LLC
+ *
+ * This file is part of the SQL Server Performance Monitor Lite.
+ *
+ * Licensed under the MIT License. See LICENSE file in the project root for full license information.
+ */
+
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Extensions.Logging;
+using PerformanceMonitor.Collectors;
+using PerformanceMonitorLite.Database;
+using PerformanceMonitorLite.Models;
+using PerformanceMonitorLite.Services;
+using PerformanceMonitorLite.Tests;
+using Xunit;
+
+namespace Lite.Tests;
+
+///
+/// The long-query trace's session lifecycle on Azure SQL Database, where each database holds its own session: which
+/// databases a reconcile creates the session in and drops it from, and how a failed drop is retried. Each test drives
+/// the real reconcile with the database list and the per-database work replaced, so no server is needed.
+///
+/// In app-logger-statics because two tests read 's process-wide buffer, which
+/// another reader would drain from under them.
+///
+[Collection("app-logger-statics")]
+public sealed class LongQueryTraceLifecycleLiteTests : IDisposable
+{
+ private const string Host = "lqtrace.database.windows.net";
+
+ private readonly string _tempDir;
+ private readonly string _configDir;
+ private readonly string _dbPath;
+
+ public LongQueryTraceLifecycleLiteTests()
+ {
+ _tempDir = Path.Combine(Path.GetTempPath(), "LiteTests_" + Guid.NewGuid().ToString("N")[..8]);
+ _configDir = Path.Combine(_tempDir, "config");
+ Directory.CreateDirectory(_configDir);
+ _dbPath = Path.Combine(_tempDir, "test.duckdb");
+ }
+
+ public void Dispose()
+ {
+ try
+ {
+ if (Directory.Exists(_tempDir))
+ Directory.Delete(_tempDir, recursive: true);
+ }
+ catch
+ {
+ /* Best-effort cleanup */
+ }
+ }
+
+ private sealed class Rig
+ {
+ public required RemoteCollectorService Service { get; init; }
+ public required ServerManager Servers { get; init; }
+ public required ScheduleManager Schedules { get; init; }
+ public required ServerConnection Server { get; init; }
+
+ /* What the logical server's master lists, before the registration's exclusions. */
+ public List Listed { get; set; } = new() { "master", "alpha", "beta", "gamma" };
+ public List<(string Database, bool Create)> Calls { get; } = new();
+ public HashSet Refuse { get; } = new(StringComparer.OrdinalIgnoreCase);
+ public Exception? ListFailure { get; set; }
+ public int ListCalls { get; set; }
+
+ /* The time each reconcile runs at. */
+ public DateTime Clock { get; set; } = new(2026, 10, 2, 12, 0, 0, DateTimeKind.Utc);
+
+ public Task ReconcileAsync() => Service.ReconcileLongQueryCompletionsXeSessionAsync(Server, CancellationToken.None);
+
+ public bool? Applied => Service.LongQueryTraceAppliedState(Server.Id);
+
+ public IEnumerable Dropped => Calls.Where(c => !c.Create).Select(c => c.Database);
+
+ public IEnumerable Created => Calls.Where(c => c.Create).Select(c => c.Database);
+ }
+
+ /// A logical-server registration (no database named) on an Azure SQL Database server.
+ private Task BuildRigAsync(bool traceOn, params string[] excluded) =>
+ BuildRigAsync(
+ new ServerConnection
+ {
+ ServerName = Host,
+ DisplayName = "lqtrace-" + Guid.NewGuid().ToString("N")[..8],
+ ExcludedDatabases = excluded.ToList(),
+ },
+ traceOn);
+
+ /// A registration of one database of the same logical server, with read-only intent unless told otherwise.
+ private async Task BuildDatabaseRigAsync(string database, bool traceOn, bool readOnlyIntent = true)
+ {
+ var rig = await BuildRigAsync(
+ new ServerConnection
+ {
+ ServerName = Host,
+ DisplayName = "lqtrace-" + database,
+ DatabaseName = database,
+ ReadOnlyIntent = readOnlyIntent,
+ },
+ traceOn);
+ rig.Listed = new List { database };
+ return rig;
+ }
+
+ private async Task BuildRigAsync(ServerConnection server, bool traceOn)
+ {
+ var duckDb = new DuckDbInitializer(_dbPath);
+ await duckDb.InitializeAsync();
+
+ var servers = new ServerManager(_configDir);
+ servers.AddServer(server);
+ servers.GetConnectionStatus(server.Id).SqlEngineEdition = 5;
+
+ var schedules = new ScheduleManager(_configDir);
+ schedules.UpdateSchedule("long_query_completions", enabled: traceOn);
+
+ var rig = new Rig
+ {
+ Service = new RemoteCollectorService(duckDb, servers, schedules),
+ Servers = servers,
+ Schedules = schedules,
+ Server = server,
+ };
+
+ rig.Service.LongQueryTraceUtcNowForTests = () => rig.Clock;
+ rig.Service.LongQueryTraceListOverrideForTests = (registration, allDatabases, _) =>
+ {
+ rig.ListCalls++;
+ if (rig.ListFailure is { } failure)
+ {
+ throw failure;
+ }
+
+ return Task.FromResult(allDatabases
+ ? rig.Listed.ToList()
+ : rig.Listed.Where(d => !registration.ExcludedDatabases.Contains(d, StringComparer.OrdinalIgnoreCase)).ToList());
+ };
+
+ rig.Service.LongQueryTraceDatabaseOverrideForTests = (_, database, create, _) =>
+ {
+ rig.Calls.Add((database, create));
+ return rig.Refuse.Contains(database)
+ ? Task.FromException(new InvalidOperationException($"The drop was refused in {database}."))
+ : Task.CompletedTask;
+ };
+
+ return rig;
+ }
+
+ /// Registers one database of the same logical server as its own server.
+ private static void RegisterSeparately(Rig rig, string database) =>
+ rig.Servers.AddServer(new ServerConnection { ServerName = Host, DisplayName = $"{Host} {database}", DatabaseName = database });
+
+ ///
+ /// Registers another server of the same logical server with its own trace setting: one database when
+ /// is given, otherwise a second registration of the logical server.
+ ///
+ private static ServerConnection RegisterOther(Rig rig, string? database, bool traceOn, bool readOnlyIntent, params string[] excluded)
+ {
+ var other = new ServerConnection
+ {
+ ServerName = Host,
+ DisplayName = $"{Host} {database ?? "server"} {(readOnlyIntent ? "read-only" : "read-write")}",
+ DatabaseName = database,
+ ReadOnlyIntent = readOnlyIntent,
+ ExcludedDatabases = excluded.ToList(),
+ };
+ rig.Servers.AddServer(other);
+ SetTrace(rig, other, traceOn);
+ return other;
+ }
+
+ private static void SetTrace(Rig rig, ServerConnection server, bool traceOn) =>
+ rig.Schedules.SetScheduleForServer(
+ server.Id,
+ new List { new() { Name = "long_query_completions", Enabled = traceOn } });
+
+ /* ── M1: a failed drop is retried, with a cap ── */
+
+ [Fact]
+ public async Task Off_AFailedListing_IsNotMarkedDone_AndTheNextCycleListsAgain()
+ {
+ var rig = await BuildRigAsync(traceOn: false);
+ rig.ListFailure = SqlExceptionFactory.Create(40613, 20, "Database 'master' is not currently available.");
+
+ await rig.ReconcileAsync();
+
+ Assert.Null(rig.Applied);
+
+ rig.ListFailure = null;
+ await rig.ReconcileAsync();
+
+ Assert.Equal(2, rig.ListCalls);
+ Assert.Equal(new[] { "alpha", "beta", "gamma" }, rig.Dropped);
+ Assert.False(rig.Applied);
+ }
+
+ [Fact]
+ public async Task Off_OneDatabaseRefusesTheDrop_TheOthersAreStillDropped_AndTheNextCycleRetries()
+ {
+ var level = AppLogger.MinimumLevel;
+ try
+ {
+ AppLogger.SetMinimumLevel(LogLevel.Warning);
+ AppLogger.DrainBufferedLines();
+
+ var rig = await BuildRigAsync(traceOn: false);
+ rig.Refuse.Add("beta");
+
+ await rig.ReconcileAsync();
+
+ Assert.Equal(new[] { "alpha", "beta", "gamma" }, rig.Dropped);
+ Assert.Null(rig.Applied);
+ Assert.Contains(AppLogger.DrainBufferedLines(), line =>
+ line.Contains(rig.Server.DisplayName, StringComparison.Ordinal)
+ && line.Contains("[beta]", StringComparison.Ordinal)
+ && line.Contains("WARN", StringComparison.Ordinal));
+
+ rig.Calls.Clear();
+ await rig.ReconcileAsync();
+
+ Assert.Contains("beta", rig.Dropped);
+ }
+ finally
+ {
+ AppLogger.SetMinimumLevel(level);
+ }
+ }
+
+ [Fact]
+ public async Task Off_TheFifthFailedPassInARow_IsMarkedDone_WithOneWarningThatNamesTheDatabase()
+ {
+ var level = AppLogger.MinimumLevel;
+ try
+ {
+ AppLogger.SetMinimumLevel(LogLevel.Warning);
+ AppLogger.DrainBufferedLines();
+
+ var rig = await BuildRigAsync(traceOn: false);
+ rig.Refuse.Add("beta");
+
+ for (var pass = 1; pass < LongQueryTraceDatabases.DropAttemptCap; pass++)
+ {
+ await rig.ReconcileAsync();
+ Assert.Null(rig.Applied);
+ }
+
+ await rig.ReconcileAsync();
+ Assert.False(rig.Applied);
+
+ /* Done: the next cycle does not try again. */
+ rig.Calls.Clear();
+ await rig.ReconcileAsync();
+ Assert.Empty(rig.Calls);
+
+ var giveUps = AppLogger.DrainBufferedLines()
+ .Where(line => line.Contains(rig.Server.DisplayName, StringComparison.Ordinal)
+ && line.Contains("Stopped retrying", StringComparison.Ordinal))
+ .ToList();
+ var giveUp = Assert.Single(giveUps);
+ Assert.Contains("beta", giveUp, StringComparison.Ordinal);
+ }
+ finally
+ {
+ AppLogger.SetMinimumLevel(level);
+ }
+ }
+
+ /* ── After the cap: one attempt an hour ── */
+
+ [Fact]
+ public async Task Off_AfterTheCap_TriesAgainOnceAnHour_AtDebug_WithNoSecondWarning()
+ {
+ var level = AppLogger.MinimumLevel;
+ try
+ {
+ AppLogger.SetMinimumLevel(LogLevel.Debug);
+ AppLogger.DrainBufferedLines();
+
+ var rig = await BuildRigAsync(traceOn: false);
+ rig.Refuse.Add("beta");
+
+ for (var pass = 1; pass <= LongQueryTraceDatabases.DropAttemptCap; pass++)
+ {
+ await rig.ReconcileAsync();
+ }
+
+ Assert.False(rig.Applied);
+ var giveUp = Assert.Single(Lines(rig), line => line.Contains("Stopped retrying", StringComparison.Ordinal));
+ Assert.Contains(
+ "It tries again once an hour, and right away after a restart, a change to the trace's settings, or a change to"
+ + " another registration of these databases.",
+ giveUp,
+ StringComparison.Ordinal);
+ Assert.DoesNotContain("reconnects", giveUp, StringComparison.Ordinal);
+
+ /* Within the hour: nothing. */
+ rig.Calls.Clear();
+ rig.Clock += LongQueryTraceDatabases.RetryInterval - TimeSpan.FromMinutes(1);
+ await rig.ReconcileAsync();
+ Assert.Empty(rig.Calls);
+
+ /* An hour after the cap: one attempt, and the cycle after it runs nothing. */
+ rig.Clock += TimeSpan.FromMinutes(1);
+ await rig.ReconcileAsync();
+ await rig.ReconcileAsync();
+ Assert.Equal(new[] { "alpha", "beta", "gamma" }, rig.Dropped);
+
+ /* It fails again an hour later. Both failures log at Debug, and the cap's warning stays the only one. */
+ rig.Clock += LongQueryTraceDatabases.RetryInterval;
+ await rig.ReconcileAsync();
+ Assert.Equal(new[] { "alpha", "beta", "gamma", "alpha", "beta", "gamma" }, rig.Dropped);
+
+ var afterTheCap = Lines(rig);
+ Assert.DoesNotContain(afterTheCap, line => line.Contains("WARN", StringComparison.Ordinal));
+ Assert.Equal(2, afterTheCap.Count(line =>
+ line.Contains("DEBUG", StringComparison.Ordinal)
+ && line.Contains("The next attempt is in an hour.", StringComparison.Ordinal)));
+ Assert.Equal(2, afterTheCap.Count(line =>
+ line.Contains("DEBUG", StringComparison.Ordinal)
+ && line.Contains("[beta]", StringComparison.Ordinal)));
+ Assert.False(rig.Applied);
+ }
+ finally
+ {
+ AppLogger.SetMinimumLevel(level);
+ }
+ }
+
+ [Fact]
+ public async Task AfterTheCap_AnAttemptThatSucceeds_EndsTheHourlyAttempts()
+ {
+ var rig = await BuildRigAsync(traceOn: false);
+ rig.Refuse.Add("beta");
+
+ for (var pass = 1; pass <= LongQueryTraceDatabases.DropAttemptCap; pass++)
+ {
+ await rig.ReconcileAsync();
+ }
+
+ rig.Refuse.Clear();
+ rig.Calls.Clear();
+ rig.Clock += LongQueryTraceDatabases.RetryInterval;
+ await rig.ReconcileAsync();
+
+ Assert.Equal(new[] { "alpha", "beta", "gamma" }, rig.Dropped);
+
+ /* Done, with nothing left to retry: an hour later nothing runs. */
+ rig.Calls.Clear();
+ rig.Clock += LongQueryTraceDatabases.RetryInterval;
+ await rig.ReconcileAsync();
+ Assert.Empty(rig.Calls);
+ }
+
+ [Fact]
+ public async Task On_AfterTheCap_TheCyclesInBetween_KeepTheHourlyAttempt()
+ {
+ /* gamma is excluded, so the drop outside the set tries it, and gamma refuses. */
+ var rig = await BuildRigAsync(traceOn: true, "gamma");
+ rig.Refuse.Add("gamma");
+
+ for (var pass = 1; pass <= LongQueryTraceDatabases.DropAttemptCap; pass++)
+ {
+ await rig.ReconcileAsync();
+ }
+
+ Assert.True(rig.Applied);
+
+ /* Each cycle in the hour creates the session where it is missing and leaves the drop alone. A cycle that
+ skips the drop must not end the hourly attempt. */
+ rig.Calls.Clear();
+ for (var cycle = 1; cycle <= 3; cycle++)
+ {
+ rig.Clock += TimeSpan.FromMinutes(15);
+ await rig.ReconcileAsync();
+ }
+
+ Assert.Empty(rig.Dropped);
+ Assert.Equal(new[] { "alpha", "beta", "alpha", "beta", "alpha", "beta" }, rig.Created);
+
+ rig.Clock += TimeSpan.FromMinutes(15);
+ await rig.ReconcileAsync();
+ Assert.Equal(new[] { "gamma" }, rig.Dropped);
+ }
+
+ /// This rig's lines in the log buffer since the last drain.
+ private static List Lines(Rig rig) =>
+ AppLogger.DrainBufferedLines()
+ .Where(line => line.Contains(rig.Server.DisplayName, StringComparison.Ordinal))
+ .ToList();
+
+ /* ── M2: the trace follows the monitored set ── */
+
+ [Fact]
+ public async Task On_AnExclusionChange_DropsTheSessionFromTheNewlyExcludedDatabase()
+ {
+ var rig = await BuildRigAsync(traceOn: true);
+
+ await rig.ReconcileAsync();
+ Assert.Equal(new[] { "alpha", "beta", "gamma" }, rig.Created);
+
+ rig.Server.ExcludedDatabases.Add("gamma");
+ rig.Calls.Clear();
+ await rig.ReconcileAsync();
+
+ Assert.Equal(new[] { "gamma" }, rig.Dropped);
+ Assert.DoesNotContain("gamma", rig.Created);
+ }
+
+ [Fact]
+ public async Task Off_DropsTheSessionEverywhere_ExcludedDatabasesIncluded()
+ {
+ var rig = await BuildRigAsync(traceOn: false, "gamma");
+
+ await rig.ReconcileAsync();
+
+ Assert.Equal(new[] { "alpha", "beta", "gamma" }, rig.Dropped);
+ }
+
+ /* ── L3: a database monitored as its own server belongs to that registration ── */
+
+ [Fact]
+ public async Task Off_LeavesTheSessionOfADatabaseMonitoredAsItsOwnServer()
+ {
+ var rig = await BuildRigAsync(traceOn: false);
+ RegisterSeparately(rig, "beta");
+
+ await rig.ReconcileAsync();
+
+ Assert.Equal(new[] { "alpha", "gamma" }, rig.Dropped);
+ }
+
+ [Fact]
+ public async Task On_DoesNotCreateTheSessionInADatabaseMonitoredAsItsOwnServer()
+ {
+ var rig = await BuildRigAsync(traceOn: true);
+ RegisterSeparately(rig, "beta");
+
+ await rig.ReconcileAsync();
+
+ Assert.Equal(new[] { "alpha", "gamma" }, rig.Created);
+ Assert.DoesNotContain("beta", rig.Dropped);
+ }
+
+ ///
+ /// The trace ON leaves a database monitored as its own server even when this registration excludes it, which
+ /// would otherwise put it in the drop outside the set. Only the plan's own-server rule keeps it: the other
+ /// registration's trace is off, so no other registration keeps the session there.
+ ///
+ [Fact]
+ public async Task On_ADatabaseMonitoredAsItsOwnServer_AndExcluded_IsNotDropped()
+ {
+ var rig = await BuildRigAsync(traceOn: true, "beta");
+ RegisterOther(rig, "beta", traceOn: false, readOnlyIntent: false);
+
+ await rig.ReconcileAsync();
+
+ Assert.DoesNotContain("beta", rig.Dropped);
+ }
+
+ /* ── M1: a drop leaves a database where another registration of the server keeps the session ── */
+
+ [Fact]
+ public async Task Off_LeavesTheDatabaseOfAReadOnlyRegistration_WhileItsTraceIsOn()
+ {
+ var rig = await BuildRigAsync(traceOn: false);
+ RegisterOther(rig, "beta", traceOn: true, readOnlyIntent: true);
+
+ await rig.ReconcileAsync();
+
+ Assert.Equal(new[] { "alpha", "gamma" }, rig.Dropped);
+ }
+
+ [Fact]
+ public async Task ReadOnlyRegistration_Off_LeavesItsDatabase_WhileTheLogicalServersTraceIsOnAndCoversIt()
+ {
+ var rig = await BuildDatabaseRigAsync("beta", traceOn: false);
+ RegisterOther(rig, database: null, traceOn: true, readOnlyIntent: false);
+
+ await rig.ReconcileAsync();
+
+ Assert.Empty(rig.Dropped);
+ Assert.False(rig.Applied);
+ }
+
+ [Fact]
+ public async Task ReadOnlyRegistration_Off_DropsItsDatabase_WhenTheLogicalServerExcludesIt()
+ {
+ var rig = await BuildDatabaseRigAsync("beta", traceOn: false);
+ RegisterOther(rig, database: null, traceOn: true, readOnlyIntent: false, "beta");
+
+ await rig.ReconcileAsync();
+
+ Assert.Equal(new[] { "beta" }, rig.Dropped);
+ }
+
+ ///
+ /// A registration of one database without read-only intent is monitored as its own server, so the logical
+ /// server's registration never creates the session there and does not keep it. Turning that database's own
+ /// trace off still drops it.
+ ///
+ [Fact]
+ public async Task DatabaseRegistration_Off_DropsItsDatabase_WhileTheLogicalServerLeavesItToIt()
+ {
+ var rig = await BuildDatabaseRigAsync("beta", traceOn: false, readOnlyIntent: false);
+ RegisterOther(rig, database: null, traceOn: true, readOnlyIntent: false);
+
+ await rig.ReconcileAsync();
+
+ Assert.Equal(new[] { "beta" }, rig.Dropped);
+ }
+
+ [Fact]
+ public async Task Off_LeavesTheDatabasesOfASecondLogicalServerRegistration_WhileItsTraceIsOn()
+ {
+ var rig = await BuildRigAsync(traceOn: false);
+ RegisterOther(rig, database: null, traceOn: true, readOnlyIntent: true);
+
+ await rig.ReconcileAsync();
+
+ Assert.Empty(rig.Dropped);
+ }
+
+ [Fact]
+ public async Task On_TheOutsideDrop_LeavesAnExcludedDatabase_ThatASecondRegistrationsTraceCovers()
+ {
+ var rig = await BuildRigAsync(traceOn: true, "gamma");
+ RegisterOther(rig, database: null, traceOn: true, readOnlyIntent: true);
+
+ await rig.ReconcileAsync();
+
+ Assert.Equal(new[] { "alpha", "beta" }, rig.Created);
+ Assert.Empty(rig.Dropped);
+ }
+
+ [Fact]
+ public async Task On_TheOutsideDrop_StillDropsADatabase_ThatBothLogicalServerRegistrationsExclude()
+ {
+ var rig = await BuildRigAsync(traceOn: true, "gamma");
+ RegisterOther(rig, database: null, traceOn: true, readOnlyIntent: true, "gamma");
+
+ await rig.ReconcileAsync();
+
+ Assert.Equal(new[] { "gamma" }, rig.Dropped);
+ }
+
+ [Fact]
+ public async Task Off_OnceTheOtherRegistrationTurnsItsTraceOff_TheNextPassDrops()
+ {
+ var rig = await BuildRigAsync(traceOn: false);
+ var other = RegisterOther(rig, "beta", traceOn: true, readOnlyIntent: true);
+
+ await rig.ReconcileAsync();
+ Assert.DoesNotContain("beta", rig.Dropped);
+
+ /* Nothing changed: the reconcile is done and does not run again. */
+ rig.Calls.Clear();
+ await rig.ReconcileAsync();
+ Assert.Empty(rig.Calls);
+
+ SetTrace(rig, other, traceOn: false);
+ await rig.ReconcileAsync();
+
+ Assert.Contains("beta", rig.Dropped);
+ }
+
+ /* ── the read follows the lifecycle; the always-on sessions do not change ── */
+
+ ///
+ /// The logical server's long-query read skips the databases monitored as their own servers, the same ones its
+ /// lifecycle leaves alone. Reading one would fail every cycle when that database's own trace is off (a missing
+ /// session is a read failure since #4731), or store its events twice when it is on. Pinned in the source: the
+ /// read loop needs a live connection per database.
+ ///
+ [Fact]
+ public void TheLongQueryRead_SkipsTheDatabasesMonitoredAsTheirOwnServers()
+ {
+ var definition = ReadLf(Path.Combine("PerformanceMonitor.Collectors", "LongQueryCompletionsCollector.cs"));
+ Assert.Contains("public override bool SkipsSeparatelyMonitoredDatabases => true;", definition, StringComparison.Ordinal);
+
+ var runner = ReadLf(Path.Combine("Lite", "Services", "RemoteCollectorService.DefinitionRunner.cs"));
+ var list = runner.IndexOf("await GetAzureDatabaseListAsync(server, cancellationToken);", StringComparison.Ordinal);
+ var skip = runner.IndexOf("if (definition.SkipsSeparatelyMonitoredDatabases)", StringComparison.Ordinal);
+ Assert.True(list > 0 && skip > list, "the per-database read filters its list after listing it");
+ Assert.Contains("databases = WithoutSeparatelyMonitoredDatabases(server, databases);", runner, StringComparison.Ordinal);
+ }
+
+ ///
+ /// Guard, not RED first: the always-on deadlock and blocked-process sessions keep their inventory lifecycle. Their
+ /// ensures still list the databases themselves, with the server's exclusions and nothing else, so an exclusion or
+ /// ownership change that moves the long-query trace does not move them.
+ ///
+ [Fact]
+ public void TheAlwaysOnSessions_StillFollowTheInventory()
+ {
+ var shared = ReadLf(Path.Combine("Lite", "Services", "RemoteCollectorService.BlockedProcessReport.cs"));
+ Assert.Contains("databases = await GetAzureDatabaseListAsync(server, cancellationToken);", shared, StringComparison.Ordinal);
+
+ foreach (var (file, capture) in new[] { ("RemoteCollectorService.BlockedProcessReport.cs", "\"blocked process\""), ("RemoteCollectorService.Deadlocks.cs", "\"deadlock\"") })
+ {
+ var source = ReadLf(Path.Combine("Lite", "Services", file));
+ var call = source.IndexOf("await EnsureDatabaseScopedXeSessionsAsync(", StringComparison.Ordinal);
+ Assert.True(call > 0, $"{file} lost its database-scoped ensure");
+ var args = source[call..source.IndexOf(';', call)];
+ Assert.Contains(capture, args, StringComparison.Ordinal);
+ Assert.EndsWith("cancellationToken)", args, StringComparison.Ordinal);
+ Assert.DoesNotContain("SeparatelyMonitored", args, StringComparison.Ordinal);
+ }
+ }
+
+ ///
+ /// The edition is read once for a reconcile. A connection check that writes a blank edition and then 5 again, between the
+ /// reconcile's first read and its drop, must not send the drop down the Azure branch with no registrations: that drop
+ /// listed nothing as kept and dropped the session from a database another registration still uses.
+ ///
+ [Fact]
+ public async Task Off_AnEditionThatChangesDuringTheReconcile_StillLeavesTheDatabasesAnotherRegistrationKeeps()
+ {
+ var rig = await BuildRigAsync(traceOn: false);
+ var other = RegisterOther(rig, database: null, traceOn: false, readOnlyIntent: true);
+
+ /* An earlier cycle read the edition. With both traces off nothing is kept, so the drop takes every database. */
+ await rig.ReconcileAsync();
+ Assert.Equal(new[] { "alpha", "beta", "gamma" }, rig.Dropped);
+
+ /* The other registration turns its trace on, so the plan runs again. A failed check wrote a blank edition, and the
+ next check wrote 5 between this reconcile's first read of it and its drop (the clock is read in between). */
+ SetTrace(rig, other, traceOn: true);
+ var status = rig.Servers.GetConnectionStatus(rig.Server.Id);
+ status.SqlEngineEdition = 0;
+ rig.Service.LongQueryTraceUtcNowForTests = () =>
+ {
+ status.SqlEngineEdition = 5;
+ return rig.Clock;
+ };
+ rig.Calls.Clear();
+
+ await rig.ReconcileAsync();
+
+ Assert.Empty(rig.Dropped);
+ }
+
+ ///
+ /// The enumeration behind the Azure database list has the server's exclusion clause and parameters only when asked for.
+ /// The trace's drops ask for none, so a session created before a database was excluded is still dropped there. The
+ /// lifecycle rigs replace the listing, so this reads the query the real listing runs.
+ ///
+ [Fact]
+ public void TheAzureDatabaseList_AppliesTheServersExclusionsOnlyWhenAsked()
+ {
+ var server = new ServerConnection { ServerName = Host, ExcludedDatabases = new List { "scratch", "tempdb_clone" } };
+
+ var (everySql, everyParameters) = RemoteCollectorService.BuildAzureDatabaseListQuery(server, applyExclusions: false);
+ Assert.DoesNotContain("NOT IN", everySql, StringComparison.Ordinal);
+ Assert.Empty(everyParameters);
+
+ var (monitoredSql, monitoredParameters) = RemoteCollectorService.BuildAzureDatabaseListQuery(server, applyExclusions: true);
+ Assert.Contains("name NOT IN (@excl_db_0, @excl_db_1)", monitoredSql, StringComparison.Ordinal);
+ Assert.Equal(2, monitoredParameters.Count);
+ }
+
+ private static string ReadLf(string relativePath)
+ {
+ var dir = AppContext.BaseDirectory;
+ while (dir != null && !File.Exists(Path.Combine(dir, relativePath)))
+ {
+ dir = Path.GetDirectoryName(dir);
+ }
+
+ Assert.NotNull(dir);
+ return File.ReadAllText(Path.Combine(dir!, relativePath)).Replace("\r\n", "\n");
+ }
+}
diff --git a/Lite/Controls/ServerTab.LongQueries.cs b/Lite/Controls/ServerTab.LongQueries.cs
index bcd74e9468..c5bb8a1edf 100644
--- a/Lite/Controls/ServerTab.LongQueries.cs
+++ b/Lite/Controls/ServerTab.LongQueries.cs
@@ -10,12 +10,24 @@
using System.ComponentModel;
using System.Threading.Tasks;
using System.Windows;
+using PerformanceMonitor.Collectors;
using PerformanceMonitorLite.Services;
namespace PerformanceMonitorLite.Controls;
public partial class ServerTab
{
+ ///
+ /// The disabled-trace banner for an Azure SQL Database target or any other: where the switch is, then where the
+ /// session lives (). On Azure SQL Database the
+ /// session is per monitored database, so the banner must not say it is on the server.
+ ///
+ internal static string LongQueriesDisabledText(bool isAzureSqlDatabase) =>
+ "The long-query completion trace is OFF for this server. It is opt-in because a completion trace adds overhead "
+ + "on busy servers. Turn it on in Settings → Collector Schedules → Edit (Default or per-server), then tick "
+ + "the 'long_query_completions' Enabled box. "
+ + LongQueryCompletionsCollector.SessionScopeSentence(isAzureSqlDatabase);
+
///
/// Tab 20 — Long Queries (#1496): completed long-running queries (rpc/batch over the duration
/// threshold) plus attentions (cancels/timeouts) from the opt-in XE session. Because the collector
@@ -25,6 +37,7 @@ public partial class ServerTab
///
private async Task RefreshLongQueriesAsync(int hoursBack, DateTime? fromDate, DateTime? toDate)
{
+ LongQueriesDisabledWarning.Text = LongQueriesDisabledText(_isAzureSqlDatabase);
LongQueriesDisabledWarning.Visibility = _isLongQueryTraceEnabled()
? Visibility.Collapsed
: Visibility.Visible;
diff --git a/Lite/Controls/ServerTab.xaml b/Lite/Controls/ServerTab.xaml
index 5db2dacf3f..5efbe80775 100644
--- a/Lite/Controls/ServerTab.xaml
+++ b/Lite/Controls/ServerTab.xaml
@@ -3117,8 +3117,7 @@
+ Foreground="{DynamicResource WarningTextBrush}" FontSize="12" Margin="0,0,0,8" TextWrapping="Wrap"/>
///
public IReadOnlyList SeparatelyMonitoredDatabases(
ServerConnection self, int liveEdition, IEnumerable servers) =>
+ SeparatelyMonitoredDatabases(IsAzureSqlDatabase(self, liveEdition), self, servers);
+
+ ///
+ /// for a configured server whose edition the caller
+ /// already knows. The long-query trace's session lifecycle calls this from its Azure SQL Database branch, so
+ /// the trace and the alerts skip the same databases.
+ ///
+ public static IReadOnlyList SeparatelyMonitoredDatabases(
+ bool isAzureSqlDatabase, ServerConnection self, IEnumerable servers) =>
AzureMasterScope.SeparatelyMonitoredDatabases(
- IsAzureSqlDatabase(self, liveEdition),
+ isAzureSqlDatabase,
self.Id, self.ServerName, self.DatabaseName,
- servers.Select(t => new AlertTargetIdentity(t.Id, t.ServerName, t.DatabaseName, t.IsEnabled, t.ReadOnlyIntent)));
+ AlertTargets(servers));
+
+ ///
+ /// The databases monitored as their own servers on an Azure SQL Database logical server, as that server's
+ /// registration sees them: the same list for every registration of the server, including one that names a
+ /// database. The long-query trace uses it to tell which databases a logical server's registration keeps its
+ /// session in (). No configuration id is empty, so the empty
+ /// self id leaves none of the registrations out.
+ ///
+ public static IReadOnlyList SeparatelyMonitoredDatabasesOnServer(string host, IEnumerable servers) =>
+ AzureMasterScope.SeparatelyMonitoredDatabases(
+ isAzureSqlDb: true, selfId: string.Empty, host, database: null, AlertTargets(servers));
+
+ private static IEnumerable AlertTargets(IEnumerable servers) =>
+ servers.Select(t => new AlertTargetIdentity(t.Id, t.ServerName, t.DatabaseName, t.IsEnabled, t.ReadOnlyIntent));
///
/// The analysis provider's answer for a storage server id: for the
diff --git a/Lite/Services/RemoteCollectorService.BlockedProcessReport.cs b/Lite/Services/RemoteCollectorService.BlockedProcessReport.cs
index c22a048de0..3c33d6fa00 100644
--- a/Lite/Services/RemoteCollectorService.BlockedProcessReport.cs
+++ b/Lite/Services/RemoteCollectorService.BlockedProcessReport.cs
@@ -329,22 +329,45 @@ private static bool IsBenignXeSessionAlreadyPresent(SqlException ex)
/// for the #1086 health surface) instead of letting a
/// zero-row read record SUCCESS.
///
+ private Task EnsureDatabaseScopedXeSessionsAsync(
+ ServerConnection server,
+ string captureName,
+ string sessionName,
+ Func ensureAsync,
+ CancellationToken cancellationToken) =>
+ EnsureDatabaseScopedXeSessionsAsync(server, captureName, sessionName, ensureAsync, plannedDatabases: null, cancellationToken);
+
+ ///
+ ///
+ /// over a list the caller already planned. The always-on deadlock and blocked-process sessions pass none and
+ /// follow the inventory, as before. The long-query trace passes its plan's create list
+ /// (): the monitored databases, minus those monitored as their own
+ /// servers. An empty plan ensures nothing and does not throw.
+ ///
private async Task EnsureDatabaseScopedXeSessionsAsync(
ServerConnection server,
string captureName,
string sessionName,
Func ensureAsync,
+ IReadOnlyList? plannedDatabases,
CancellationToken cancellationToken)
{
- List databases;
- try
+ IReadOnlyList databases;
+ if (plannedDatabases is not null)
{
- databases = await GetAzureDatabaseListAsync(server, cancellationToken);
+ databases = plannedDatabases;
}
- catch (SqlException ex)
+ else
{
- AppLogger.Error("XeSession", $"[{server.DisplayName}] Failed to enumerate databases for {captureName} XE sessions: {ex.Message}");
- throw new XeSessionEnsureException(captureName, ex);
+ try
+ {
+ databases = await GetAzureDatabaseListAsync(server, cancellationToken);
+ }
+ catch (SqlException ex)
+ {
+ AppLogger.Error("XeSession", $"[{server.DisplayName}] Failed to enumerate databases for {captureName} XE sessions: {ex.Message}");
+ throw new XeSessionEnsureException(captureName, ex);
+ }
}
int attempted = 0;
diff --git a/Lite/Services/RemoteCollectorService.DefinitionRunner.cs b/Lite/Services/RemoteCollectorService.DefinitionRunner.cs
index aba46be700..1e39a5dc0f 100644
--- a/Lite/Services/RemoteCollectorService.DefinitionRunner.cs
+++ b/Lite/Services/RemoteCollectorService.DefinitionRunner.cs
@@ -280,6 +280,13 @@ watermark would let one busy database's newer event silence another database's o
? await databaseListOverride(server, cancellationToken)
: await GetAzureDatabaseListAsync(server, cancellationToken);
+ /* The long-query trace leaves a database monitored as its own server to that registration, so its
+ read does too (LongQueryCompletionsCollector.SkipsSeparatelyMonitoredDatabases). */
+ if (definition.SkipsSeparatelyMonitoredDatabases)
+ {
+ databases = WithoutSeparatelyMonitoredDatabases(server, databases);
+ }
+
var attempted = 0;
var failed = 0;
Exception? firstFailure = null;
diff --git a/Lite/Services/RemoteCollectorService.LongQueryCompletions.cs b/Lite/Services/RemoteCollectorService.LongQueryCompletions.cs
index 600337195d..ca1eaa7d07 100644
--- a/Lite/Services/RemoteCollectorService.LongQueryCompletions.cs
+++ b/Lite/Services/RemoteCollectorService.LongQueryCompletions.cs
@@ -10,6 +10,7 @@
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Data;
+using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Data.SqlClient;
@@ -24,12 +25,18 @@ public partial class RemoteCollectorService
lifecycle can never disagree on them (#1496). */
private const string LongQueryXeSessionName = LongQueryCompletionsCollector.XeSessionName;
- /* Per-server last-applied enabled state for the long-query trace's XE session, so the reconcile
- does not open a connection every cycle for a server whose state has not changed. Keyed by
- server id; unset = not yet reconciled. In-memory (cleared on app restart, which then re-
- reconciles once). true = the session is being ensured (enabled); false = confirmed dropped
- (disabled). */
- private readonly ConcurrentDictionary _longQueryTraceApplied = new();
+ /* Per-server last-applied state for the long-query trace's XE session, so the reconcile does not
+ open a connection every cycle for a server whose state has not changed. Keyed by server id;
+ unset = not yet reconciled. In-memory (cleared on app restart, which then re-reconciles once).
+ Enabled true = the session is being ensured; false = confirmed dropped. StateKey is what the
+ Azure SQL Database plan depended on (LongQueryTraceDatabases.StateKey): when the exclusions or
+ the databases monitored as their own servers change, the last reconcile no longer says where
+ the session belongs, so it runs again. Empty on every other engine. */
+ private readonly ConcurrentDictionary _longQueryTraceApplied = new();
+
+ /* Failed cleanup passes in a row, per server, for LongQueryTraceDatabases.DropAttemptCap, and the clock for the
+ hourly attempts after the cap. */
+ private readonly ConcurrentDictionary _longQueryTraceDropRetry = new();
/* #3754: the ENABLE failure the reconcile below caught, per server, kept until a later reconcile
succeeds. The reconcile runs from the per-server collection loop, OUTSIDE the collector's run - so
@@ -44,6 +51,11 @@ this slot is how the long-query collector's out-of-run ensure reaches the same a
SqlException's number (PERMISSIONS for a denied ALTER ANY EVENT SESSION, ERROR otherwise). */
private readonly ConcurrentDictionary _longQueryTraceFault = new();
+ /* The engine edition the reconcile judges a server by. Its own instance, because the app's other one lives in the main
+ window. A known live edition wins and is remembered, so a blank status that a failed connection check wrote leaves the
+ server judged by the edition it had. Its dictionary is concurrent: the per-server tasks run in parallel. */
+ private readonly KnownEngineEditions _engineEditions = new();
+
///
/// Reconciles the long-query completion XE session to the collector's enabled flag — Erik's
/// dedicated switch (#1496), default OFF. ENABLED: ensure the session exists + running (re-ensured
@@ -56,28 +68,82 @@ this slot is how the long-query collector's out-of-run ensure reaches the same a
/// connection to every server forever. Called unconditionally from the per-server collection loop
/// (NOT gated by the enabled flag), because a disabled collector is never dispatched and so the drop
/// has nowhere else to run.
+ ///
+ /// On Azure SQL Database, where each database holds its own session, the databases come from
+ /// , shared with Darling. Enabled: created in each monitored
+ /// database, and dropped from listed databases outside that set whenever the plan's settings change
+ /// (and once after each start). Disabled: dropped from every listed database, exclusions included.
+ /// Both leave alone a database monitored as its own server: its own registration owns that session.
+ /// Neither drops the session from a database where another registration of the same logical server has the
+ /// trace on and keeps it (). A drop that fails is retried on the next cycles, up to
+ /// failed passes in a row; then one warning names the databases where the session may remain, and the drop is
+ /// tried again once an hour, logged at Debug.
///
public async Task ReconcileLongQueryCompletionsXeSessionAsync(ServerConnection server, CancellationToken cancellationToken = default)
{
var schedule = _scheduleManager.GetScheduleForServer(server.Id, "long_query_completions");
var enabled = schedule?.Enabled ?? false;
+ /* Read once. The ensure and the drop below get this answer: a status that changes during the reconcile (a blank one
+ from a failed connection check, then 5 again) must not send the drop down the Azure branch with none of the
+ registrations this reconcile worked from. */
+ var isAzureSqlDatabase = _engineEditions.IsAzureSqlDatabase(server, _serverManager.GetConnectionStatus(server.Id).SqlEngineEdition);
+ var separatelyMonitored = isAzureSqlDatabase ? SeparatelyMonitoredDatabasesFor(server) : Array.Empty();
+
+ /* Azure SQL Database: the other registrations of this logical server, so a drop leaves a database where one
+ of them keeps the session (LongQueryTraceDatabases.KeptElsewhere). They are in the state key too: a drop
+ skipped for one of them runs once that one turns its trace off. */
+ IReadOnlyList registrations = isAzureSqlDatabase
+ ? LongQueryTraceRegistrationsFor(server)
+ : Array.Empty();
+ var serverSeparatelyMonitored = isAzureSqlDatabase
+ ? KnownEngineEditions.SeparatelyMonitoredDatabasesOnServer(server.ServerName, _serverManager.GetAllServers())
+ : Array.Empty();
+ IReadOnlyList KeptElsewhere(IEnumerable candidates) =>
+ LongQueryTraceDatabases.KeptElsewhere(server.Id, server.ServerName, candidates, registrations, serverSeparatelyMonitored);
+
+ var stateKey = isAzureSqlDatabase
+ ? LongQueryTraceDatabases.StateKey(
+ enabled,
+ databaseScope: null,
+ server.ExcludedDatabases,
+ separatelyMonitored,
+ LongQueryTraceDatabases.CoOwners(server.Id, server.ServerName, registrations, serverSeparatelyMonitored))
+ : string.Empty;
+
+ /* After the cap, the cleanup runs once an hour, on this clock. That attempt logs its failures at Debug, so
+ the one warning is not repeated. */
+ var utcNow = LongQueryTraceUtcNowForTests?.Invoke() ?? DateTime.UtcNow;
+ var retry = _longQueryTraceDropRetry.GetOrAdd(server.Id, _ => new LongQueryTraceDropRetry());
+ var afterTheCap = retry.RetryDue(stateKey, utcNow);
+
try
{
if (enabled)
{
- await EnsureLongQueryCompletionsXeSessionAsync(server, cancellationToken);
- _longQueryTraceApplied[server.Id] = true;
+ var monitored = await EnsureLongQueryCompletionsXeSessionAsync(server, isAzureSqlDatabase, separatelyMonitored, cancellationToken);
/* #3754: the session exists (everywhere it could) - a fault from an earlier cycle is over. */
_longQueryTraceFault.TryRemove(server.Id, out _);
+
+ /* Azure SQL Database: drop the session from listed databases outside the monitored set, when
+ the plan's settings changed since the last pass that finished (and once after each start), or
+ when the hourly attempt after the cap is due. */
+ if (monitored is not null && (afterTheCap || !IsLongQueryTraceApplied(server.Id, enabled: true, stateKey)))
+ {
+ await DropLongQueryTraceOutsideTheSetAsync(server, monitored, separatelyMonitored, KeptElsewhere, afterTheCap, cancellationToken);
+ retry.Reset();
+ }
+
+ MarkLongQueryTraceApplied(server.Id, enabled: true, stateKey);
}
- else if (_longQueryTraceApplied.GetValueOrDefault(server.Id, true))
+ else if (afterTheCap || !IsLongQueryTraceApplied(server.Id, enabled: false, stateKey))
{
- /* Disabled and either never reconciled or previously enabled: drop once, then remember
- it is gone so the next cycles skip the connection entirely. */
- await DropLongQueryCompletionsXeSessionAsync(server, cancellationToken);
- _longQueryTraceApplied[server.Id] = false;
+ /* Disabled and either never reconciled, previously enabled, or reconciled under different
+ settings: drop, then remember it is gone so the next cycles skip the connection entirely. */
+ await DropLongQueryCompletionsXeSessionAsync(server, isAzureSqlDatabase, separatelyMonitored, KeptElsewhere, afterTheCap, cancellationToken);
+ retry.Reset();
+ MarkLongQueryTraceApplied(server.Id, enabled: false, stateKey);
/* #3754: nothing to be honest about while disabled - the collector is not dispatched - and a
fault left here would classify the first run after re-enabling before its reconcile ran. */
@@ -88,6 +154,26 @@ fault left here would classify the first run after re-enabling before its reconc
{
throw;
}
+ catch (LongQueryTraceDropException ex)
+ {
+ /* Azure SQL Database: a drop failed, or the databases could not be listed for it. The state stays
+ unapplied so the next cycle tries again, until the cap: then it counts as done, and one warning
+ names where the session may remain. After that, one attempt an hour, logged at Debug, so the
+ warning is not repeated. While enabled, the create side had already finished. */
+ switch (retry.RecordFailure(stateKey, utcNow))
+ {
+ case LongQueryTraceDropOutcome.GaveUp:
+ _longQueryTraceApplied[server.Id] = (enabled, stateKey);
+ AppLogger.Warn("XeSession", $"[{server.DisplayName}] {LongQueryTraceDatabases.GiveUpWarning(ex.Databases)}");
+ break;
+ case LongQueryTraceDropOutcome.TryAgainInAnHour:
+ AppLogger.Debug("XeSession", $"[{server.DisplayName}] {ex.Message} The next attempt is in an hour.");
+ break;
+ default:
+ AppLogger.Warn("XeSession", $"[{server.DisplayName}] {ex.Message} The next cycle tries again.");
+ break;
+ }
+ }
catch (Exception ex)
{
/* Leave the applied state unchanged so the next cycle retries; a failed reconcile must
@@ -107,23 +193,50 @@ state already buys is the whole remedy. */
///
/// Ensures the long-query completion XE session exists and is running. Server-scoped on
- /// on-prem/MI/RDS; on Azure SQL DB a database-scoped session in EVERY monitored database (#1535),
- /// matching the per-database ring-buffer read ().
+ /// on-prem/MI/RDS; on Azure SQL DB a database-scoped session in each monitored database (#1535),
+ /// except a database monitored as its own server, matching the per-database ring-buffer read
+ /// (,
+ /// ). Returns the monitored
+ /// databases on Azure SQL DB, for the drop outside that set; null on every other engine.
///
- public async Task EnsureLongQueryCompletionsXeSessionAsync(ServerConnection server, CancellationToken cancellationToken = default)
+ private async Task?> EnsureLongQueryCompletionsXeSessionAsync(
+ ServerConnection server, bool isAzureSqlDatabase, IReadOnlyList separatelyMonitored, CancellationToken cancellationToken)
{
- var engineEdition = _serverManager.GetConnectionStatus(server.Id).SqlEngineEdition;
-
- if (engineEdition == 5)
+ if (isAzureSqlDatabase)
{
- await EnsureDatabaseScopedXeSessionsAsync(
- server, "long query completions", LongQueryXeSessionName,
- EnsureLongQueryCompletionsXeSessionAzureSqlDbAsync, cancellationToken);
- return;
+ List monitored;
+ try
+ {
+ monitored = await ListLongQueryTraceDatabasesAsync(server, allDatabases: false, cancellationToken);
+ }
+ catch (SqlException ex)
+ {
+ AppLogger.Error("XeSession", $"[{server.DisplayName}] Failed to enumerate databases for long query completions XE sessions: {ex.Message}");
+ throw new XeSessionEnsureException("long query completions", ex);
+ }
+
+ var create = LongQueryTraceDatabases.Plan(enabled: true, Array.Empty(), monitored, separatelyMonitored, keptElsewhere: Array.Empty()).Create;
+
+ if (LongQueryTraceDatabaseOverrideForTests is { } createInDatabase)
+ {
+ foreach (var databaseName in create)
+ {
+ await createInDatabase(server, databaseName, true, cancellationToken);
+ }
+ }
+ else
+ {
+ await EnsureDatabaseScopedXeSessionsAsync(
+ server, "long query completions", LongQueryXeSessionName,
+ EnsureLongQueryCompletionsXeSessionAzureSqlDbAsync, create, cancellationToken);
+ }
+
+ return monitored;
}
using var connection = await CreateConnectionAsync(server, cancellationToken);
await EnsureLongQueryCompletionsXeSessionOnPremAsync(connection, server, cancellationToken);
+ return null;
}
private async Task EnsureLongQueryCompletionsXeSessionOnPremAsync(SqlConnection connection, ServerConnection server, CancellationToken cancellationToken)
@@ -209,55 +322,189 @@ FROM sys.dm_xe_database_sessions AS xes
/// Drops the long-query completion XE session (the opt-out path — disabling the collector removes
/// the server-side session, the actual busy-server cost). Idempotent: the shared DROP DDL is
/// guarded by an existence check, so a server that never had the session is a clean no-op. On Azure
- /// SQL DB the drop runs per monitored database (master skipped), matching where the sessions live.
+ /// SQL DB the drop runs in every listed database, exclusions included, except master, a database monitored as
+ /// its own server, and a database where another registration keeps the session
+ /// (). There a failure throws
+ /// after every database was tried, so the reconcile retries it.
///
- public async Task DropLongQueryCompletionsXeSessionAsync(ServerConnection server, CancellationToken cancellationToken = default)
+ private async Task DropLongQueryCompletionsXeSessionAsync(
+ ServerConnection server,
+ bool isAzureSqlDatabase,
+ IReadOnlyList separatelyMonitored,
+ Func, IReadOnlyList> keptElsewhere,
+ bool afterTheCap,
+ CancellationToken cancellationToken)
{
- var engineEdition = _serverManager.GetConnectionStatus(server.Id).SqlEngineEdition;
-
- if (engineEdition == 5)
+ if (isAzureSqlDatabase)
{
- List databases;
- try
- {
- databases = await GetAzureDatabaseListAsync(server, cancellationToken);
- }
- catch (SqlException ex)
- {
- AppLogger.Warn("XeSession", $"[{server.DisplayName}] Could not enumerate databases to drop the long-query completion XE session: {ex.Message}");
- return;
- }
+ var listed = await ListEveryLongQueryTraceDatabaseAsync(server, cancellationToken);
+ var plan = LongQueryTraceDatabases.Plan(enabled: false, listed, Array.Empty(), separatelyMonitored, keptElsewhere(listed));
+ await DropLongQueryTraceInEachAsync(server, plan.Drop, afterTheCap, cancellationToken);
+ return;
+ }
- foreach (var databaseName in databases)
- {
- cancellationToken.ThrowIfCancellationRequested();
+ using var conn = await CreateConnectionAsync(server, cancellationToken);
+ using var cmd = new SqlCommand(LongQueryCompletionsCollector.BuildDropSessionSql(databaseScoped: false), conn);
+ cmd.CommandTimeout = CommandTimeoutSeconds;
+ await cmd.ExecuteNonQueryAsync(cancellationToken);
+ AppLogger.Info("XeSession", $"[{server.DisplayName}] Long-query completion XE session reconciled OFF (collector disabled)");
+ }
- if (string.Equals(databaseName, "master", StringComparison.OrdinalIgnoreCase))
- {
- continue;
- }
+ ///
+ /// Replaces the database list the long-query trace reads on Azure SQL Database. Called with
+ /// allDatabases true for every online database, false for the monitored ones. Null in production.
+ ///
+ internal Func>>? LongQueryTraceListOverrideForTests { get; set; }
- try
+ ///
+ /// Replaces the long-query trace's work in one Azure SQL Database database: called with create true to
+ /// create the session there, false to drop it. Null in production.
+ ///
+ internal Func? LongQueryTraceDatabaseOverrideForTests { get; set; }
+
+ ///
+ /// What the last reconcile that finished applied for this server: true for on, false for off, null when no
+ /// reconcile has finished since the app started.
+ ///
+ internal bool? LongQueryTraceAppliedState(string serverId) =>
+ _longQueryTraceApplied.TryGetValue(serverId, out var applied) ? applied.Enabled : null;
+
+ ///
+ /// Replaces the clock the long-query trace's hourly attempts after the cap read ().
+ /// Null in production.
+ ///
+ internal Func? LongQueryTraceUtcNowForTests { get; set; }
+
+ /// True when the last reconcile that finished applied this enabled state under this state key.
+ private bool IsLongQueryTraceApplied(string serverId, bool enabled, string stateKey) =>
+ _longQueryTraceApplied.TryGetValue(serverId, out var applied)
+ && applied.Enabled == enabled
+ && string.Equals(applied.StateKey, stateKey, StringComparison.Ordinal);
+
+ ///
+ /// Records a reconcile that finished. The failed-pass count starts again only where a cleanup pass ran and
+ /// succeeded, so a cycle that skips the cleanup keeps the hourly attempt after the cap.
+ ///
+ private void MarkLongQueryTraceApplied(string serverId, bool enabled, string stateKey) =>
+ _longQueryTraceApplied[serverId] = (enabled, stateKey);
+
+ ///
+ /// The databases monitored as their own servers, for this Azure SQL Database registration: the same list the
+ /// blocking and deadlock alerts skip ().
+ /// Empty unless the registration is of the logical server.
+ ///
+ private IReadOnlyList SeparatelyMonitoredDatabasesFor(ServerConnection server) =>
+ KnownEngineEditions.SeparatelyMonitoredDatabases(isAzureSqlDatabase: true, server, _serverManager.GetAllServers());
+
+ ///
+ /// The registrations of this server's logical server, for : each
+ /// configured server with the same server name, with whether its long-query trace is on and its exclusions. Lite
+ /// has no database scope, so none is given.
+ ///
+ private List LongQueryTraceRegistrationsFor(ServerConnection server)
+ {
+ var hostKey = server.ServerName.Trim();
+ return _serverManager.GetAllServers()
+ .Where(other => string.Equals(other.ServerName.Trim(), hostKey, StringComparison.OrdinalIgnoreCase))
+ .Select(other => new LongQueryTraceRegistration(
+ other.Id,
+ other.ServerName,
+ other.DatabaseName,
+ other.IsEnabled,
+ TraceOn: _scheduleManager.GetScheduleForServer(other.Id, "long_query_completions")?.Enabled ?? false,
+ other.ExcludedDatabases.ToList(),
+ DatabaseScope: null))
+ .ToList();
+ }
+
+ ///
+ /// The per-database read's list without the databases monitored as their own servers, for a collector whose read
+ /// skips them (). Called only on the
+ /// Azure SQL Database per-database path.
+ ///
+ private List WithoutSeparatelyMonitoredDatabases(ServerConnection server, List databases) =>
+ AzureSweepScope.WithoutSeparatelyMonitored(databases, SeparatelyMonitoredDatabasesFor(server));
+
+ ///
+ /// Every online database, with no exclusions, for a drop. A failure to list them throws
+ /// with no names, so the reconcile retries it like a failed drop.
+ ///
+ private async Task> ListEveryLongQueryTraceDatabaseAsync(ServerConnection server, CancellationToken cancellationToken)
+ {
+ try
+ {
+ return await ListLongQueryTraceDatabasesAsync(server, allDatabases: true, cancellationToken);
+ }
+ catch (Exception ex) when (ex is not OperationCanceledException)
+ {
+ throw new LongQueryTraceDropException(Array.Empty(), ex);
+ }
+ }
+
+ ///
+ /// Trace ON on Azure SQL Database: drops the session from each listed database outside the monitored set,
+ /// except a database monitored as its own server or one where another registration keeps the session.
+ ///
+ private async Task DropLongQueryTraceOutsideTheSetAsync(
+ ServerConnection server,
+ IReadOnlyList monitored,
+ IReadOnlyList separatelyMonitored,
+ Func, IReadOnlyList> keptElsewhere,
+ bool afterTheCap,
+ CancellationToken cancellationToken)
+ {
+ var listed = await ListEveryLongQueryTraceDatabaseAsync(server, cancellationToken);
+ var plan = LongQueryTraceDatabases.Plan(enabled: true, listed, monitored, separatelyMonitored, keptElsewhere(listed));
+ await DropLongQueryTraceInEachAsync(server, plan.Drop, afterTheCap, cancellationToken);
+ }
+
+ ///
+ /// Drops the session in each database, tries every one, and logs a warning that names each database where the
+ /// drop failed (). The hourly attempt after the cap logs it at
+ /// Debug instead, so the cap's one warning is not repeated.
+ ///
+ private Task DropLongQueryTraceInEachAsync(ServerConnection server, IReadOnlyList databases, bool afterTheCap, CancellationToken cancellationToken) =>
+ LongQueryTraceDatabases.DropEachAsync(
+ databases,
+ (databaseName, token) => DropLongQueryTraceInDatabaseAsync(server, databaseName, token),
+ (databaseName, ex) =>
+ {
+ var line = $"[{server.DisplayName}] [{databaseName}] Could not drop the long-query completion XE session: {ex.Message}";
+ if (afterTheCap)
{
- using var connection = await OpenAzureDatabaseConnectionAsync(server, databaseName, cancellationToken);
- using var dropCmd = new SqlCommand(LongQueryCompletionsCollector.BuildDropSessionSql(databaseScoped: true), connection);
- dropCmd.CommandTimeout = CommandTimeoutSeconds;
- await dropCmd.ExecuteNonQueryAsync(cancellationToken);
+ AppLogger.Debug("XeSession", line);
}
- catch (Exception ex) when (ex is not OperationCanceledException)
+ else
{
- AppLogger.Debug("XeSession", $"[{server.DisplayName}] [{databaseName}] Could not drop the long-query completion XE session: {ex.Message}");
+ AppLogger.Warn("XeSession", line);
}
- }
+ },
+ createNote: null,
+ cancellationToken);
+ ///
+ /// The databases the long-query trace works in on Azure SQL Database. With , every
+ /// online database with no exclusions; otherwise the monitored ones. A registration that names a database gets
+ /// that database either way.
+ ///
+ private async Task> ListLongQueryTraceDatabasesAsync(ServerConnection server, bool allDatabases, CancellationToken cancellationToken) =>
+ LongQueryTraceListOverrideForTests is { } listOverride
+ ? await listOverride(server, allDatabases, cancellationToken)
+ : await GetAzureDatabaseListAsync(server, applyExclusions: !allDatabases, cancellationToken);
+
+ /// Drops the long-query trace's database-scoped session in one Azure SQL Database database.
+ private async Task DropLongQueryTraceInDatabaseAsync(ServerConnection server, string databaseName, CancellationToken cancellationToken)
+ {
+ if (LongQueryTraceDatabaseOverrideForTests is { } dropInDatabase)
+ {
+ await dropInDatabase(server, databaseName, false, cancellationToken);
return;
}
- using var conn = await CreateConnectionAsync(server, cancellationToken);
- using var cmd = new SqlCommand(LongQueryCompletionsCollector.BuildDropSessionSql(databaseScoped: false), conn);
- cmd.CommandTimeout = CommandTimeoutSeconds;
- await cmd.ExecuteNonQueryAsync(cancellationToken);
- AppLogger.Info("XeSession", $"[{server.DisplayName}] Long-query completion XE session reconciled OFF (collector disabled)");
+ using var connection = await OpenAzureDatabaseConnectionAsync(server, databaseName, cancellationToken);
+ using var dropCmd = new SqlCommand(LongQueryCompletionsCollector.BuildDropSessionSql(databaseScoped: true), connection);
+ dropCmd.CommandTimeout = CommandTimeoutSeconds;
+ await dropCmd.ExecuteNonQueryAsync(cancellationToken);
}
///
diff --git a/Lite/Services/RemoteCollectorService.cs b/Lite/Services/RemoteCollectorService.cs
index f1685c99dd..b3d847bedb 100644
--- a/Lite/Services/RemoteCollectorService.cs
+++ b/Lite/Services/RemoteCollectorService.cs
@@ -1049,7 +1049,15 @@ internal void NoteServerOnline(ServerConnection server)
/// server into whichever registration ran the sweep — N registrations of N databases meant N² collection
/// with every registration's history contaminated by its siblings'.
///
- protected async Task> GetAzureDatabaseListAsync(ServerConnection server, CancellationToken cancellationToken)
+ protected Task> GetAzureDatabaseListAsync(ServerConnection server, CancellationToken cancellationToken) =>
+ GetAzureDatabaseListAsync(server, applyExclusions: true, cancellationToken);
+
+ ///
+ /// , with a choice about the server's
+ /// excluded databases. The long-query trace's drop passes false: a session
+ /// created before a database was excluded must still be dropped there.
+ ///
+ protected async Task> GetAzureDatabaseListAsync(ServerConnection server, bool applyExclusions, CancellationToken cancellationToken)
{
var serverId = GetServerId(server);
var baseConnStr = _serverManager.CredentialResolver.GetConnectionString(server);
@@ -1105,14 +1113,12 @@ to be reached.
return await RetryHelper.ExecuteWithRetryAsync(
async () =>
{
- var (exclusionClause, exclusionParams) = BuildDatabaseExclusionFilter(server.ExcludedDatabases, "name");
+ var (listSql, exclusionParams) = BuildAzureDatabaseListQuery(server, applyExclusions);
var databases = new List();
using var conn = new SqlConnection(connStr);
await conn.OpenAsync(cancellationToken);
- using var cmd = new SqlCommand(
- $"SELECT name FROM sys.databases WHERE state_desc = N'ONLINE' AND database_id > 0 {exclusionClause} ORDER BY name;",
- conn)
+ using var cmd = new SqlCommand(listSql, conn)
{ CommandTimeout = CommandTimeoutSeconds };
foreach (var p in exclusionParams) cmd.Parameters.Add(p);
using var reader = await cmd.ExecuteReaderAsync(cancellationToken);
@@ -1134,6 +1140,20 @@ to be reached.
}
}
+ ///
+ /// The enumeration query behind , with its
+ /// parameters. The server's excluded databases are in it only when is true, so a drop that
+ /// passes false lists the databases the registration excludes too. Built fresh on each call: a SqlParameter cannot be added
+ /// to a second SqlCommand, so the retry builds its own. Its own member so a test reads the query, not a stand-in for the list.
+ ///
+ internal static (string Sql, List Parameters) BuildAzureDatabaseListQuery(ServerConnection server, bool applyExclusions)
+ {
+ var (exclusionClause, exclusionParams) = BuildDatabaseExclusionFilter(applyExclusions ? server.ExcludedDatabases : null, "name");
+ return (
+ $"SELECT name FROM sys.databases WHERE state_desc = N'ONLINE' AND database_id > 0 {exclusionClause} ORDER BY name;",
+ exclusionParams);
+ }
+
///
/// True while a recent master-inaccessible verdict still stands. The verdict expires so that a
/// server whose access was restored (or whose login was granted master rights) recovers on its
diff --git a/Lite/Services/ScheduleManager.cs b/Lite/Services/ScheduleManager.cs
index 96fb8cdbec..58d852cebb 100644
--- a/Lite/Services/ScheduleManager.cs
+++ b/Lite/Services/ScheduleManager.cs
@@ -751,7 +751,7 @@ internal static List GetDefaultSchedules()
new() { Name = "waiting_tasks", Enabled = true, FrequencyMinutes = 1, RetentionDays = 7, Description = "Point-in-time waiting tasks from sys.dm_os_waiting_tasks" },
new() { Name = "dmv_blocking_snapshot", Enabled = true, FrequencyMinutes = 1, RetentionDays = 30, Description = "Always-on point-in-time blocking snapshot from DMVs (BPR-independent fallback; works when blocked process threshold is unset, e.g. AWS RDS)" },
new() { Name = "blocked_process_report", Enabled = true, FrequencyMinutes = 1, RetentionDays = 30, Description = "Blocked process reports from XE ring buffer session (opt-out)" },
- new() { Name = "long_query_completions", Enabled = false, FrequencyMinutes = 1, RetentionDays = 30, Description = "Long-running query completions (rpc_completed/sql_batch_completed >= threshold, plus attention/cancels) from a dedicated XE ring buffer session. OPT-IN, default OFF: enabling creates the session on monitored servers, disabling drops it (#1496)" },
+ new() { Name = "long_query_completions", Enabled = false, FrequencyMinutes = 1, RetentionDays = 30, Description = "Long-running query completions (rpc_completed/sql_batch_completed >= threshold, plus attention/cancels) from a dedicated XE ring buffer session. OPT-IN, default OFF: enabling creates the session on monitored servers, disabling drops it (#1496). On Azure SQL Database the session is created in each monitored database." },
new() { Name = "database_scoped_config", Enabled = true, FrequencyMinutes = 0, RetentionDays = 30, Description = "Database-scoped configurations (on-load only)" },
new() { Name = "trace_flags", Enabled = true, FrequencyMinutes = 0, RetentionDays = 30, Description = "Active trace flags via DBCC TRACESTATUS (on-load only)" },
new() { Name = "running_jobs", Enabled = true, FrequencyMinutes = 5, RetentionDays = 7, Description = "Currently running SQL Agent jobs with duration comparison" },
diff --git a/Lite/config/collection_schedule.json b/Lite/config/collection_schedule.json
index 9d0acdc1be..30242c41d5 100644
--- a/Lite/config/collection_schedule.json
+++ b/Lite/config/collection_schedule.json
@@ -17,7 +17,7 @@
{ "name": "memory_grant_stats", "enabled": true, "frequency_minutes": 1, "retention_days": 30, "description": "Memory grant statistics from sys.dm_exec_query_memory_grants" },
{ "name": "waiting_tasks", "enabled": true, "frequency_minutes": 1, "retention_days": 7, "description": "Point-in-time waiting tasks from sys.dm_os_waiting_tasks" },
{ "name": "blocked_process_report", "enabled": true, "frequency_minutes": 1, "retention_days": 30, "description": "Blocked process reports from XE ring buffer session (opt-out)" },
- { "name": "long_query_completions", "enabled": false, "frequency_minutes": 1, "retention_days": 30, "description": "Long-running query completions from a dedicated XE ring buffer session. OPT-IN, default OFF (#1496)" },
+ { "name": "long_query_completions", "enabled": false, "frequency_minutes": 1, "retention_days": 30, "description": "Long-running query completions from a dedicated XE ring buffer session. OPT-IN, default OFF (#1496). On Azure SQL Database the session is created in each monitored database." },
{ "name": "database_scoped_config", "enabled": true, "frequency_minutes": 0, "retention_days": 30, "description": "Database-scoped configurations (on-load only)" },
{ "name": "trace_flags", "enabled": true, "frequency_minutes": 0, "retention_days": 30, "description": "Active trace flags via DBCC TRACESTATUS (on-load only)" },
{ "name": "running_jobs", "enabled": true, "frequency_minutes": 5, "retention_days": 7, "description": "Currently running SQL Agent jobs with duration comparison" },
diff --git a/PerformanceMonitor.Collectors/AzureSweepScope.cs b/PerformanceMonitor.Collectors/AzureSweepScope.cs
index d12a36626f..2cffab2933 100644
--- a/PerformanceMonitor.Collectors/AzureSweepScope.cs
+++ b/PerformanceMonitor.Collectors/AzureSweepScope.cs
@@ -41,6 +41,22 @@ namespace PerformanceMonitor.Collectors;
///
public static class AzureSweepScope
{
+ ///
+ /// The listed databases without the ones monitored as their own servers, for a collector whose read skips them
+ /// (). Returns the list itself when
+ /// there is nothing to skip.
+ ///
+ public static List WithoutSeparatelyMonitored(List databases, IReadOnlyCollection separatelyMonitored)
+ {
+ if (separatelyMonitored.Count == 0)
+ {
+ return databases;
+ }
+
+ var skip = new HashSet(separatelyMonitored, StringComparer.OrdinalIgnoreCase);
+ return databases.FindAll(database => !skip.Contains(database));
+ }
+
///
/// The single database this registration names, or an EMPTY list when it names none.
///
diff --git a/PerformanceMonitor.Collectors/CollectorDefinitionBase.cs b/PerformanceMonitor.Collectors/CollectorDefinitionBase.cs
index 852af08dcf..ea95916a55 100644
--- a/PerformanceMonitor.Collectors/CollectorDefinitionBase.cs
+++ b/PerformanceMonitor.Collectors/CollectorDefinitionBase.cs
@@ -81,6 +81,8 @@ so without this a derived definition could not `override` it and the interface m
public virtual bool RunsPerDatabase(CollectorTargetInfo target) => false;
+ public virtual bool SkipsSeparatelyMonitoredDatabases => false;
+
public abstract CollectorQuery BuildQuery(CollectorContext context);
public virtual CollectorQuery? BuildSupplementalQuery(CollectorContext context) => null;
diff --git a/PerformanceMonitor.Collectors/CollectorScheduleDefaults.cs b/PerformanceMonitor.Collectors/CollectorScheduleDefaults.cs
index f44c8a10fd..1cf8706c8c 100644
--- a/PerformanceMonitor.Collectors/CollectorScheduleDefaults.cs
+++ b/PerformanceMonitor.Collectors/CollectorScheduleDefaults.cs
@@ -74,7 +74,8 @@ database_config snapshot (frequency 0), this feeds the "database offline / unhea
/* #1496 long-running query completion trace: seeded DISABLED (DefaultEnabled: false). A
completion trace (rpc_completed/sql_batch_completed) fires per statement/batch even though
the duration predicate discards most of them, so it is opt-in per fleet; enabling it creates
- the XE session on the monitored servers and disabling it DROPS the session there. Cadence +
+ the XE session on the monitored servers (in each monitored database on Azure SQL Database)
+ and disabling it DROPS the session there. Cadence +
retention mirror the sibling XE collectors. */
["long_query_completions"] = new(1, 30, DefaultEnabled: false),
["database_scoped_config"] = new(0, 30),
diff --git a/PerformanceMonitor.Collectors/ICollectorDefinition.cs b/PerformanceMonitor.Collectors/ICollectorDefinition.cs
index be186cb6f5..88aee30045 100644
--- a/PerformanceMonitor.Collectors/ICollectorDefinition.cs
+++ b/PerformanceMonitor.Collectors/ICollectorDefinition.cs
@@ -45,6 +45,14 @@ public interface ICollectorDefinition : ICollectorSchemaInfo
///
bool RunsPerDatabase(CollectorTargetInfo target);
+ ///
+ /// True when the per-database read on an Azure SQL Database logical server skips the databases monitored as
+ /// their own servers (AzureMasterScope.SeparatelyMonitoredDatabases). The long-query trace does: those
+ /// registrations own the trace's session in their database, so the logical server's registration neither
+ /// manages nor reads it. False for every other collector.
+ ///
+ bool SkipsSeparatelyMonitoredDatabases => false;
+
///
/// Time column the host should read its latest already-collected value of (from the host's
/// own store) before building the query — exposed to the definition as
diff --git a/PerformanceMonitor.Collectors/LongQueryCompletionsCollector.cs b/PerformanceMonitor.Collectors/LongQueryCompletionsCollector.cs
index 2a578c31b1..cbdb86d96a 100644
--- a/PerformanceMonitor.Collectors/LongQueryCompletionsCollector.cs
+++ b/PerformanceMonitor.Collectors/LongQueryCompletionsCollector.cs
@@ -135,6 +135,23 @@ public sealed class Row
///
public override bool RunsPerDatabase(CollectorTargetInfo target) => target.IsAzureSqlDb;
+ ///
+ /// The read covers the databases the session lifecycle covers (). A database
+ /// monitored as its own server owns its session, so the logical server's registration skips it here too. While that
+ /// database's own trace is off it has no session: Lite's read fails there every cycle (#4731), and Darling's reads
+ /// zero rows that look like a quiet trace. While it is on, reading it would store its events twice.
+ ///
+ public override bool SkipsSeparatelyMonitoredDatabases => true;
+
+ ///
+ /// The last sentence of the banner that Lite and the Darling Viewer show while this trace is off: where turning
+ /// it on creates the Extended Events session, and where turning it off drops it. On Azure SQL Database that is
+ /// each monitored database (); on every other engine it is the server.
+ ///
+ public static string SessionScopeSentence(bool isAzureSqlDatabase) => isAzureSqlDatabase
+ ? "Enabling it creates the Extended Events session in each monitored database. Disabling it drops the session from every database that has it, except where another registration of that database still has the trace on. A database that is also monitored as its own server follows that server's setting."
+ : "Enabling it creates the Extended Events session on this server. Disabling it drops the session.";
+
///
/// Per-database watermark for the per-database sessions: each database's ring buffer dispatches
/// independently, so one database's newer completion must not watermark past another's older one.
diff --git a/PerformanceMonitor.Collectors/LongQueryTraceDatabases.cs b/PerformanceMonitor.Collectors/LongQueryTraceDatabases.cs
new file mode 100644
index 0000000000..cfd15c81e2
--- /dev/null
+++ b/PerformanceMonitor.Collectors/LongQueryTraceDatabases.cs
@@ -0,0 +1,442 @@
+/*
+ * 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.Threading;
+using System.Threading.Tasks;
+
+namespace PerformanceMonitor.Collectors;
+
+///
+/// Where the opt-in long-query trace keeps its Extended Events session on Azure SQL Database, where each database
+/// holds its own session. Lite and Darling both plan every create and drop here, so the two apps touch the same
+/// databases.
+///
+/// Trace ON: create the session in each monitored database, and drop it from each listed database outside
+/// that set. The trace costs overhead, so it must not run in a database the user left out.
+/// Trace OFF: drop it from every listed database. That list is the full one, with no exclusions and no
+/// database scope, so a session created before a database was left out is removed too.
+/// Both: never touch master, which cannot hold a database-scoped session, or a database monitored as
+/// its own server (AzureMasterScope.SeparatelyMonitoredDatabases). That database's own registration creates
+/// and drops its session, so the logical server's registration leaves it alone.
+/// A drop also skips a database while another registration of the same logical server has the trace on and
+/// keeps the session there (), whatever either registration's read-only intent. The
+/// session is one object per database, so a drop by one registration removes it for every other one.
+///
+/// The always-on deadlock and blocked-process sessions do not use this. They follow the inventory: they are
+/// created in every inventoried database whatever the database scope, because they are cheap and the alerts read
+/// them.
+///
+public static class LongQueryTraceDatabases
+{
+ ///
+ /// Failed cleanup passes in a row, for one registration, before it stops retrying every cycle. Without a cap, a
+ /// database that refuses the drop forever would repeat a warning every cycle forever. After the cap, the
+ /// registration tries again once every .
+ ///
+ public const int DropAttemptCap = 5;
+
+ ///
+ /// The wait between the attempts that follow the cap (). Darling also re-runs
+ /// the create side this often while the trace is on, so a session dropped from outside comes back.
+ ///
+ public static readonly TimeSpan RetryInterval = TimeSpan.FromHours(1);
+
+ /* Characters no database name can contain, one for each level of a state key, so a key cannot read two ways:
+ names within one list, the lists inside one co-owner, the co-owners, and the key's parts. */
+ private const char NameSeparator = '\u001F';
+ private const char ListSeparator = '\u001D';
+ private const char CoOwnerSeparator = '\u001C';
+ private const char PartSeparator = '\u001E';
+
+ ///
+ /// The databases one reconcile creates the session in and drops it from.
+ ///
+ /// Whether the trace is on for this registration.
+ /// Every online database the registration can see, with no exclusions and no
+ /// database scope. For a registration that names a database, that database alone.
+ /// The databases the registration monitors: the listed ones minus its
+ /// exclusions and, in Darling, narrowed by the trace's database scope. Read only while the trace is on.
+ /// The databases monitored as their own servers.
+ /// The databases where another registration keeps the session
+ /// (). Applied to the drops only: a create never removes anything.
+ public static LongQueryTracePlan Plan(
+ bool enabled,
+ IEnumerable listedDatabases,
+ IEnumerable monitoredDatabases,
+ IEnumerable separatelyMonitoredDatabases,
+ IEnumerable keptElsewhere)
+ {
+ var ownedElsewhere = new HashSet(separatelyMonitoredDatabases, StringComparer.OrdinalIgnoreCase);
+ var kept = new HashSet(keptElsewhere, StringComparer.OrdinalIgnoreCase);
+ bool CanTouch(string database) =>
+ !string.Equals(database, "master", StringComparison.OrdinalIgnoreCase)
+ && !ownedElsewhere.Contains(database);
+
+ if (!enabled)
+ {
+ return new LongQueryTracePlan(
+ Array.Empty(),
+ DistinctInOrder(listedDatabases.Where(database => CanTouch(database) && !kept.Contains(database))));
+ }
+
+ var create = DistinctInOrder(monitoredDatabases.Where(CanTouch));
+ var keep = new HashSet(monitoredDatabases, StringComparer.OrdinalIgnoreCase);
+ var drop = DistinctInOrder(listedDatabases.Where(database => CanTouch(database) && !keep.Contains(database) && !kept.Contains(database)));
+ return new LongQueryTracePlan(create, drop);
+ }
+
+ ///
+ /// The settings a plan depends on, apart from the database lists. When this changes, the last reconcile no
+ /// longer says where the session belongs, so the reconcile runs again. While the trace is on that is the
+ /// database scope, the exclusions, the separately monitored databases and the co-owners. While it is off the
+ /// plan ignores the scope and the exclusions, so only the separately monitored databases and the co-owners
+ /// count. The co-owners are in the key so that a drop skipped for another registration runs once that one
+ /// turns its trace off.
+ ///
+ /// The other registrations that keep the session somewhere on this logical server
+ /// ().
+ public static string StateKey(
+ bool enabled,
+ IEnumerable? databaseScope,
+ IEnumerable? excludedDatabases,
+ IEnumerable separatelyMonitoredDatabases,
+ IEnumerable coOwners)
+ {
+ var owned = JoinNames(separatelyMonitoredDatabases);
+ var others = string.Join(CoOwnerSeparator, coOwners.Distinct(StringComparer.Ordinal).OrderBy(owner => owner, StringComparer.Ordinal));
+ return enabled
+ ? string.Join(PartSeparator, "on", JoinNames(databaseScope), JoinNames(excludedDatabases), owned, others)
+ : string.Join(PartSeparator, "off", owned, others);
+ }
+
+ ///
+ /// The candidates where another registration of the same logical server keeps the session, so a drop by this
+ /// registration would remove a session that one still uses. Another registration counts when it is enabled,
+ /// has the trace on, and names the same host (trimmed, ignoring case, as AzureMasterScope matches),
+ /// whatever its read-only intent:
+ ///
+ /// A registration that names a database keeps the session in that database.
+ /// A logical server's registration keeps it in each database it creates it in: not master, not a
+ /// database monitored as its own server, not one it excludes, and inside its database scope.
+ ///
+ /// The coverage is exact. Two logical server registrations that both exclude a database do not keep the session
+ /// there for each other, so a session left in that database is still dropped.
+ ///
+ /// This registration, which never counts as another.
+ /// This registration's host.
+ /// The databases this registration would drop the session from.
+ /// Every registration the app monitors. The others are filtered here.
+ /// The databases monitored as their own servers on this logical server, as a
+ /// logical server's registration sees them: the databases its creates leave out. The same list for every
+ /// registration of the server, including one that names a database.
+ public static IReadOnlyList KeptElsewhere(
+ string selfId,
+ string host,
+ IEnumerable candidates,
+ IEnumerable registrations,
+ IEnumerable separatelyMonitored)
+ {
+ var owners = OtherOwners(selfId, host, registrations).ToList();
+ if (owners.Count == 0)
+ {
+ return Array.Empty();
+ }
+
+ var ownedElsewhere = new HashSet(separatelyMonitored, StringComparer.OrdinalIgnoreCase);
+ return DistinctInOrder(candidates.Where(database => owners.Any(owner => Keeps(owner, database, ownedElsewhere))));
+ }
+
+ ///
+ /// The co-owner part of : one entry for each other registration that
+ /// counts, holding what decides where it keeps the session. A registration that names
+ /// a database gives that database. A logical server's registration gives its exclusions, its database scope and
+ /// .
+ ///
+ public static IReadOnlyList CoOwners(
+ string selfId,
+ string host,
+ IEnumerable registrations,
+ IEnumerable separatelyMonitored)
+ {
+ var owned = JoinNames(separatelyMonitored);
+ return OtherOwners(selfId, host, registrations)
+ .Select(owner => IsLogicalServer(owner.Database)
+ ? "srv:" + string.Join(ListSeparator, JoinNames(owner.ExcludedDatabases), JoinNames(owner.DatabaseScope), owned)
+ : "db:" + owner.Database!.Trim().ToUpperInvariant())
+ .Distinct(StringComparer.Ordinal)
+ .OrderBy(owner => owner, StringComparer.Ordinal)
+ .ToList();
+ }
+
+ ///
+ /// Drops the session in each database, trying every one before it gives up, so one database that refuses
+ /// cannot keep the session in the others. When any drop failed, throws one
+ /// that names each database where it failed.
+ ///
+ /// The databases to drop the session from (a plan's ).
+ /// Drops the session in one database.
+ /// Called once for each database whose drop failed, before the next one is tried. The
+ /// apps log a warning that names the database here.
+ /// Trace ON only: the create side's note about databases it could not create the
+ /// session in, carried on the exception so a failed drop does not lose it.
+ /// Cancels the remaining drops.
+ public static async Task DropEachAsync(
+ IReadOnlyList databases,
+ Func dropOne,
+ Action onFailure,
+ string? createNote,
+ CancellationToken cancellationToken)
+ {
+ var failed = new List();
+ Exception? firstFailure = null;
+
+ foreach (var database in databases)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ try
+ {
+ await dropOne(database, cancellationToken);
+ }
+ catch (Exception ex) when (ex is not OperationCanceledException)
+ {
+ failed.Add(database);
+ firstFailure ??= ex;
+ onFailure(database, ex);
+ }
+ }
+
+ if (firstFailure is not null)
+ {
+ throw new LongQueryTraceDropException(failed, firstFailure, createNote);
+ }
+ }
+
+ ///
+ /// The one warning a registration logs when it stops retrying every cycle (). It names
+ /// each database where the session may remain. When the last pass could not list the databases there are no
+ /// names to give, and the warning says so. adds what else makes this app try again.
+ ///
+ public static string GiveUpWarning(IReadOnlyList databases, string? retryNote = null) => (databases.Count == 0
+ ? $"Stopped retrying the long-query trace cleanup after {DropAttemptCap} failed attempts in a row. The databases could not be listed, so the session may remain in any of them."
+ : $"Stopped retrying the long-query trace cleanup after {DropAttemptCap} failed attempts in a row. The session may remain in: {string.Join(", ", databases)}.")
+ + " It tries again once an hour, and right away after a restart, a change to the trace's settings, or a change to another registration of these databases."
+ + retryNote;
+
+ private static IEnumerable OtherOwners(
+ string selfId, string host, IEnumerable registrations)
+ {
+ var hostKey = host.Trim();
+ return registrations.Where(other =>
+ other.Id != selfId
+ && other.Enabled
+ && other.TraceOn
+ && string.Equals(other.Host.Trim(), hostKey, StringComparison.OrdinalIgnoreCase));
+ }
+
+ private static bool Keeps(LongQueryTraceRegistration owner, string database, HashSet separatelyMonitored)
+ {
+ if (!IsLogicalServer(owner.Database))
+ {
+ return string.Equals(owner.Database!.Trim(), database, StringComparison.OrdinalIgnoreCase);
+ }
+
+ return !string.Equals(database, "master", StringComparison.OrdinalIgnoreCase)
+ && !separatelyMonitored.Contains(database)
+ && !owner.ExcludedDatabases.Any(excluded => string.Equals(excluded.Trim(), database, StringComparison.OrdinalIgnoreCase))
+ && (owner.DatabaseScope is null
+ || owner.DatabaseScope.Count == 0
+ || owner.DatabaseScope.Any(inScope => string.Equals(inScope.Trim(), database, StringComparison.OrdinalIgnoreCase)));
+ }
+
+ /* A registration with no database, or with master, is of the logical server (AzureMasterScope's rule). */
+ private static bool IsLogicalServer(string? database) =>
+ string.IsNullOrWhiteSpace(database) || string.Equals(database.Trim(), "master", StringComparison.OrdinalIgnoreCase);
+
+ private static string JoinNames(IEnumerable? names) =>
+ names is null
+ ? string.Empty
+ : string.Join(NameSeparator, names
+ .Select(name => name.Trim())
+ .Where(name => name.Length > 0)
+ .Select(name => name.ToUpperInvariant())
+ .Distinct(StringComparer.Ordinal)
+ .OrderBy(name => name, StringComparer.Ordinal));
+
+ private static List DistinctInOrder(IEnumerable names)
+ {
+ var seen = new HashSet(StringComparer.OrdinalIgnoreCase);
+ var result = new List();
+ foreach (var name in names)
+ {
+ if (seen.Add(name))
+ {
+ result.Add(name);
+ }
+ }
+
+ return result;
+ }
+}
+
+/// What one long-query trace reconcile does on Azure SQL Database ().
+/// The databases to create the session in. Empty while the trace is off.
+/// The databases to drop the session from.
+public sealed record LongQueryTracePlan(IReadOnlyList Create, IReadOnlyList Drop);
+
+///
+/// One registration the app monitors, as sees it: enough to say
+/// where it keeps the long-query trace's session on an Azure SQL Database logical server.
+///
+/// The registration's id in its app.
+/// The server name it connects to.
+/// The database it names. Blank or master for the logical server's registration.
+/// Whether it is monitored.
+/// Whether its long-query trace is on.
+/// The databases it excludes.
+/// The trace's database scope. Null or empty means every database.
+public sealed record LongQueryTraceRegistration(
+ string Id,
+ string Host,
+ string? Database,
+ bool Enabled,
+ bool TraceOn,
+ IReadOnlyCollection ExcludedDatabases,
+ IReadOnlyCollection? DatabaseScope);
+
+///
+/// What a failed cleanup pass leads to ().
+///
+public enum LongQueryTraceDropOutcome
+{
+ /// Under the cap: the next cycle tries again, and the caller logs the failure as a warning.
+ TryAgainNextCycle,
+
+ ///
+ /// The last pass the cap allows: the caller marks the reconcile done and logs
+ /// once. The next attempt comes an hour later.
+ ///
+ GaveUp,
+
+ /// An attempt after the cap failed: the caller logs it at Debug, and the next attempt comes an hour later.
+ TryAgainInAnHour,
+}
+
+///
+/// Counts one registration's failed cleanup passes in a row, for ,
+/// and keeps the clock for the attempts after the cap, one every .
+/// The count belongs to one state key (): a pass that succeeds starts
+/// it again, and so does any change to the key, such as turning the trace on or off. The caller passes the time.
+///
+public sealed class LongQueryTraceDropRetry
+{
+ private readonly object _gate = new();
+ private string? _stateKey;
+ private int _failures;
+ private DateTime? _nextAttemptUtc;
+
+ /// When the next attempt after the cap is due. Null until the cap gives up under the current key.
+ public DateTime? NextAttemptUtc
+ {
+ get
+ {
+ lock (_gate)
+ {
+ return _nextAttemptUtc;
+ }
+ }
+ }
+
+ /// Records a failed pass under this state key, and says what the caller does next.
+ public LongQueryTraceDropOutcome RecordFailure(string stateKey, DateTime utcNow)
+ {
+ lock (_gate)
+ {
+ if (!string.Equals(_stateKey, stateKey, StringComparison.Ordinal))
+ {
+ _stateKey = stateKey;
+ _failures = 0;
+ _nextAttemptUtc = null;
+ }
+
+ if (_nextAttemptUtc is not null)
+ {
+ _nextAttemptUtc = utcNow + LongQueryTraceDatabases.RetryInterval;
+ return LongQueryTraceDropOutcome.TryAgainInAnHour;
+ }
+
+ _failures++;
+ if (_failures < LongQueryTraceDatabases.DropAttemptCap)
+ {
+ return LongQueryTraceDropOutcome.TryAgainNextCycle;
+ }
+
+ _failures = 0;
+ _nextAttemptUtc = utcNow + LongQueryTraceDatabases.RetryInterval;
+ return LongQueryTraceDropOutcome.GaveUp;
+ }
+ }
+
+ ///
+ /// True when the cap gave up under this state key and the next attempt is due. The caller then runs the cleanup
+ /// pass again, although the reconcile is marked done.
+ ///
+ public bool RetryDue(string stateKey, DateTime utcNow)
+ {
+ lock (_gate)
+ {
+ return _nextAttemptUtc is { } next
+ && utcNow >= next
+ && string.Equals(_stateKey, stateKey, StringComparison.Ordinal);
+ }
+ }
+
+ ///
+ /// Starts the count again and stops the attempts after the cap: after a cleanup pass that succeeds, and in Darling
+ /// after every reconnect.
+ ///
+ public void Reset()
+ {
+ lock (_gate)
+ {
+ _stateKey = null;
+ _failures = 0;
+ _nextAttemptUtc = null;
+ }
+ }
+}
+
+///
+/// A long-query trace reconcile that could not finish its drops on Azure SQL Database: the database list could not
+/// be read, or the drop failed in some databases (the others were still dropped). The caller does not mark the
+/// reconcile done, so the next cycle tries again, up to passes
+/// in a row, and then once an hour ().
+///
+public sealed class LongQueryTraceDropException : Exception
+{
+ /// Creates the exception for the given databases. An empty list means the databases could not be listed.
+ public LongQueryTraceDropException(IReadOnlyList databases, Exception firstFailure, string? createNote = null)
+ : base(Describe(databases, firstFailure), firstFailure)
+ {
+ Databases = databases;
+ CreateNote = createNote;
+ }
+
+ /// The databases where the drop failed, so the session may remain there. Empty when the list could not be read.
+ public IReadOnlyList Databases { get; }
+
+ /// Trace ON only: the create side's note about databases it could not create the session in.
+ public string? CreateNote { get; }
+
+ private static string Describe(IReadOnlyList databases, Exception firstFailure) => databases.Count == 0
+ ? $"Could not list the databases to drop the long-query trace session from: {firstFailure.Message}"
+ : $"Could not drop the long-query trace session in {databases.Count} database(s) ({string.Join(", ", databases)}): {firstFailure.Message}";
+}
From ea91586f1ae3339077f0122f417f63c22c6ffcbd Mon Sep 17 00:00:00 2001
From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com>
Date: Fri, 2 Oct 2026 18:55:46 -0400
Subject: [PATCH 008/399] Darling measures its rollup floors once per store and
in the background, so the first daily summary no longer waits on them (#4968)
On a store with a long Query Store history, the probe that measures how far back each rollup reaches took about 10 seconds, and every data source paid it on its own: at start, after a failed probe, and once an hour. The floors are now measured once per store (a database recreated under the same name is measured again), the service and the Viewer measure them in the background shortly after start, and a floor between one and two hours old is served from the cache while one background pass measures it again. A floor two hours old or older is measured by the caller, so no floor is served once it is two hours old.
Closes #4957.
---
.../RollupCoverageCeilingLiveTests.cs | 15 +-
.../RollupCoverageWarmupTests.cs | 50 +++
.../RollupFloorCacheLiveTests.cs | 332 ++++++++++++++++++
.../RollupFloorStoreCacheTests.cs | 213 +++++++++++
.../DarlingWorker.cs | 11 +
.../RollupCoverageWarmup.cs | 77 ++++
.../TimescaleSupport.cs | 292 +++++++++++++--
.../MainWindow.xaml.cs | 5 +
.../ViewerDataService.RollupAvailability.cs | 11 +
9 files changed, 966 insertions(+), 40 deletions(-)
create mode 100644 Darling/Darling.Tests/RollupCoverageWarmupTests.cs
create mode 100644 Darling/Darling.Tests/RollupFloorStoreCacheTests.cs
create mode 100644 Darling/PerformanceMonitor.Darling.Storage/RollupCoverageWarmup.cs
diff --git a/Darling/Darling.Tests/RollupCoverageCeilingLiveTests.cs b/Darling/Darling.Tests/RollupCoverageCeilingLiveTests.cs
index c3846eaed9..271dc0ae4b 100644
--- a/Darling/Darling.Tests/RollupCoverageCeilingLiveTests.cs
+++ b/Darling/Darling.Tests/RollupCoverageCeilingLiveTests.cs
@@ -112,8 +112,21 @@ RollupFloorMaxReuse documents. ── */
Assert.NotNull(laterWatermark);
Assert.True(laterWatermark > engineWatermark, "the second refresh must move the engine's own watermark later");
+ /* #4957: past the hour with the oldest chunk unchanged, the caller is served the cached floor AND ceiling and one
+ background re-measure replaces them, so the first call after the hour still reads the old ceiling and the
+ ceiling advances once that re-measure lands. (Before #4957 this call measured inline.) */
var pastTtl = now + TimescaleSupport.RollupFloorMaxReuse + TimeSpan.FromMinutes(1);
- var secondCall = await TimescaleSupport.DetectRollupCoverageAsync(dataSource, availability, pastTtl, ct);
+ var servedFromCache = await TimescaleSupport.DetectRollupCoverageAsync(dataSource, availability, pastTtl, ct);
+ Assert.Equal(firstCall.CeilingOf(view), servedFromCache.CeilingOf(view));
+
+ var secondCall = servedFromCache;
+ var deadline = DateTime.UtcNow.AddSeconds(30);
+ while (secondCall.CeilingOf(view) != laterWatermark && DateTime.UtcNow < deadline)
+ {
+ await Task.Delay(50, ct);
+ secondCall = await TimescaleSupport.DetectRollupCoverageAsync(dataSource, availability, pastTtl, ct);
+ }
+
Assert.Equal(laterWatermark, secondCall.CeilingOf(view));
Assert.True(secondCall.CeilingOf(view) > firstCall.CeilingOf(view), "the ceiling must advance once the rollup materializes further");
diff --git a/Darling/Darling.Tests/RollupCoverageWarmupTests.cs b/Darling/Darling.Tests/RollupCoverageWarmupTests.cs
new file mode 100644
index 0000000000..98dfda5936
--- /dev/null
+++ b/Darling/Darling.Tests/RollupCoverageWarmupTests.cs
@@ -0,0 +1,50 @@
+/*
+ * 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.Text.RegularExpressions;
+using Xunit;
+
+namespace Darling.Tests;
+
+///
+/// #4957: where the rollup-floor warm is launched. The warm itself runs against a store (see
+/// RollupFloorCacheLiveTests); these pin that the two processes that pay the cold sort inline actually start it —
+/// the service after its migrations, the Viewer once its store has answered — and that neither waits for it.
+///
+public sealed class RollupCoverageWarmupTests
+{
+ [Fact]
+ public void TheWorker_LaunchesTheCoverageWarmAfterMigrations_WithoutAwaitingItOnTheStartupPath()
+ {
+ var source = RepoFile.ReadRepoFile("Darling", "PerformanceMonitor.Darling.Service", "DarlingWorker.cs").Replace("\r\n", "\n");
+
+ var migrate = source.IndexOf("PgMigrations.MigrateAsync(migrateConnection", System.StringComparison.Ordinal);
+ var launch = source.IndexOf("var rollupCoverageWarm = RollupCoverageWarmup.RunDelayedAsync(", System.StringComparison.Ordinal);
+ var drain = source.IndexOf("await rollupCoverageWarm;", System.StringComparison.Ordinal);
+ var loopStop = source.IndexOf("PerformanceMonitor Darling collection loop stopped", System.StringComparison.Ordinal);
+
+ Assert.True(migrate > 0 && launch > migrate, "the warm must launch after migrations");
+ Assert.True(drain > launch && drain < loopStop, "the warm is drained only at shutdown, after the collection loop");
+ Assert.Single(Regex.Matches(source, @"await rollupCoverageWarm;"));
+ Assert.Contains("RollupCoverageWarmup.ServiceStartDelay", source);
+ }
+
+ [Fact]
+ public void TheViewer_WarmsTheCoverageOnceItsStoreHasAnswered_AndDoesNotWaitForIt()
+ {
+ var main = RepoFile.ReadRepoFile("Darling", "PerformanceMonitor.Darling.Viewer", "MainWindow.xaml.cs").Replace("\r\n", "\n");
+ var readOnlyProbe = main.IndexOf("await _dataService.DetectReadOnlyAsync();", System.StringComparison.Ordinal);
+ var warm = main.IndexOf("_ = _dataService.WarmRollupCoverageAsync(", System.StringComparison.Ordinal);
+
+ Assert.True(readOnlyProbe > 0 && warm > readOnlyProbe, "the Viewer warms only after the store answered its reachability, schema and seat probes");
+
+ var dataService = RepoFile.ReadRepoFile("Darling", "PerformanceMonitor.Darling.Viewer", "ViewerDataService.RollupAvailability.cs").Replace("\r\n", "\n");
+ Assert.Contains("RollupCoverageWarmup.RunDelayedAsync(_dataSource", dataService);
+ Assert.Contains("RollupCoverageWarmup.ViewerStartDelay", dataService);
+ }
+}
diff --git a/Darling/Darling.Tests/RollupFloorCacheLiveTests.cs b/Darling/Darling.Tests/RollupFloorCacheLiveTests.cs
index 80c613b529..b0548a7e99 100644
--- a/Darling/Darling.Tests/RollupFloorCacheLiveTests.cs
+++ b/Darling/Darling.Tests/RollupFloorCacheLiveTests.cs
@@ -8,6 +8,7 @@
using System;
using System.Collections.Generic;
+using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Npgsql;
@@ -160,6 +161,337 @@ drop_chunks is called against the AGGREGATE VIEW (the only relation name that st
Assert.True(floorThird > floorSecond, "the cache must re-measure and move the floor later once the chunk it was keyed on is gone");
}
+ /* ──────────── #4957: one floor cache per STORE, warmed at start, re-measured past the hour in the background ──────────── */
+
+ private static readonly string HourlyView = TimescaleSupport.QueryStoreStatsIntervalHourlyView;
+
+ /// The statement text whose executions the #4957 tests count: the hourly rollup's min(bucket).
+ private static readonly string MinBucketStatement = $"min(bucket) FROM collect.{TimescaleSupport.QueryStoreStatsIntervalHourlyView}";
+
+ private static string? BaseConnectionString => Environment.GetEnvironmentVariable("DARLING_TEST_PG");
+
+ /// The hour two UTC days before today at 02:00: inside ONE one-day materialization chunk with 22 buckets
+ /// after it, so a delete of its first two buckets can never empty that chunk (a 23:00 start would).
+ private static DateTime SeedStart() => DateTime.SpecifyKind(DateTime.UtcNow.Date.AddDays(-3).AddHours(2), DateTimeKind.Unspecified);
+
+ private static DateTime SeedEnd() => DateTime.SpecifyKind(DateTime.UtcNow, DateTimeKind.Unspecified);
+
+ [Fact]
+ public async Task TwoDataSourcesOnOneStore_MeasureTheFloorOnce_AndTwoStoresNeverShareAFloor()
+ {
+ Assert.SkipWhen(string.IsNullOrEmpty(BaseConnectionString),
+ "Set DARLING_TEST_PG to a Postgres connection string (with TimescaleDB installed) to run the live #4957 per-store floor cache test (it mints its own scratch databases).");
+ var ct = TestContext.Current.CancellationToken;
+ var now = SeedEnd();
+
+ await using var storeA = await ScratchPostgres.CreateAsync(BaseConnectionString!, ct);
+ await using var storeB = await ScratchPostgres.CreateAsync(BaseConnectionString!, ct);
+ await PopulateStoreAsync(storeA.ConnectionString, SeedStart(), now, deleteRawBefore: null, oneDayChunks: false, ct);
+ await PopulateStoreAsync(storeB.ConnectionString, SeedStart(), now, deleteRawBefore: SeedStart().AddHours(7), oneDayChunks: false, ct);
+
+ var truthA = await TruthAsync(storeA.ConnectionString, ct);
+ var truthB = await TruthAsync(storeB.ConnectionString, ct);
+ Assert.NotNull(truthA);
+ Assert.NotNull(truthB);
+ Assert.True(truthB > truthA, "the two stores must hold different floors, or sharing one cache would go unnoticed");
+
+ var factoryA1 = new CommandCountingLoggerFactory();
+ var factoryA2 = new CommandCountingLoggerFactory();
+ var factoryB = new CommandCountingLoggerFactory();
+ await using var dataSourceA1 = new NpgsqlDataSourceBuilder(storeA.ConnectionString).UseLoggerFactory(factoryA1).Build();
+ await using var dataSourceA2 = new NpgsqlDataSourceBuilder(storeA.ConnectionString).UseLoggerFactory(factoryA2).Build();
+ await using var dataSourceB = new NpgsqlDataSourceBuilder(storeB.ConnectionString).UseLoggerFactory(factoryB).Build();
+ var availability = await TimescaleSupport.DetectRollupsAsync(dataSourceA1, ct);
+ Assert.True(availability.Has(HourlyView));
+
+ var viaA1 = await TimescaleSupport.DetectRollupCoverageAsync(dataSourceA1, availability, ct);
+ var viaA2 = await TimescaleSupport.DetectRollupCoverageAsync(dataSourceA2, availability, ct);
+ var viaB = await TimescaleSupport.DetectRollupCoverageAsync(dataSourceB, availability, ct);
+
+ Assert.Equal(truthA, viaA1.FloorOf(HourlyView));
+ Assert.Equal(truthA, viaA2.FloorOf(HourlyView));
+ Assert.Equal(truthB, viaB.FloorOf(HourlyView));
+
+ /* The first data source on a store pays the sort; the second data source on the SAME store does not. */
+ Assert.Equal(1, factoryA1.Provider.CountContaining(MinBucketStatement));
+ Assert.Equal(0, factoryA2.Provider.CountContaining(MinBucketStatement));
+
+ /* A different store has its own floor: it measured for itself, and never read store A's. */
+ Assert.Equal(1, factoryB.Provider.CountContaining(MinBucketStatement));
+ }
+
+ [Fact]
+ public async Task ADatabaseDroppedAndRecreatedUnderTheSameName_IsMeasuredAgain_NotServedItsOldFloor()
+ {
+ Assert.SkipWhen(string.IsNullOrEmpty(BaseConnectionString),
+ "Set DARLING_TEST_PG to a Postgres connection string (with TimescaleDB installed) to run the live #4957 recreated-database floor cache test (it mints its own scratch database).");
+ var ct = TestContext.Current.CancellationToken;
+ var now = SeedEnd();
+
+ await using var store = await ScratchPostgres.CreateAsync(BaseConnectionString!, ct);
+ await PopulateStoreAsync(store.ConnectionString, SeedStart(), now, deleteRawBefore: null, oneDayChunks: false, ct);
+ var truthBefore = await TruthAsync(store.ConnectionString, ct);
+ var oldestChunkBefore = await OldestChunkAsync(store.ConnectionString, ct);
+
+ /* One data source across the drop: the scratch connection string does not pool, so each command opens a
+ fresh connection and the same data source reaches the second incarnation. */
+ var factory = new CommandCountingLoggerFactory();
+ await using var dataSource = new NpgsqlDataSourceBuilder(store.ConnectionString).UseLoggerFactory(factory).Build();
+ var availability = await TimescaleSupport.DetectRollupsAsync(dataSource, ct);
+ var first = await TimescaleSupport.DetectRollupCoverageAsync(dataSource, availability, now, ct);
+ Assert.Equal(truthBefore, first.FloorOf(HourlyView));
+
+ /* Drop the database and build it again under the same name, laid out identically but with its oldest five
+ hours removed: the same chunk and hypertable names, a later floor. */
+ await RecreateDatabaseAsync(BaseConnectionString!, store.DatabaseName, ct);
+ await PopulateStoreAsync(store.ConnectionString, SeedStart(), now, deleteRawBefore: SeedStart().AddHours(5), oneDayChunks: false, ct);
+ var truthAfter = await TruthAsync(store.ConnectionString, ct);
+ Assert.True(truthAfter > truthBefore, "the recreated store's floor must be later, or a stale floor would go unnoticed");
+ Assert.Equal(oldestChunkBefore, await OldestChunkAsync(store.ConnectionString, ct));
+
+ var second = await TimescaleSupport.DetectRollupCoverageAsync(dataSource, availability, now, ct);
+
+ Assert.Equal(truthAfter, second.FloorOf(HourlyView));
+ Assert.Equal(2, factory.Provider.CountContaining(MinBucketStatement));
+ }
+
+ [Fact]
+ public async Task APastTheHourFloorWithItsChunkUnchanged_IsServedFromTheCache_AndRemeasuredOnceInTheBackground_AChangedChunkIsMeasuredInline()
+ {
+ Assert.SkipWhen(string.IsNullOrEmpty(BaseConnectionString),
+ "Set DARLING_TEST_PG to a Postgres connection string (with TimescaleDB installed) to run the live #4957 background re-measure test (it mints its own scratch database).");
+ var ct = TestContext.Current.CancellationToken;
+ var now = SeedEnd();
+
+ await using var store = await ScratchPostgres.CreateAsync(BaseConnectionString!, ct);
+ await PopulateStoreAsync(store.ConnectionString, SeedStart(), now, deleteRawBefore: null, oneDayChunks: true, ct);
+
+ var factory = new CommandCountingLoggerFactory();
+ await using var dataSource = new NpgsqlDataSourceBuilder(store.ConnectionString).UseLoggerFactory(factory).Build();
+ var availability = await TimescaleSupport.DetectRollupsAsync(dataSource, ct);
+
+ var cold = await TimescaleSupport.DetectRollupCoverageAsync(dataSource, availability, now, ct);
+ var floorCold = cold.FloorOf(HourlyView);
+ Assert.Equal(await TruthAsync(store.ConnectionString, ct), floorCold);
+ Assert.Equal(1, factory.Provider.CountContaining(MinBucketStatement));
+
+ /* A retention delete inside the oldest chunk: the true floor moves later and the chunk's identity does not. */
+ var oldestChunk = await OldestChunkAsync(store.ConnectionString, ct);
+ await DeleteMaterializedBucketsBeforeAsync(store.ConnectionString, floorCold!.Value.AddHours(2), ct);
+ var floorAfterDelete = await TruthAsync(store.ConnectionString, ct);
+ Assert.True(floorAfterDelete > floorCold);
+ Assert.Equal(oldestChunk, await OldestChunkAsync(store.ConnectionString, ct));
+
+ /* Past the hour: served from the cache (the floor as it stood), NOT measured inline. */
+ var pastTheHour = now + TimescaleSupport.RollupFloorMaxReuse + TimeSpan.FromMinutes(1);
+ var served = await TimescaleSupport.DetectRollupCoverageAsync(dataSource, availability, pastTheHour, ct);
+ Assert.Equal(floorCold, served.FloorOf(HourlyView));
+
+ /* Several callers at once, while that re-measure runs or just after it: each gets the old or the new floor, never an error. */
+ var burst = await Task.WhenAll(Enumerable.Range(0, 6).Select(_ =>
+ Task.Run(() => TimescaleSupport.DetectRollupCoverageAsync(dataSource, availability, pastTheHour, ct), ct)));
+ Assert.All(burst, c => Assert.True(c.FloorOf(HourlyView) == floorCold || c.FloorOf(HourlyView) == floorAfterDelete));
+
+ /* The one background re-measure lands: callers see the new floor, and the seven past-the-hour callers
+ between them ran the sort once (plus the cold measure), not seven times. */
+ var refreshed = await PollForFloorAsync(dataSource, availability, pastTheHour, floorAfterDelete, ct);
+ Assert.Equal(floorAfterDelete, refreshed.FloorOf(HourlyView));
+ Assert.Equal(2, factory.Provider.CountContaining(MinBucketStatement));
+
+ /* A changed chunk is wrong, not merely old: dropping the oldest chunk is measured inline, on the calling thread,
+ even though this entry is past the hour too. */
+ var (_, oldestRangeEnd) = await OldestChunkWithRangeEndAsync(store.ConnectionString, ct);
+ await DropChunksOlderThanAsync(store.ConnectionString, oldestRangeEnd, ct);
+ var floorAfterDrop = await TruthAsync(store.ConnectionString, ct);
+ Assert.True(floorAfterDrop > floorAfterDelete);
+
+ var pastTheHourAgain = pastTheHour + TimescaleSupport.RollupFloorMaxReuse + TimeSpan.FromMinutes(1);
+ var inline = await TimescaleSupport.DetectRollupCoverageAsync(dataSource, availability, pastTheHourAgain, ct);
+ Assert.Equal(floorAfterDrop, inline.FloorOf(HourlyView));
+ Assert.Equal(3, factory.Provider.CountContaining(MinBucketStatement));
+ }
+
+ [Fact]
+ public async Task AFloorPastTwiceTheHourWithItsChunkUnchanged_IsMeasuredInline_NotServedFromTheCache()
+ {
+ Assert.SkipWhen(string.IsNullOrEmpty(BaseConnectionString),
+ "Set DARLING_TEST_PG to a Postgres connection string (with TimescaleDB installed) to run the live #4957 serve-cap test (it mints its own scratch database).");
+ var ct = TestContext.Current.CancellationToken;
+ var now = SeedEnd();
+
+ await using var store = await ScratchPostgres.CreateAsync(BaseConnectionString!, ct);
+ await PopulateStoreAsync(store.ConnectionString, SeedStart(), now, deleteRawBefore: null, oneDayChunks: true, ct);
+
+ var factory = new CommandCountingLoggerFactory();
+ await using var dataSource = new NpgsqlDataSourceBuilder(store.ConnectionString).UseLoggerFactory(factory).Build();
+ var availability = await TimescaleSupport.DetectRollupsAsync(dataSource, ct);
+
+ var cold = await TimescaleSupport.DetectRollupCoverageAsync(dataSource, availability, now, ct);
+ var floorCold = cold.FloorOf(HourlyView);
+ Assert.Equal(await TruthAsync(store.ConnectionString, ct), floorCold);
+ Assert.Equal(1, factory.Provider.CountContaining(MinBucketStatement));
+
+ /* A retention delete inside the oldest chunk: the true floor moves later and the chunk's identity does not,
+ which is the one change the cache cannot see and the cap exists to bound. */
+ var oldestChunk = await OldestChunkAsync(store.ConnectionString, ct);
+ await DeleteMaterializedBucketsBeforeAsync(store.ConnectionString, floorCold!.Value.AddHours(2), ct);
+ var floorAfterDelete = await TruthAsync(store.ConnectionString, ct);
+ Assert.True(floorAfterDelete > floorCold);
+ Assert.Equal(oldestChunk, await OldestChunkAsync(store.ConnectionString, ct));
+
+ /* At the serve cap the entry is no longer served: the first caller measures it inline and gets the true floor
+ in the same call, with the sort already run when it returns (no polling for a background pass). */
+ var pastTheCap = now + TimescaleSupport.RollupFloorMaxServeAge + TimeSpan.FromMinutes(1);
+ var inline = await TimescaleSupport.DetectRollupCoverageAsync(dataSource, availability, pastTheCap, ct);
+ Assert.Equal(floorAfterDelete, inline.FloorOf(HourlyView));
+ Assert.Equal(2, factory.Provider.CountContaining(MinBucketStatement));
+
+ /* It replaced the entry, so the next call is current and measures nothing. */
+ var next = await TimescaleSupport.DetectRollupCoverageAsync(dataSource, availability, pastTheCap, ct);
+ Assert.Equal(floorAfterDelete, next.FloorOf(HourlyView));
+ Assert.Equal(2, factory.Provider.CountContaining(MinBucketStatement));
+ }
+
+ [Fact]
+ public async Task AfterTheStartUpWarm_TheFirstCoverageCallOnAnotherDataSource_RunsNoMinBucket()
+ {
+ Assert.SkipWhen(string.IsNullOrEmpty(BaseConnectionString),
+ "Set DARLING_TEST_PG to a Postgres connection string (with TimescaleDB installed) to run the live #4957 start-up warm test (it mints its own scratch database).");
+ var ct = TestContext.Current.CancellationToken;
+ var now = SeedEnd();
+
+ await using var store = await ScratchPostgres.CreateAsync(BaseConnectionString!, ct);
+ await PopulateStoreAsync(store.ConnectionString, SeedStart(), now, deleteRawBefore: null, oneDayChunks: false, ct);
+ var truth = await TruthAsync(store.ConnectionString, ct);
+
+ /* The service's own data source warms; the MCP or web host's data source is the first CALLER. */
+ await using var workerSource = NpgsqlDataSource.Create(store.ConnectionString);
+ await RollupCoverageWarmup.RunDelayedAsync(workerSource, logger: null, TimeSpan.Zero, ct);
+
+ var factory = new CommandCountingLoggerFactory();
+ await using var callerSource = new NpgsqlDataSourceBuilder(store.ConnectionString).UseLoggerFactory(factory).Build();
+ var availability = await TimescaleSupport.DetectRollupsAsync(callerSource, ct);
+ var coverage = await TimescaleSupport.DetectRollupCoverageAsync(callerSource, availability, ct);
+
+ Assert.Equal(truth, coverage.FloorOf(HourlyView));
+ Assert.Equal(0, factory.Provider.CountContaining(MinBucketStatement));
+ }
+
+ /// Builds a TimescaleDB store in the database names: migrated, hypertables,
+ /// the continuous aggregates, and the hourly Query Store rollup materialized over ..
+ /// — less any raw rows older than , which is how a second incarnation of the same
+ /// layout gets a later floor.
+ private static async Task PopulateStoreAsync(
+ string connectionString, DateTime from, DateTime to, DateTime? deleteRawBefore, bool oneDayChunks, CancellationToken ct)
+ {
+ await using var connection = new NpgsqlConnection(connectionString);
+ await connection.OpenAsync(ct);
+ await PgMigrations.MigrateAsync(connection, ct);
+
+ var timescaleEnabled = await LiveTimescaleProbe.TryEnableAsync(connectionString, ct);
+ Assert.True(timescaleEnabled, "TimescaleDB must be available on CI for the live #4957 floor cache tests");
+
+ await TimescaleSupport.ConvertToHypertablesAsync(connection, null, ct);
+
+ /* No background worker racing the fixture's own refresh calls below. */
+ await using (var stop = new NpgsqlCommand("SELECT _timescaledb_functions.stop_background_workers()", connection))
+ {
+ await stop.ExecuteNonQueryAsync(ct);
+ }
+
+ await SeedHourlyQueryStoreStatsAsync(connection, from, to, ct);
+ if (deleteRawBefore is DateTime cut)
+ {
+ await using var trim = new NpgsqlCommand("DELETE FROM collect.query_store_stats WHERE collection_time < $1", connection);
+ trim.Parameters.AddWithValue(cut);
+ await trim.ExecuteNonQueryAsync(ct);
+ }
+
+ await TimescaleSupport.EnsureContinuousAggregatesAsync(connection, null, ct);
+
+ if (oneDayChunks)
+ {
+ await using var setInterval = new NpgsqlCommand(TimescaleSupport.SetMaterializationChunkIntervalSql(HourlyView), connection);
+ await setInterval.ExecuteNonQueryAsync(ct);
+ }
+
+ await using var refresh = new NpgsqlCommand(TimescaleSupport.RefreshContinuousAggregateSql(HourlyView, force: true), connection);
+ refresh.Parameters.AddWithValue(from);
+ await refresh.ExecuteNonQueryAsync(ct);
+ }
+
+ private static async Task TruthAsync(string connectionString, CancellationToken ct)
+ {
+ await using var connection = new NpgsqlConnection(connectionString);
+ await connection.OpenAsync(ct);
+ return await ScalarInstantAsync(connection, $"SELECT min(bucket) FROM collect.{HourlyView}", ct);
+ }
+
+ private static async Task OldestChunkAsync(string connectionString, CancellationToken ct)
+ => (await OldestChunkWithRangeEndAsync(connectionString, ct)).ChunkName;
+
+ private static async Task<(string? ChunkName, DateTime RangeEnd)> OldestChunkWithRangeEndAsync(string connectionString, CancellationToken ct)
+ {
+ await using var connection = new NpgsqlConnection(connectionString);
+ await connection.OpenAsync(ct);
+ return await OldestMaterializationChunkAsync(connection, HourlyView, ct);
+ }
+
+ private static async Task DeleteMaterializedBucketsBeforeAsync(string connectionString, DateTime before, CancellationToken ct)
+ {
+ await using var connection = new NpgsqlConnection(connectionString);
+ await connection.OpenAsync(ct);
+ await using var find = new NpgsqlCommand($@"
+SELECT format('%I.%I', materialization_hypertable_schema, materialization_hypertable_name)
+FROM timescaledb_information.continuous_aggregates
+WHERE view_schema = 'collect' AND view_name = '{HourlyView}'", connection);
+ var table = (string)(await find.ExecuteScalarAsync(ct))!;
+
+ await using var delete = new NpgsqlCommand($"DELETE FROM {table} WHERE bucket < $1", connection);
+ delete.Parameters.AddWithValue(before);
+ await delete.ExecuteNonQueryAsync(ct);
+ }
+
+ private static async Task DropChunksOlderThanAsync(string connectionString, DateTime olderThan, CancellationToken ct)
+ {
+ await using var connection = new NpgsqlConnection(connectionString);
+ await connection.OpenAsync(ct);
+ await using var drop = new NpgsqlCommand($"SELECT drop_chunks('collect.{HourlyView}', older_than => $1::timestamp)", connection);
+ drop.Parameters.AddWithValue(olderThan);
+ await drop.ExecuteNonQueryAsync(ct);
+ }
+
+ private static async Task RecreateDatabaseAsync(string baseConnectionString, string databaseName, CancellationToken ct)
+ {
+ await using var admin = new NpgsqlConnection(baseConnectionString);
+ await admin.OpenAsync(ct);
+ await using (var drop = new NpgsqlCommand($"DROP DATABASE \"{databaseName}\" WITH (FORCE)", admin))
+ {
+ await drop.ExecuteNonQueryAsync(ct);
+ }
+
+ await using var create = new NpgsqlCommand($"CREATE DATABASE \"{databaseName}\"", admin);
+ await create.ExecuteNonQueryAsync(ct);
+ }
+
+ /// Calls coverage with the same wall clock until the hourly rollup's floor reads
+ /// (a background re-measure has landed) or thirty seconds pass.
+ private static async Task PollForFloorAsync(
+ NpgsqlDataSource dataSource, RollupAvailability availability, DateTime now, DateTime? expected, CancellationToken ct)
+ {
+ var deadline = DateTime.UtcNow.AddSeconds(30);
+ while (true)
+ {
+ var coverage = await TimescaleSupport.DetectRollupCoverageAsync(dataSource, availability, now, ct);
+ if (coverage.FloorOf(HourlyView) == expected || DateTime.UtcNow > deadline)
+ {
+ return coverage;
+ }
+
+ await Task.Delay(50, ct);
+ }
+ }
+
private static async Task SeedHourlyQueryStoreStatsAsync(
NpgsqlConnection connection, DateTime from, DateTime to, CancellationToken cancellationToken)
{
diff --git a/Darling/Darling.Tests/RollupFloorStoreCacheTests.cs b/Darling/Darling.Tests/RollupFloorStoreCacheTests.cs
new file mode 100644
index 0000000000..5f89c71034
--- /dev/null
+++ b/Darling/Darling.Tests/RollupFloorStoreCacheTests.cs
@@ -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.Collections.Generic;
+using Npgsql;
+using PerformanceMonitor.Darling.Storage;
+using Xunit;
+
+namespace Darling.Tests;
+
+///
+/// #4957, with no database: the pure halves of the per-store rollup floor cache — which store a data source belongs
+/// to, which views a cycle measures inline versus in the background, and how a cycle's result is written back.
+/// The end-to-end behavior is in RollupFloorCacheLiveTests.
+///
+public sealed class RollupFloorStoreCacheTests
+{
+ private const string View = TimescaleSupport.QueryStoreStatsIntervalHourlyView;
+ private static readonly DateTime Now = new(2026, 10, 2, 12, 0, 0, DateTimeKind.Unspecified);
+ private static readonly DateTime Floor = Now.AddDays(-30);
+
+ private static string KeyOf(string connectionString)
+ {
+ using var dataSource = NpgsqlDataSource.Create(connectionString);
+ return TimescaleSupport.RollupStoreKey(dataSource);
+ }
+
+ [Fact]
+ public void RollupStoreKey_IsTheSameForAnyUserPasswordOrSessionOption_OnOneHostPortAndDatabase()
+ {
+ var first = KeyOf("Host=Store-A;Port=5432;Username=owner;Password=one;Database=darling");
+ var second = KeyOf("Host=store-a;Port=5432;Username=mcp;Password=two;Database=darling;Options=-c TimeZone=UTC");
+
+ Assert.Equal(first, second);
+ }
+
+ [Theory]
+ [InlineData("Host=store-b;Port=5432;Username=owner;Database=darling")]
+ [InlineData("Host=store-a;Port=5433;Username=owner;Database=darling")]
+ [InlineData("Host=store-a;Port=5432;Username=owner;Database=darling_other")]
+ public void RollupStoreKey_DiffersWhenTheHostPortOrDatabaseDiffers(string other)
+ {
+ Assert.NotEqual(KeyOf("Host=store-a;Port=5432;Username=owner;Database=darling"), KeyOf(other));
+ }
+
+ private static TimescaleSupport.RollupFloorCacheEntry Entry(DateTime measuredAt, long oid = 7)
+ => new("chunk_1", "hyper_1", Floor, measuredAt, Ceiling: null, DatabaseOid: oid);
+
+ private static TimescaleSupport.RollupFloorPlan Plan(
+ TimescaleSupport.RollupFloorCacheEntry? entry, long oidNow = 7, string chunkNow = "chunk_1", string hypertableNow = "hyper_1")
+ {
+ var cached = new Dictionary(StringComparer.Ordinal);
+ if (entry is { } e)
+ {
+ cached[View] = e;
+ }
+
+ var oldestNow = new Dictionary(StringComparer.Ordinal)
+ {
+ [View] = new(chunkNow, hypertableNow, oidNow),
+ };
+
+ return TimescaleSupport.PlanRollupFloorMeasurements(cached, oldestNow, RollupAvailability.All, Now);
+ }
+
+ [Fact]
+ public void Plan_AFloorPastTheHourWithItsIdentityUnchanged_IsDueInTheBackground_NotInline()
+ {
+ var plan = Plan(Entry(Now - TimescaleSupport.RollupFloorMaxReuse));
+
+ Assert.Empty(plan.Inline);
+ Assert.Contains(View, plan.Background);
+ }
+
+ [Fact]
+ public void Plan_AFreshFloorWithItsIdentityUnchanged_IsNeitherInlineNorDue()
+ {
+ var plan = Plan(Entry(Now - TimeSpan.FromMinutes(5)));
+
+ Assert.Empty(plan.Inline);
+ Assert.Empty(plan.Background);
+ }
+
+ [Fact]
+ public void Plan_NoEntryYet_IsInline()
+ {
+ var plan = Plan(entry: null);
+
+ Assert.Contains(View, plan.Inline);
+ Assert.Empty(plan.Background);
+ }
+
+ [Fact]
+ public void Plan_ADifferentDatabaseOid_IsInline_EvenWhenTheEntryIsPastTheHour()
+ {
+ var plan = Plan(Entry(Now - TimescaleSupport.RollupFloorMaxReuse - TimeSpan.FromMinutes(5), oid: 7), oidNow: 8);
+
+ Assert.Contains(View, plan.Inline);
+ Assert.Empty(plan.Background);
+ }
+
+ /* The second threshold. Between the reuse window and twice the window a floor is served from the cache and
+ re-measured once in the background (the tests above). At twice the window it is measured inline again, so no
+ floor is served older than that, whether or not callers kept arriving. */
+ [Fact]
+ public void ServeCap_IsTwiceTheReuseWindow()
+ {
+ Assert.Equal(TimescaleSupport.RollupFloorMaxReuse * 2, TimescaleSupport.RollupFloorMaxServeAge);
+ Assert.True(TimescaleSupport.RollupFloorMaxServeAge > TimescaleSupport.RollupFloorMaxReuse);
+ }
+
+ [Fact]
+ public void Plan_AFloorJustUnderTheServeCap_WithItsIdentityUnchanged_IsStillServed_AndDueInTheBackground()
+ {
+ var plan = Plan(Entry(Now - TimescaleSupport.RollupFloorMaxServeAge + TimeSpan.FromMinutes(1)));
+
+ Assert.Empty(plan.Inline);
+ Assert.Contains(View, plan.Background);
+ }
+
+ [Theory]
+ [InlineData(0)]
+ [InlineData(1)]
+ [InlineData(60 * 24 * 30)]
+ public void Plan_AFloorAtOrPastTheServeCap_WithItsIdentityUnchanged_IsInline_NotServedFromTheCache(int minutesPast)
+ {
+ var plan = Plan(Entry(Now - TimescaleSupport.RollupFloorMaxServeAge - TimeSpan.FromMinutes(minutesPast)));
+
+ Assert.Contains(View, plan.Inline);
+ Assert.Empty(plan.Background);
+ }
+
+ [Theory]
+ [InlineData(5)]
+ [InlineData(61)]
+ [InlineData(121)]
+ [InlineData(60 * 24 * 30)]
+ public void Plan_AChangedChunkHypertableOrDatabase_IsInline_AtAnyAge(int ageMinutes)
+ {
+ var measuredAt = Now - TimeSpan.FromMinutes(ageMinutes);
+
+ foreach (var plan in new[]
+ {
+ Plan(Entry(measuredAt), chunkNow: "chunk_2"),
+ Plan(Entry(measuredAt), hypertableNow: "hyper_2"),
+ Plan(Entry(measuredAt), oidNow: 8),
+ })
+ {
+ Assert.Contains(View, plan.Inline);
+ Assert.Empty(plan.Background);
+ }
+ }
+
+ [Fact]
+ public void Plan_RollupFloorsToMeasure_IsTheUnionOfBoth()
+ {
+ var cached = new Dictionary(StringComparer.Ordinal)
+ {
+ [View] = Entry(Now - TimescaleSupport.RollupFloorMaxReuse),
+ };
+ var oldestNow = new Dictionary(StringComparer.Ordinal)
+ {
+ [View] = new("chunk_1", "hyper_1", 7),
+ };
+
+ Assert.Contains(View, TimescaleSupport.RollupFloorsToMeasure(cached, oldestNow, RollupAvailability.All, Now));
+ }
+
+ [Fact]
+ public void Apply_ReplacesWhatThisCycleMeasured_DropsAnEmptyView_AndLeavesTheRestAsTheCacheHoldsThemNow()
+ {
+ var other = TimescaleSupport.QueryStoreStatsHourlyView;
+ var newerThanTheCycleSnapshot = Entry(Now);
+ var cache = new Dictionary(StringComparer.Ordinal)
+ {
+ [View] = Entry(Now.AddHours(-2)),
+ [other] = newerThanTheCycleSnapshot,
+ };
+ var measured = new Dictionary(StringComparer.Ordinal) { [View] = null };
+ var newEntries = new Dictionary(StringComparer.Ordinal)
+ {
+ [other] = Entry(Now.AddHours(-5)),
+ };
+
+ TimescaleSupport.ApplyRollupFloorMeasurements(cache, measured, newEntries, RollupAvailability.All);
+
+ Assert.False(cache.ContainsKey(View));
+ Assert.Equal(newerThanTheCycleSnapshot, cache[other]);
+ }
+
+ [Fact]
+ public void Apply_DropsAViewWhoseRollupIsGone()
+ {
+ var cache = new Dictionary(StringComparer.Ordinal)
+ {
+ [View] = Entry(Now),
+ };
+
+ TimescaleSupport.ApplyRollupFloorMeasurements(
+ cache,
+ new Dictionary(StringComparer.Ordinal),
+ new Dictionary(StringComparer.Ordinal),
+ RollupAvailability.None);
+
+ Assert.Empty(cache);
+ }
+}
diff --git a/Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs b/Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs
index 48a054f3cb..2b6c848ef1 100644
--- a/Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs
+++ b/Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs
@@ -2318,6 +2318,14 @@ background QueryStoreIntervalWideBrinIndex.StartDelay after start so the full-he
var intervalWideBrin = QueryStoreIntervalWideBrinIndex.RunDelayedAsync(
postgres, _logger, QueryStoreIntervalWideBrinIndex.StartDelay, stoppingToken);
+ /* #4957: one rollup-coverage probe in the background RollupCoverageWarmup.ServiceStartDelay after start, so
+ the first MCP or web call finds each rollup's floor already measured and does not wait ~10 s on the
+ large stores for the oldest compressed chunk's min(bucket). Launched after migrations, never awaited on
+ the startup path, and RunDelayedAsync never throws: a failed warm logs once at Debug and changes
+ nothing else. Drained with the other background work. */
+ var rollupCoverageWarm = RollupCoverageWarmup.RunDelayedAsync(
+ postgres, _logger, RollupCoverageWarmup.ServiceStartDelay, stoppingToken);
+
/* #4214 ruling 9: the once-per-start store host/settings profile log — host facts, pg_settings and
the managed conf files only, never the store-size/chunk-total reads --check-settings and the MCP
read own. Its own short deadline and its own catch: a bad conf file, a permission problem or a
@@ -3813,6 +3821,9 @@ it throws is observed rather than lost with the task. */
/* And the interval-wide BRIN index ensure (#4605), which absorbs its own failures. */
await intervalWideBrin;
+ /* And the rollup-coverage warm (#4957), which also absorbs its own failures. */
+ await rollupCoverageWarm;
+
_logger.LogInformation("PerformanceMonitor Darling collection loop stopped");
}
diff --git a/Darling/PerformanceMonitor.Darling.Storage/RollupCoverageWarmup.cs b/Darling/PerformanceMonitor.Darling.Storage/RollupCoverageWarmup.cs
new file mode 100644
index 0000000000..a0aa539755
--- /dev/null
+++ b/Darling/PerformanceMonitor.Darling.Storage/RollupCoverageWarmup.cs
@@ -0,0 +1,77 @@
+/*
+ * 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.Threading;
+using System.Threading.Tasks;
+using Microsoft.Extensions.Logging;
+using Npgsql;
+
+namespace PerformanceMonitor.Darling.Storage;
+
+///
+/// #4957: runs one rollup-coverage probe in the background shortly after a process opens its store, so the first
+/// reader finds every rollup's floor already measured.
+///
+/// Why. On a store with a long Query Store history, a cold
+///
+/// is about ten seconds: each rollup's min(bucket) decompresses and sorts the batches of that rollup's
+/// oldest compressed chunk. Before this, the first get_daily_summary (or the Viewer's first routed read)
+/// after a start paid that inline, ahead of its own query. The floors are cached per store (see
+/// ), so one probe here is enough for every data source the process opens on that
+/// store.
+///
+/// What it probes. The same two calls both production callers make —
+/// , then
+///
+/// with what it found — so the views measured are exactly the union the callers need: every rollup the store has.
+/// It deliberately does not go through the service's cached availability answer or the Viewer's: a failed probe
+/// there is cached as "no rollups" for five minutes, and a failed warm must change nothing else.
+///
+/// The delay follows : wait, make one
+/// attempt on the process's own data source, never throw. It is shorter than that index build's, because this
+/// probe is read-only and bounded, and a first call that arrives before it still measures inline, exactly as it
+/// did before.
+///
+public static class RollupCoverageWarmup
+{
+ /// How long the service waits after start before warming the rollup floors: past the start-up
+ /// collection burst, early enough that the first call from an agent or the web UI usually finds them measured.
+ public static readonly TimeSpan ServiceStartDelay = TimeSpan.FromSeconds(30);
+
+ /// How long the Viewer waits after opening its store before warming the rollup floors: enough for its
+ /// open-time reachability, schema and seat reads to clear the pool, short enough to beat the first tab load.
+ public static readonly TimeSpan ViewerStartDelay = TimeSpan.FromSeconds(3);
+
+ ///
+ /// Waits , then probes the store's rollup coverage once, and never throws. A cancel
+ /// (shutdown, or the data source going away) and any failure alike log once at Debug and change nothing else:
+ /// the caller that needs the floors measures them itself, as it always has. A null
+ /// logs nothing (the Viewer has no and no Debug level).
+ ///
+ public static async Task RunDelayedAsync(
+ NpgsqlDataSource postgres, ILogger? logger, TimeSpan delay, CancellationToken cancellationToken)
+ {
+ try
+ {
+ if (delay > TimeSpan.Zero)
+ {
+ await Task.Delay(delay, cancellationToken).ConfigureAwait(false);
+ }
+
+ var rollups = await TimescaleSupport.DetectRollupsAsync(postgres, cancellationToken).ConfigureAwait(false);
+ await TimescaleSupport.DetectRollupCoverageAsync(postgres, rollups, cancellationToken).ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ logger?.LogDebug(
+ "Rollup coverage warm-up did not complete, so the first caller measures the floors itself: {ExceptionType}: {Message}",
+ ex.GetType().Name, ex.Message);
+ }
+ }
+}
diff --git a/Darling/PerformanceMonitor.Darling.Storage/TimescaleSupport.cs b/Darling/PerformanceMonitor.Darling.Storage/TimescaleSupport.cs
index 70aaf875ce..3f06dd26a3 100644
--- a/Darling/PerformanceMonitor.Darling.Storage/TimescaleSupport.cs
+++ b/Darling/PerformanceMonitor.Darling.Storage/TimescaleSupport.cs
@@ -7,6 +7,7 @@
*/
using System;
+using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Diagnostics;
using System.Globalization;
@@ -9290,6 +9291,12 @@ public static string RollupCoverageProbeSql(RollupAvailability availability, IRe
/// names chunks from a sequence that is not scoped per-hypertable in every version) — the oldest-chunk name
/// alone is not a reliable identity across a drop/re-create. The hypertable identity is, so the planner
/// must treat EITHER one changing as a reason to re-measure.
+ ///
+ /// #4957: every row also carries the database's OID, read from the same catalog round trip. A
+ /// database dropped and recreated under the same name, with the same layout, reproduces both names above
+ /// exactly (chunk ids and hypertable ids come from per-database sequences), and the floor cache is now held
+ /// per store, so the name alone would let a recreated database be served its predecessor's floors. A recreated
+ /// database gets a new OID, so the OID is the third half of the identity.
///
public static string? RollupOldestChunkSql(RollupAvailability availability)
{
@@ -9305,7 +9312,8 @@ public static string RollupCoverageProbeSql(RollupAvailability availability, IRe
ca.view_name,
oldest.chunk_name,
oldest.range_start,
- ca.materialization_hypertable_schema || '.' || ca.materialization_hypertable_name AS materialization_hypertable
+ ca.materialization_hypertable_schema || '.' || ca.materialization_hypertable_name AS materialization_hypertable,
+ (SELECT d.oid::bigint FROM pg_catalog.pg_database AS d WHERE d.datname = current_database()) AS database_oid
FROM timescaledb_information.continuous_aggregates AS ca
LEFT JOIN LATERAL (
SELECT c.chunk_name, c.range_start
@@ -9320,30 +9328,93 @@ LIMIT 1
}
///
- /// #4539/#4553: the per- rollup floor cache — keyed on the data source rather
- /// than held statically, so two stores' caches (two instances, one per store)
- /// never cross-pollute, and a disposed data source's entry is collected with it rather than leaking
- /// forever. for exactly that lifetime, and its own
- /// dictionary value is a plain (thread-unsynchronized) guarded by a
- /// lock — is called on a timer AND on demand from the viewer, so
- /// two calls can race on the same data source.
- private static readonly ConditionalWeakTable RollupFloorCaches = new();
+ /// #4957: the rollup floor cache, ONE PER STORE for the whole process — keyed on the store's identity
+ /// (: host, port and database from the connection string), not on the
+ /// . The service opens several data sources on one store (the collector's, the MCP
+ /// host's, the web host's), and the cache used to be one per data source, so each paid the cold
+ /// min(bucket) sort of every rollup's oldest compressed chunk on its own, about ten seconds on a store
+ /// with a long history, at the first call, after every failed probe and every hour.
+ ///
+ /// This keeps both goals #4539/#4553 held the per-data-source key for. No cross-store
+ /// pollution: two stores differ in host, port or database, so they never share an entry; and every entry
+ /// also records the database's OID (), so a database dropped and recreated
+ /// under the same name is never served its predecessor's floors even when the new one reproduces the old
+ /// chunk and hypertable names. Nothing grows without bound: there is one small entry per store this
+ /// process has opened, not one per data source or per call; a process that talks to a handful of stores
+ /// holds a handful of entries.
+ ///
+ /// The cache's own dictionary is a plain (thread-unsynchronized)
+ /// guarded by a lock — is called on a timer AND on demand from every
+ /// data source on the store, so calls race.
+ ///
+ private static readonly ConcurrentDictionary RollupFloorCaches = new(StringComparer.Ordinal);
+
+ ///
+ /// #4957: the key holds a store under: host (case-insensitive), port and database,
+ /// from 's connection string. The user, password and session options are left out on
+ /// purpose: the floors are a fact about the store, not about the role that read them. A connection string Npgsql
+ /// cannot parse falls back to itself, so the same string still shares one entry.
+ ///
+ internal static string RollupStoreKey(NpgsqlDataSource dataSource)
+ {
+ var connectionString = dataSource.ConnectionString;
+ try
+ {
+ var builder = new NpgsqlConnectionStringBuilder(connectionString);
+ var host = (builder.Host ?? string.Empty).Trim().ToLowerInvariant();
+ /* Npgsql opens the database named after the user when none is given. */
+ var database = builder.Database ?? builder.Username ?? string.Empty;
+ return string.Create(CultureInfo.InvariantCulture, $"{host}:{builder.Port}/{database}");
+ }
+ catch (Exception ex) when (ex is ArgumentException or FormatException)
+ {
+ return connectionString;
+ }
+ }
///
/// #4553: bounds how long a cached rollup floor may be reused even when its oldest chunk's identity has
/// not changed. A retention DELETE that runs against the oldest materialization chunk (raw data aging out
/// past its retention while the chunk itself is left in place) can move the true floor LATER without
/// changing the chunk's identity at all — the cache has no catalog signal for that. Re-measuring on a
- /// timer, independent of the chunk-identity check, bounds the staleness to at most this long. An hour
- /// still cuts the expensive re-sort from every five-minute probe cycle to at most once an hour.
+ /// timer, independent of the chunk-identity check, bounds the staleness (the bound itself, with the #4957
+ /// background pass, is stated in the paragraph below). An hour still cuts the expensive re-sort from every
+ /// five-minute probe cycle to at most once an hour.
+ ///
+ /// #4957: past the hour, up to , the caller does not wait for the
+ /// re-measure. A caller that finds an entry between one and two times this window old whose oldest chunk is
+ /// UNCHANGED is served the cached floor, and starts ONE background re-measure for the store (callers that arrive
+ /// while it runs start none), so the floor is replaced one probe after the first caller notices it is due. A
+ /// changed chunk, a different database or no entry at all still measures inline, because then the cached floor
+ /// is known to be wrong, not merely old. So does an entry two times this window old or older, however long the
+ /// store went without a caller: it is measured by that caller, as it was before the background pass existed.
+ /// The staleness bound is therefore at most two hours plus one probe's duration, whether or not callers
+ /// keep arriving. No floor is served once its entry is two hours old, and the probe is what the call that serves
+ /// a floor may still spend measuring its other views before it returns. The reused floor can only be EARLIER
+ /// than the true one by what a retention delete inside the oldest chunk removed in that time (a routing
+ /// over-claim of at most that much), which is the same exposure this safety net always bounded, now for up to
+ /// an hour and one probe longer.
///
internal static readonly TimeSpan RollupFloorMaxReuse = TimeSpan.FromHours(1);
- /// One data source's cached rollup floors, keyed by view name.
+ ///
+ /// #4957: the age at which a cached rollup floor is no longer served at all: twice .
+ /// An entry between the two is served from the cache while one background pass re-measures it; an entry this old or
+ /// older is measured inline, by the caller, as it was before the background pass existed. Derived from
+ /// rather than written as a second number, and declared AFTER it: static fields
+ /// initialize in textual order, so one declared above it would capture a zero.
+ ///
+ internal static readonly TimeSpan RollupFloorMaxServeAge = RollupFloorMaxReuse * 2;
+
+ /// One store's cached rollup floors, keyed by view name, plus the background re-measure (#4957).
private sealed class RollupFloorCache
{
public readonly object Lock = new();
public readonly Dictionary ByView = new(StringComparer.Ordinal);
+
+ /// The store's one background re-measure, started under . Non-null and incomplete
+ /// while it runs; a completed task is history and the next caller that finds an entry due may start another.
+ public Task? Remeasure;
}
///
@@ -9352,17 +9423,28 @@ private sealed class RollupFloorCache
/// the continuous aggregate mints a new materialization hypertable whose first chunk can, by coincidence,
/// land with the same generated chunk name a previous incarnation once had. Either identity changing means
/// the cached floor no longer describes the relation actually being read. backs
- /// .
+ /// . (#4957) is the third half of that identity:
+ /// the OID of the database the floor was measured in, so a database dropped and recreated under the same name
+ /// never matches the entry its predecessor left (0 when the catalog read did not return one).
///
/// (#4605) is the materialization ceiling measured in the SAME
/// cycle as — re-read whenever the floor is (the ceiling moves every refresh, so
/// piggybacking on the floor's re-measure cadence, rather than a separate TTL, is the cheap answer: a
/// view whose floor is trusted from cache almost never has a stale-enough ceiling to matter, and a
/// dedicated per-request re-read would be the expensive scan #4539 already exists to avoid).
- internal readonly record struct RollupFloorCacheEntry(string? ChunkName, string? MaterializationHypertable, DateTime Floor, DateTime MeasuredAtUtc, DateTime? Ceiling = null);
+ internal readonly record struct RollupFloorCacheEntry(string? ChunkName, string? MaterializationHypertable, DateTime Floor, DateTime MeasuredAtUtc, DateTime? Ceiling = null, long DatabaseOid = 0);
- /// The oldest-chunk catalog read's per-view identity, before any floor has been attached to it.
- internal readonly record struct RollupChunkIdentity(string? ChunkName, string? MaterializationHypertable);
+ /// The oldest-chunk catalog read's per-view identity, before any floor has been attached to it:
+ /// the oldest chunk's name, the materialization hypertable, and (#4957) the database's OID.
+ internal readonly record struct RollupChunkIdentity(string? ChunkName, string? MaterializationHypertable, long DatabaseOid = 0);
+
+ ///
+ /// #4957: which present views a coverage cycle must measure NOW (: the caller waits for
+ /// them) and which are due only because their cached floor is older than but
+ /// younger than (: the caller is served the cached
+ /// floor and a background re-measure replaces it).
+ ///
+ internal readonly record struct RollupFloorPlan(IReadOnlySet Inline, IReadOnlySet Background);
///
/// #4539/#4553 pure planner: which of 's present views must have
@@ -9383,7 +9465,29 @@ internal static IReadOnlySet RollupFloorsToMeasure(
RollupAvailability availability,
DateTime now)
{
- var measure = new HashSet(StringComparer.Ordinal);
+ var plan = PlanRollupFloorMeasurements(cached, oldestNow, availability, now);
+ var measure = new HashSet(plan.Inline, StringComparer.Ordinal);
+ measure.UnionWith(plan.Background);
+ return measure;
+ }
+
+ ///
+ /// #4957: 's decision, split by who waits. A view with no entry, a changed
+ /// identity (oldest chunk, materialization hypertable or database OID) or no chunk at all is
+ /// : its cached floor is wrong or absent. So is a view whose identity matches
+ /// but whose entry is old or older: that floor is too old to serve, however
+ /// long the store went without a caller. A view whose identity matches and whose entry is at least
+ /// but under old is
+ /// : the cached floor is only old.
+ ///
+ internal static RollupFloorPlan PlanRollupFloorMeasurements(
+ IReadOnlyDictionary cached,
+ IReadOnlyDictionary oldestNow,
+ RollupAvailability availability,
+ DateTime now)
+ {
+ var inline = new HashSet(StringComparer.Ordinal);
+ var background = new HashSet(StringComparer.Ordinal);
foreach (var (view, identity) in oldestNow)
{
if (!availability.Has(view))
@@ -9393,20 +9497,102 @@ internal static IReadOnlySet RollupFloorsToMeasure(
if (identity.ChunkName is null)
{
- measure.Add(view);
+ inline.Add(view);
continue;
}
if (!cached.TryGetValue(view, out var entry)
|| !string.Equals(entry.ChunkName, identity.ChunkName, StringComparison.Ordinal)
|| !string.Equals(entry.MaterializationHypertable, identity.MaterializationHypertable, StringComparison.Ordinal)
- || now - entry.MeasuredAtUtc >= RollupFloorMaxReuse)
+ || entry.DatabaseOid != identity.DatabaseOid)
+ {
+ inline.Add(view);
+ continue;
+ }
+
+ var age = now - entry.MeasuredAtUtc;
+ if (age >= RollupFloorMaxServeAge)
+ {
+ /* Too old to serve at all, however long the store went without a caller: measured by this caller. */
+ inline.Add(view);
+ }
+ else if (age >= RollupFloorMaxReuse)
{
- measure.Add(view);
+ background.Add(view);
}
}
- return measure;
+ return new RollupFloorPlan(inline, background);
+ }
+
+ ///
+ /// #4957: writes one coverage cycle's result into a store's cache, touching only what that cycle measured. A view
+ /// measured this cycle gets its new entry, or none when it came back empty; a view whose rollup is gone is
+ /// dropped; every other view is left as the cache holds it NOW. The cycle's result
+ /// was computed from a snapshot taken before the (slow) measure, so overwriting the whole cache with it would
+ /// let a cycle that started earlier undo a newer measurement made meanwhile by another data source or by the
+ /// background re-measure.
+ ///
+ internal static void ApplyRollupFloorMeasurements(
+ Dictionary cache,
+ IReadOnlyDictionary measuredThisCycle,
+ IReadOnlyDictionary newEntries,
+ RollupAvailability availability)
+ {
+ foreach (var view in measuredThisCycle.Keys)
+ {
+ if (newEntries.TryGetValue(view, out var entry))
+ {
+ cache[view] = entry;
+ }
+ else
+ {
+ cache.Remove(view);
+ }
+ }
+
+ foreach (var view in cache.Keys.Where(v => !availability.Has(v)).ToArray())
+ {
+ cache.Remove(view);
+ }
+ }
+
+ ///
+ /// #4957: starts the store's ONE background re-measure for the views only by age.
+ /// Does nothing while one is already running, and nothing when the cache's CURRENT entries show another call
+ /// already refreshed them (the planner read a snapshot from before that landed), so callers that arrive together
+ /// start one between them. The re-measure runs the same cycle with the age deferral off, on
+ /// (no caller owns it), and swallows every failure: the cached entries stay
+ /// and the next caller that finds them due tries again.
+ ///
+ private static void StartRollupRemeasure(
+ NpgsqlDataSource dataSource, RollupFloorCache cache, RollupAvailability availability, IReadOnlySet due, DateTime now)
+ {
+ lock (cache.Lock)
+ {
+ if (cache.Remeasure is { IsCompleted: false })
+ {
+ return;
+ }
+
+ if (!due.Any(view => cache.ByView.TryGetValue(view, out var entry) && now - entry.MeasuredAtUtc >= RollupFloorMaxReuse))
+ {
+ return;
+ }
+
+ cache.Remeasure = Task.Run(async () =>
+ {
+ try
+ {
+ await MeasureRollupCoverageAsync(dataSource, availability, now, deferPastTheHour: false, CancellationToken.None);
+ }
+ catch (Exception ex)
+ {
+ /* A failed re-measure leaves the cached floors in place; the next caller retries. */
+ _ = ex;
+ }
+ });
+ }
}
///
@@ -9419,9 +9605,10 @@ internal static IReadOnlySet RollupFloorsToMeasure(
/// and its cached floor is carried forward unchanged — UNLESS the rollup itself is no longer present, in
/// which case its stale entry is dropped rather than carried forward.
///
- /// The returned NewCacheEntries is the COMPLETE replacement for the cache's ByView —
- /// the caller must overwrite, not merge, or an evicted view's stale entry would survive under whatever key
- /// this function chose not to re-emit.
+ /// The returned NewCacheEntries is the COMPLETE replacement for the snapshot cached was
+ /// taken from. #4957: the cache itself is written through , which
+ /// applies it for the views this cycle measured (an evicted view is removed, not left to survive) and leaves
+ /// the rest as the cache holds them now, so a slower cycle cannot undo a newer measurement.
///
internal static (IReadOnlyDictionary Floors, IReadOnlyDictionary NewCacheEntries) MergeRollupFloors(
IReadOnlyDictionary measuredThisCycle,
@@ -9458,7 +9645,7 @@ internal static (IReadOnlyDictionary Floors, IReadOnlyDictiona
DateTime? ceiling = ceilingsMeasuredThisCycle is not null && ceilingsMeasuredThisCycle.TryGetValue(view, out var freshCeiling)
? freshCeiling
: null;
- newEntries[view] = new RollupFloorCacheEntry(identity.ChunkName, identity.MaterializationHypertable, floor, now, ceiling);
+ newEntries[view] = new RollupFloorCacheEntry(identity.ChunkName, identity.MaterializationHypertable, floor, now, ceiling, identity.DatabaseOid);
}
}
@@ -9520,22 +9707,31 @@ public static Task DetectRollupCoverageAsync(
/// measured against injectable for the TTL check () rather than always
/// reading — so a live test can force a TTL expiry without sleeping an hour.
///
- internal static async Task DetectRollupCoverageAsync(
+ internal static Task DetectRollupCoverageAsync(
NpgsqlDataSource dataSource, RollupAvailability availability, DateTime now, CancellationToken cancellationToken = default)
+ => MeasureRollupCoverageAsync(dataSource, availability, now, deferPastTheHour: true, cancellationToken);
+
+ ///
+ /// The coverage cycle behind .
+ /// (#4957) is true for a caller: a view whose cached floor is OLDER than
+ /// but younger than (its oldest chunk,
+ /// materialization hypertable and database are unchanged) keeps its cached floor for this call and is re-measured
+ /// by one background cycle for the store. One old or older is measured by this
+ /// call either way. It is false for that background cycle itself, which measures every due view.
+ ///
+ private static async Task MeasureRollupCoverageAsync(
+ NpgsqlDataSource dataSource, RollupAvailability availability, DateTime now, bool deferPastTheHour, CancellationToken cancellationToken)
{
if (dataSource is null)
{
throw new ArgumentNullException(nameof(dataSource));
}
- var cache = RollupFloorCaches.GetOrCreateValue(dataSource);
+ var cache = RollupFloorCaches.GetOrAdd(RollupStoreKey(dataSource), static _ => new RollupFloorCache());
HashSet? measure = null;
+ HashSet? due = null;
Dictionary? oldestNow = null;
- Dictionary cachedSnapshot;
- lock (cache.Lock)
- {
- cachedSnapshot = new Dictionary(cache.ByView, StringComparer.Ordinal);
- }
+ var cachedSnapshot = new Dictionary(StringComparer.Ordinal);
var oldestChunkSql = RollupOldestChunkSql(availability);
if (oldestChunkSql is not null)
@@ -9551,10 +9747,25 @@ internal static async Task DetectRollupCoverageAsync(
var view = chunkReader.GetString(0);
var chunkName = await chunkReader.IsDBNullAsync(1, cancellationToken) ? null : chunkReader.GetString(1);
var materializationHypertable = await chunkReader.IsDBNullAsync(3, cancellationToken) ? null : chunkReader.GetString(3);
- chunksNow[view] = new RollupChunkIdentity(chunkName, materializationHypertable);
+ var databaseOid = await chunkReader.IsDBNullAsync(4, cancellationToken) ? 0L : chunkReader.GetInt64(4);
+ chunksNow[view] = new RollupChunkIdentity(chunkName, materializationHypertable, databaseOid);
}
- var toMeasure = new HashSet(RollupFloorsToMeasure(cachedSnapshot, chunksNow, availability, now), StringComparer.Ordinal);
+ lock (cache.Lock)
+ {
+ cachedSnapshot = new Dictionary(cache.ByView, StringComparer.Ordinal);
+ }
+
+ var plan = PlanRollupFloorMeasurements(cachedSnapshot, chunksNow, availability, now);
+ var toMeasure = new HashSet(plan.Inline, StringComparer.Ordinal);
+ if (deferPastTheHour)
+ {
+ due = new HashSet(plan.Background, StringComparer.Ordinal);
+ }
+ else
+ {
+ toMeasure.UnionWith(plan.Background);
+ }
/* Every present view this catalog read never saw (should not happen, but a partial read from a
mid-build store is exactly what #1664 exists to tolerate) is measured too — the safe default
@@ -9574,6 +9785,7 @@ when this cache has no evidence either way. */
{
measure = null;
oldestNow = null;
+ due = null;
}
}
@@ -9644,11 +9856,7 @@ when this cache has no evidence either way. */
lock (cache.Lock)
{
- cache.ByView.Clear();
- foreach (var (view, entry) in newEntries)
- {
- cache.ByView[view] = entry;
- }
+ ApplyRollupFloorMeasurements(cache.ByView, measuredThisCycle, newEntries, availability);
}
var ceilings = new Dictionary(StringComparer.Ordinal);
@@ -9660,6 +9868,12 @@ when this cache has no evidence either way. */
}
}
+ if (due is { Count: > 0 })
+ {
+ /* #4957: these floors were served from the cache; the store's one background re-measure replaces them. */
+ StartRollupRemeasure(dataSource, cache, availability, due, now);
+ }
+
return new RollupCoverage(floors, rawOldest, availability, ceilings);
}
diff --git a/Darling/PerformanceMonitor.Darling.Viewer/MainWindow.xaml.cs b/Darling/PerformanceMonitor.Darling.Viewer/MainWindow.xaml.cs
index 9f802129ca..2cf12f903b 100644
--- a/Darling/PerformanceMonitor.Darling.Viewer/MainWindow.xaml.cs
+++ b/Darling/PerformanceMonitor.Darling.Viewer/MainWindow.xaml.cs
@@ -432,6 +432,11 @@ operator now looks first — sending them to fix a role they never needed to tou
return;
}
+ /* #4957: measure the rollup floors in the background now that the store has answered, so the first tab that
+ routes by them does not wait on the cold sort. Fire and forget: the warm never throws and changes nothing
+ else when it fails. */
+ _ = _dataService.WarmRollupCoverageAsync();
+
/* A read-only seat cannot command the service, so "Generate now" (analyze_now) is disabled. */
RecommendationsGenerateButton.IsEnabled = !_dataService.IsReadOnly;
diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.RollupAvailability.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.RollupAvailability.cs
index e0913a9540..9f878f8886 100644
--- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.RollupAvailability.cs
+++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.RollupAvailability.cs
@@ -72,4 +72,15 @@ public sealed partial class ViewerDataService
_rollupProbeAtUtc = DateTime.UtcNow;
return (_rollups, _rollupCoverage);
}
+
+ ///
+ /// #4957: measures each rollup's coverage floor in the background shortly after the Viewer opens its store, so
+ /// the first routed read (Overview, Queries, FinOps and the rest all go through
+ /// ) does not wait on the cold min(bucket) sort of each rollup's
+ /// oldest compressed chunk. The Viewer is its own process, so the service's warm does not reach it. Fire and
+ /// forget: never throws, and a failed warm changes nothing
+ /// here (this method does not touch the Viewer's own cached availability answer).
+ ///
+ internal Task WarmRollupCoverageAsync(CancellationToken cancellationToken = default)
+ => RollupCoverageWarmup.RunDelayedAsync(_dataSource, logger: null, RollupCoverageWarmup.ViewerStartDelay, cancellationToken);
}
From d39b9224513bd851ea0e60925ae013cfb8fe175d Mon Sep 17 00:00:00 2001
From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com>
Date: Fri, 2 Oct 2026 19:07:46 -0400
Subject: [PATCH 009/399] CI: the review and its guard skip a draft pull
request and run when it is marked ready for review (#4973)
The Claude review and its guard now skip a pull request while it is a draft. Both also run when a pull request is marked ready for review, so a draft gets its first review then.
---
.github/workflows/claude-review-guard.yml | 6 +++++-
.github/workflows/claude-review.yml | 9 +++++++--
2 files changed, 12 insertions(+), 3 deletions(-)
diff --git a/.github/workflows/claude-review-guard.yml b/.github/workflows/claude-review-guard.yml
index 2832936f7f..5cf8a480c7 100644
--- a/.github/workflows/claude-review-guard.yml
+++ b/.github/workflows/claude-review-guard.yml
@@ -46,7 +46,9 @@ name: Claude review guard
# condition this check exists to make loud, and a 503 must not wave one through.
on:
pull_request:
- types: [opened, synchronize, reopened]
+ # A draft is skipped, as claude-review.yml skips it: with no review to wait for, a draft run could
+ # only fail. Marking the pull request ready for review runs both.
+ types: [opened, synchronize, reopened, ready_for_review]
branches: [dev, main]
# Match the review's own cancel-in-progress behavior. A new push cancels the review it is watching,
@@ -63,6 +65,8 @@ jobs:
# satisfy (#3492's first CI cycle sat green-but-BLOCKED on exactly that). The name below is the
# enforcement contract: change it and the required context in branch protection must move with it.
name: Claude review guard
+ # Skips while the pull request is a draft, as claude-review.yml does: there is no review to wait for.
+ if: github.event.pull_request.draft == false
runs-on: ubuntu-latest
# The review is capped at 30 minutes; leave room to observe one that uses all of it.
timeout-minutes: 40
diff --git a/.github/workflows/claude-review.yml b/.github/workflows/claude-review.yml
index 46d917b62e..6ca5b103ab 100644
--- a/.github/workflows/claude-review.yml
+++ b/.github/workflows/claude-review.yml
@@ -1,6 +1,7 @@
name: Claude Auto Review
-# Reviews every pull request into dev or main when it is opened or updated. The review posts
+# Reviews every pull request into dev or main when it is opened, updated or marked ready for
+# review, but never while it is a draft. The review posts
# comments for its findings and ends every run with ONE formal verdict review (the VERDICT
# PROTOCOL in the prompt below): changes-requested when a finding is substantive, a comment
# review otherwise. This workflow itself is still deliberately NOT a required status check --
@@ -40,7 +41,9 @@ name: Claude Auto Review
# accounting knows or cares which tier ran.
on:
pull_request:
- types: [opened, synchronize, reopened]
+ # A draft gets no review: the job below skips while the pull request is a draft, and marking it
+ # ready for review starts one.
+ types: [opened, synchronize, reopened, ready_for_review]
branches: [dev, main]
# Cancel an in-flight review when new commits are pushed to the same PR.
@@ -50,6 +53,8 @@ concurrency:
jobs:
review:
+ # No review while the pull request is a draft (see the trigger above).
+ if: github.event.pull_request.draft == false
runs-on: ubuntu-latest
# Reviews are advisory and typically finish in minutes; a wedged run should release
# its runner rather than sit on the default 6h timeout.
From 8c6a299bd52cdebfec83de27dbfa89d6f029bd6f Mon Sep 17 00:00:00 2001
From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com>
Date: Fri, 2 Oct 2026 19:25:39 -0400
Subject: [PATCH 010/399] MCP tools refuse a value the parameter's type cannot
hold, by name, in Lite and the Darling service (#4960)
MCP tool calls whose arguments the server cannot read, such as a whole number past the parameter's range, null for a number, a number sent for text or text sent for true or false, are now refused with a message that names the argument, instead of the generic invocation error. The shared call-tool guard checks each value against the type the tool declares for that parameter, in both Lite and the Darling service, and a census test keeps that record of declared types in step with the schema each tool serves.
Closes #4956.
---
.../DarlingMcpHostGateLiveTests.cs | 37 ++
Darling/Darling.Tests/McpArgumentTypeRows.cs | 420 ++++++++++++++++++
Darling/Darling.Tests/McpArgumentTypeTests.cs | 102 +++++
Darling/Darling.Tests/McpInProcessHost.cs | 9 +
.../McpUnknownArgumentGuardTests.cs | 245 ++++++++++
.../McpWholeNumberArgumentTests.cs | 51 ++-
.../Darling.Tests/WebServerPageRangeTests.cs | 41 +-
.../web-kept-history-harness.mjs | 22 +-
Lite.Tests/Lite.Tests.csproj | 3 +
Lite.Tests/McpArgumentTypeTests.cs | 99 +++++
Lite.Tests/McpWholeNumberArgumentTests.cs | 41 +-
PerformanceMonitor.Common/Mcp/McpHelpers.cs | 4 +-
.../Mcp/McpSchemaCompat.cs | 63 ++-
.../Mcp/McpToolParameterTypes.cs | 338 ++++++++++++++
.../Mcp/McpUnknownArgumentGuard.cs | 165 +++++--
15 files changed, 1570 insertions(+), 70 deletions(-)
create mode 100644 Darling/Darling.Tests/McpArgumentTypeRows.cs
create mode 100644 Darling/Darling.Tests/McpArgumentTypeTests.cs
create mode 100644 Lite.Tests/McpArgumentTypeTests.cs
create mode 100644 PerformanceMonitor.Common/Mcp/McpToolParameterTypes.cs
diff --git a/Darling/Darling.Tests/DarlingMcpHostGateLiveTests.cs b/Darling/Darling.Tests/DarlingMcpHostGateLiveTests.cs
index e54d09c6f3..45c1081b2c 100644
--- a/Darling/Darling.Tests/DarlingMcpHostGateLiveTests.cs
+++ b/Darling/Darling.Tests/DarlingMcpHostGateLiveTests.cs
@@ -264,6 +264,43 @@ public async Task Core_ToolsCall_OutsideThePinnedSet_GetsTheSdkUnknownToolError(
Assert.Contains("Unknown tool", error.GetProperty("message").GetString(), StringComparison.OrdinalIgnoreCase);
}
+ /// A tools/call that sends as the call's arguments object.
+ private static Task<(int StatusCode, string Body)> ToolsCallWithArgumentsAsync(
+ TestServer server, string path, string host, IPAddress remote, string toolName, string argumentsJson)
+ => SendJsonRpcAsync(
+ server, path, host, remote, "tools/call", $"{{\"name\":\"{toolName}\",\"arguments\":{argumentsJson}}}", bearer: null);
+
+ ///
+ /// The argument guard's typed refusal reaches a caller over the stateless HTTP pipeline the product ships. The guard
+ /// takes its record of each tool's parameter types from the request's services, and the in-process server the other
+ /// pins run on hands it those, so only a call made like this one shows that a real client's request resolves it too.
+ /// A null for hours_back is the proof: the schema says "integer" and does not say which parameters take a
+ /// null, so only the typed path refuses it. With the record missing the null would reach the SDK's binder, which
+ /// answers "An error occurred invoking ..." and names neither the argument nor the reason.
+ ///
+ [Fact]
+ public async Task ToolsCall_ANullForAWholeNumber_IsRefusedByName_OverTheShippedHttpPipeline()
+ {
+ using var server = await BuildServer(networkMode: false);
+
+ var (statusCode, body) = await ToolsCallWithArgumentsAsync(
+ server, "/", "localhost", IPAddress.Loopback, "get_alert_history", "{\"hours_back\":null}");
+ Assert.Equal(StatusCodes.Status200OK, statusCode);
+
+ var result = ReadJsonRpcResult(body);
+ Assert.True(result.GetProperty("isError").GetBoolean(), $"expected an error result, got: {body}");
+ var text = result.GetProperty("content").EnumerateArray().Single().GetProperty("text").GetString() ?? "";
+ Assert.True(
+ PerformanceMonitor.Common.McpHelpers.IsRefusalEnvelope(text), $"expected the argument guard's refusal, got: {text}");
+
+ using var refusal = JsonDocument.Parse(text);
+ Assert.Equal("hours_back", refusal.RootElement.GetProperty("hints").GetProperty("parameter").GetString());
+ Assert.Contains(
+ "Argument 'hours_back' for tool 'get_alert_history' takes a whole number of hours, and cannot be null, and the call sent null.",
+ refusal.RootElement.GetProperty("message").GetString(),
+ StringComparison.Ordinal);
+ }
+
private static JsonElement ReadJsonRpcResult(string body)
{
using var document = JsonDocument.Parse(ExtractJsonPayload(body));
diff --git a/Darling/Darling.Tests/McpArgumentTypeRows.cs b/Darling/Darling.Tests/McpArgumentTypeRows.cs
new file mode 100644
index 0000000000..7639f17799
--- /dev/null
+++ b/Darling/Darling.Tests/McpArgumentTypeRows.cs
@@ -0,0 +1,420 @@
+/*
+ * 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.ComponentModel;
+using System.Linq;
+using System.Reflection;
+using System.Text.Json;
+using System.Threading;
+using System.Threading.Tasks;
+using ModelContextProtocol.Protocol;
+using ModelContextProtocol.Server;
+using PerformanceMonitor.Common;
+using Xunit;
+
+namespace Darling.Tests;
+
+///
+/// A test-only tool with a parameter of each CLR type that a product's shipped tools do not declare: ,
+/// , , an integer array and a nullable (found by reflection
+/// over the tool types of both products), and and long? for Lite. Darling's tools declare
+/// both. Lite declares no long?, and its one (plan_id on
+/// analyze_query_store_plan) comes with a second required argument (database_name), so the runner
+/// passes that tool over. The in-process host registers it through the same
+/// McpSchemaCompat.WithGeminiCompatibleTools path as the shipped tools, so the call-tool guard meets it the same
+/// way. It carries no McpServerToolType attribute, so no census over the product's tool types sees it. It also
+/// declares a , which the SDK supplies and the schema leaves out, so the check that the
+/// record holds nothing the schema does not advertise always has such a parameter to see, whatever the shipped tools
+/// declare.
+///
+internal static class McpArgumentTypeProbeTools
+{
+ [McpServerTool(Name = "probe_argument_types")]
+ [Description("Test-only tool: one parameter of each CLR type no shipped tool declares.")]
+ public static string Probe(
+ [Description("A short.")] short small = 0,
+ [Description("A byte.")] byte tiny = 0,
+ [Description("A ulong.")] ulong huge = 0,
+ [Description("An integer array.")] int[]? ids = null,
+ [Description("A nullable bool.")] bool? maybe = null,
+ [Description("A long.")] long big = 0,
+ [Description("A nullable long.")] long? maybeBig = null,
+ CancellationToken cancellationToken = default) => "ok";
+}
+
+///
+/// The table behind the per-type argument pins, shared by both products. Each row is one value sent for one CLR type,
+/// and says whether the SDK's binder can read it. The runner checks BOTH halves of every row: with the guard left out,
+/// the binder throws on exactly the rows marked refused (so the table states what the binder does, not what the guard
+/// hopes), and with the guard in, those rows are refused by a message that names the argument and says what it takes,
+/// while every other row is left alone.
+///
+/// The tool and parameter for a row are found by reflection over the product's tool types: the first tool that
+/// declares a parameter of that CLR type and needs no other argument, taking the tool types in the order the host
+/// registers them and each type's methods by method name, and passing over a tool whose name says it changes data
+/// (delete_, create_ or update_), since a row that sends a value the binder reads runs the tool.
+/// A type no shipped tool declares falls through to
+/// .
+///
+internal static class McpArgumentTypeRows
+{
+ private static readonly Dictionary s_types = new(StringComparer.Ordinal)
+ {
+ ["int"] = typeof(int),
+ ["int?"] = typeof(int?),
+ ["long"] = typeof(long),
+ ["long?"] = typeof(long?),
+ ["short"] = typeof(short),
+ ["byte"] = typeof(byte),
+ ["ulong"] = typeof(ulong),
+ ["bool"] = typeof(bool),
+ ["bool?"] = typeof(bool?),
+ ["string"] = typeof(string),
+ ["int[]"] = typeof(int[]),
+ ["double"] = typeof(double),
+ ["double?"] = typeof(double?),
+ ["string[]"] = typeof(string[]),
+ };
+
+ /// One row per (type, value): the CLR type, the JSON value as sent, whether the binder cannot read it, and
+ /// the phrases the refusal must say, separated by '|' (empty for a value the binder reads).
+ public static TheoryData All()
+ {
+ var data = new TheoryData();
+ foreach (var row in Table())
+ {
+ data.Add(row.Type, row.Raw, row.Refused, row.Says);
+ }
+
+ return data;
+ }
+
+ /// The CLR types the table has at least one row for.
+ public static HashSet CoveredTypeKeys() => Table().Select(row => row.Type).ToHashSet(StringComparer.Ordinal);
+
+ /// The table's key for each CLR type the host advertises a parameter of, across the shipped tool types.
+ public static SortedSet ShippedParameterTypeKeys(McpInProcessHost host, IEnumerable toolTypes)
+ {
+ var served = host.RegisteredTools.ToDictionary(tool => tool.ProtocolTool.Name, StringComparer.Ordinal);
+ var keys = new SortedSet(StringComparer.Ordinal);
+
+ foreach (var method in toolTypes.SelectMany(
+ type => type.GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static)))
+ {
+ if (method.GetCustomAttribute() is not { } attribute
+ || !served.TryGetValue(attribute.Name ?? method.Name, out var tool)
+ || !tool.ProtocolTool.InputSchema.TryGetProperty("properties", out var properties))
+ {
+ continue;
+ }
+
+ foreach (var parameter in method.GetParameters().Where(p => properties.TryGetProperty(p.Name!, out _)))
+ {
+ keys.Add(s_types.FirstOrDefault(entry => entry.Value == parameter.ParameterType).Key
+ ?? parameter.ParameterType.ToString());
+ }
+ }
+
+ return keys;
+ }
+
+ ///
+ /// What is wrong with the types the host recorded for its tools, or an empty list. Every parameter a tool
+ /// advertises has a recorded type, a type that agrees with the schema's word for it (an "integer" is an integer
+ /// type, a "boolean" is a bool), and an integer parameter refuses null exactly when its type cannot take one.
+ /// A tool the record misses would quietly fall back to the schema-only check.
+ ///
+ public static List RecordedTypeProblems(McpInProcessHost host)
+ {
+ var types = host.ParameterTypes;
+ var problems = new List();
+
+ foreach (var tool in host.RegisteredTools)
+ {
+ var name = tool.ProtocolTool.Name;
+ if (!tool.ProtocolTool.InputSchema.TryGetProperty("properties", out var properties) || !properties.EnumerateObject().Any())
+ {
+ continue;
+ }
+
+ if (!types.TryGet(name, out var declared))
+ {
+ problems.Add($"{name}: no parameter types were recorded");
+ continue;
+ }
+
+ foreach (var property in properties.EnumerateObject())
+ {
+ if (!declared.TryGetValue(property.Name, out var parameter))
+ {
+ problems.Add($"{name}.{property.Name}: advertised, but no type was recorded");
+ continue;
+ }
+
+ var clr = Nullable.GetUnderlyingType(parameter.Type) ?? parameter.Type;
+ /* An enum is not a whole number here, as in McpArgumentValueCheck.Bounds: Type.GetTypeCode answers an
+ enum's underlying integer type. */
+ var integral = !clr.IsEnum && Type.GetTypeCode(clr) is TypeCode.SByte or TypeCode.Byte or TypeCode.Int16 or TypeCode.UInt16
+ or TypeCode.Int32 or TypeCode.UInt32 or TypeCode.Int64 or TypeCode.UInt64;
+ var schemaType = property.Value.TryGetProperty("type", out var type)
+ ? type.ValueKind == JsonValueKind.String
+ ? type.GetString()
+ : type.EnumerateArray().Select(entry => entry.GetString()).FirstOrDefault(word => word != "null")
+ : null;
+
+ if ((schemaType == "integer") != integral || (schemaType == "boolean" && clr != typeof(bool)))
+ {
+ problems.Add($"{name}.{property.Name}: the schema says {schemaType}, the recorded type is {parameter.Type}");
+ }
+
+ if (schemaType == "integer")
+ {
+ var call = new CallToolRequestParams
+ {
+ Name = name,
+ Arguments = new Dictionary { [property.Name] = JsonDocument.Parse("null").RootElement.Clone() },
+ };
+
+ var refused = McpUnknownArgumentGuard.Refuse(call, tool, types) is not null;
+ if (refused == parameter.AllowsNull)
+ {
+ problems.Add(
+ $"{name}.{property.Name}: null is {(refused ? "refused" : "passed")}, but the recorded type {parameter.Type} "
+ + (parameter.AllowsNull ? "takes null" : "does not take null"));
+ }
+ }
+ }
+ }
+
+ return problems;
+ }
+
+ ///
+ /// What the host recorded for a tool that the tool's schema does not advertise, or an empty list: the reverse of
+ /// . The record holds only what a caller may send, so a parameter the schema
+ /// leaves out (a cancellation token the SDK supplies, a service resolved from DI) has no place in it, whatever the
+ /// tool method declares.
+ ///
+ public static List RecordedButNotAdvertisedProblems(McpInProcessHost host)
+ {
+ var types = host.ParameterTypes;
+ var problems = new List();
+
+ foreach (var tool in host.RegisteredTools)
+ {
+ var name = tool.ProtocolTool.Name;
+ if (!types.TryGet(name, out var recorded))
+ {
+ continue;
+ }
+
+ var advertised = tool.ProtocolTool.InputSchema.TryGetProperty("properties", out var properties)
+ ? properties.EnumerateObject().Select(property => property.Name).ToHashSet(StringComparer.Ordinal)
+ : new HashSet(StringComparer.Ordinal);
+
+ problems.AddRange(recorded.Keys
+ .Where(parameter => !advertised.Contains(parameter))
+ .Select(parameter => $"{name}.{parameter}: recorded, but the schema does not advertise it"));
+ }
+
+ return problems;
+ }
+
+ private sealed class RowList : List<(string Type, string Raw, bool Refused, string Says)>
+ {
+ public void Add(string type, string raw, bool refused, string says) => Add((type, raw, refused, says));
+ }
+
+ private static List<(string Type, string Raw, bool Refused, string Says)> Table()
+ {
+ var rows = new RowList();
+
+ /* An int past int range, which the guard used to read as a long and pass. */
+ rows.Add("int", "3000000000", true, "whole number|too large");
+ rows.Add("int", "-3000000000", true, "whole number|too small");
+ rows.Add("int", "\"3000000000\"", true, "whole number|too large");
+ rows.Add("int", "2147483647", false, "");
+ rows.Add("int", "\"5\"", false, "");
+ rows.Add("int", "2.5", true, "whole number|no decimal point");
+ rows.Add("int?", "3000000000", true, "whole number|too large");
+ rows.Add("int?", "null", false, "");
+ rows.Add("long", "9223372036854775807", false, "");
+ rows.Add("long", "9223372036854775808", true, "whole number|too large");
+ rows.Add("long?", "null", false, "");
+
+ /* null for a parameter that is not nullable. */
+ rows.Add("int", "null", true, "whole number|cannot be null");
+ rows.Add("long", "null", true, "whole number|cannot be null");
+ rows.Add("bool", "null", true, "true or false|cannot be null");
+
+ /* short and byte past their range. */
+ rows.Add("short", "32767", false, "");
+ rows.Add("short", "32768", true, "whole number|too large");
+ rows.Add("short", "-32769", true, "whole number|too small");
+ rows.Add("short", "\"7\"", false, "");
+ rows.Add("byte", "255", false, "");
+ rows.Add("byte", "256", true, "whole number|too large");
+ rows.Add("byte", "-1", true, "whole number|too small");
+
+ /* A ulong: past long range but within its own is read, past its own is not. */
+ rows.Add("ulong", "18446744073709551615", false, "");
+ rows.Add("ulong", "9223372036854775808", false, "");
+ rows.Add("ulong", "18446744073709551616", true, "whole number|too large");
+ rows.Add("ulong", "-1", true, "whole number|too small");
+ /* Zero with a minus sign is no smaller than zero, but an unsigned type does not read the sign. */
+ rows.Add("ulong", "-0", true, "whole number|too small");
+
+ /* Integer arrays. */
+ rows.Add("int[]", "[1,2,3]", false, "");
+ rows.Add("int[]", "[]", false, "");
+ rows.Add("int[]", "null", false, "");
+ rows.Add("int[]", "[\"1\",\"2\"]", false, "");
+ rows.Add("int[]", "[1,2.5]", true, "list|whole numbers");
+ rows.Add("int[]", "[3000000000]", true, "list|whole numbers");
+ rows.Add("int[]", "[null]", true, "list|whole numbers");
+ rows.Add("int[]", "\"1,2,3\"", true, "list|whole numbers");
+ rows.Add("int[]", "5", true, "list|whole numbers");
+
+ /* A number, a boolean or a container sent to a string. */
+ rows.Add("string", "5", true, "text");
+ rows.Add("string", "true", true, "text");
+ rows.Add("string", "[\"a\"]", true, "text");
+ rows.Add("string", "{}", true, "text");
+ rows.Add("string", "\"x\"", false, "");
+ rows.Add("string", "null", false, "");
+
+ /* A string, or a number, sent to a boolean. */
+ rows.Add("bool", "\"yes\"", true, "true or false");
+ rows.Add("bool", "\"true\"", true, "true or false");
+ rows.Add("bool", "1", true, "true or false");
+ rows.Add("bool", "true", false, "");
+ rows.Add("bool", "false", false, "");
+ rows.Add("bool?", "null", false, "");
+ rows.Add("bool?", "\"yes\"", true, "true or false");
+
+ /* A floating-point parameter, nullable and not. */
+ rows.Add("double?", "2.5", false, "");
+ rows.Add("double?", "\"2.5\"", false, "");
+ rows.Add("double?", "\"abc\"", true, "number");
+ rows.Add("double?", "true", true, "number");
+ rows.Add("double", "2.5", false, "");
+ rows.Add("double", "\"abc\"", true, "number");
+ rows.Add("double", "null", true, "number|cannot be null");
+
+ /* A list of text. */
+ rows.Add("string[]", "[\"a\",\"b\"]", false, "");
+ rows.Add("string[]", "null", false, "");
+ rows.Add("string[]", "[\"a\",5]", true, "list|text");
+ rows.Add("string[]", "\"a\"", true, "list|text");
+
+ return rows;
+ }
+
+ ///
+ /// Runs one row against a host with the guard in and a host with it left out. lists
+ /// the shipped tool types first and the probe tool last, so a shipped tool is preferred.
+ ///
+ public static async Task CheckAsync(
+ McpInProcessHost guarded, McpInProcessHost unguarded, IReadOnlyCollection toolTypes,
+ string typeKey, string raw, bool refused, string says, CancellationToken cancellationToken)
+ {
+ var (tool, parameter) = Resolve(guarded, toolTypes, s_types[typeKey]);
+ var arguments = new Dictionary { [parameter] = JsonDocument.Parse(raw).RootElement.Clone() };
+ var label = $"{tool}.{parameter} ({typeKey}) <- {raw}";
+
+ /* Half one: the binder on its own. The row says what it does; this is what pins that. */
+ var before = unguarded.ToolExceptions.Entries.Count;
+ await unguarded.Client.CallToolAsync(tool, arguments, cancellationToken: cancellationToken);
+ var bound = !unguarded.ToolExceptions.BindingFailureSince(before);
+ Assert.True(
+ bound != refused,
+ refused
+ ? $"{label}: the row says the binder cannot read this, but the call bound without a JSON error"
+ : $"{label}: the row says the binder reads this, but it threw: " + unguarded.ToolExceptions.Describe());
+
+ /* Half two: the guard in front of it. */
+ var result = await guarded.Client.CallToolAsync(tool, arguments, cancellationToken: cancellationToken);
+ var text = McpInProcessHost.TextOf(result);
+ var refusal = McpInProcessHost.RefusalMessage(result);
+ var guardRefused = refusal is not null && refusal.Contains("refused before it ran", StringComparison.Ordinal);
+
+ if (!refused)
+ {
+ Assert.False(guardRefused, $"{label}: the guard refused a value the binder reads -> {text}");
+ return;
+ }
+
+ Assert.True(guardRefused, $"{label}: the guard passed a value the binder cannot read, so the SDK answered -> {text}");
+
+ foreach (var phrase in says.Split('|', StringSplitOptions.RemoveEmptyEntries))
+ {
+ var problem = McpInProcessHost.WholeNumberRefusalProblem(tool, parameter, phrase, result);
+ Assert.True(problem is null, $"{label}: {problem}");
+ }
+
+ if (raw.Length <= 40)
+ {
+ Assert.True(refusal!.Contains(raw, StringComparison.Ordinal), $"{label}: the refusal does not quote the value -> {refusal}");
+ }
+ }
+
+ /// The first tool with a parameter of the CLR type that the host advertises and that needs no other
+ /// argument, so a call sending only that one argument reaches the binder for it alone. The walk takes the tool types
+ /// in the order the caller gives them (both products' test lists sort them by full type name) and each type's
+ /// methods by method name, not by tool name. A tool that changes data is passed over, by its name (delete_,
+ /// create_ or update_): a row that sends a value the binder reads runs the tool, and a row should not
+ /// run a write. The name check covers those three prefixes only, so a write tool named otherwise
+ /// (add_servers, remove_server, set_mute_rule_enabled, mute_analysis_finding) is not
+ /// passed over. What keeps any tool body from reaching a real store is the test host's stand-in services, not the
+ /// name check: Darling's data source points at a closed port, and Lite's services are uninitialized instances with
+ /// every field null.
+ private static (string Tool, string Parameter) Resolve(McpInProcessHost host, IEnumerable toolTypes, Type clr)
+ {
+ var served = host.RegisteredTools.ToDictionary(tool => tool.ProtocolTool.Name, StringComparer.Ordinal);
+
+ foreach (var toolType in toolTypes)
+ {
+ foreach (var method in toolType
+ .GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static)
+ .OrderBy(method => method.Name, StringComparer.Ordinal))
+ {
+ if (method.GetCustomAttribute() is not { } attribute
+ || !served.TryGetValue(attribute.Name ?? method.Name, out var tool)
+ || ChangesData(tool.ProtocolTool.Name))
+ {
+ continue;
+ }
+
+ var schema = tool.ProtocolTool.InputSchema;
+ var advertised = schema.TryGetProperty("properties", out var properties)
+ ? properties.EnumerateObject().Select(property => property.Name).ToHashSet(StringComparer.Ordinal)
+ : new HashSet(StringComparer.Ordinal);
+ var required = schema.TryGetProperty("required", out var requiredNames)
+ ? requiredNames.EnumerateArray().Select(name => name.GetString()!).ToHashSet(StringComparer.Ordinal)
+ : new HashSet(StringComparer.Ordinal);
+
+ foreach (var parameter in method.GetParameters().Where(p => p.ParameterType == clr && advertised.Contains(p.Name!)))
+ {
+ if (required.All(name => name == parameter.Name))
+ {
+ return (tool.ProtocolTool.Name, parameter.Name!);
+ }
+ }
+ }
+ }
+
+ throw new InvalidOperationException($"No tool declares a parameter of type {clr}.");
+ }
+
+ private static bool ChangesData(string toolName) =>
+ toolName.StartsWith("delete_", StringComparison.Ordinal)
+ || toolName.StartsWith("create_", StringComparison.Ordinal)
+ || toolName.StartsWith("update_", StringComparison.Ordinal);
+}
diff --git a/Darling/Darling.Tests/McpArgumentTypeTests.cs b/Darling/Darling.Tests/McpArgumentTypeTests.cs
new file mode 100644
index 0000000000..bf6f09be76
--- /dev/null
+++ b/Darling/Darling.Tests/McpArgumentTypeTests.cs
@@ -0,0 +1,102 @@
+/*
+ * 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.Threading.Tasks;
+using Xunit;
+
+namespace Darling.Tests;
+
+///
+/// The call-tool guard checks each argument against the CLR type of its parameter, not only against the schema's word
+/// "integer". The schema cannot tell an int from a long or a byte, and says nothing of
+/// nullability, so a value the binder cannot read (an int past int range, null for a parameter that is not
+/// nullable, a number for a string, a string for a boolean, an integer array with a fraction in it) used to reach the
+/// SDK and come back as "An error occurred invoking ...", with no word on which argument was wrong. Each row of
+/// pins both halves: the binder on its own reads or rejects the value as the row
+/// says, and the guard refuses exactly the values the binder rejects, by a message that names the argument.
+///
+public sealed class McpArgumentTypeTests : IClassFixture
+{
+ /// The two in-process servers every row shares: one with the guard in, one with it left out.
+ public sealed class Hosts : IAsyncLifetime
+ {
+ internal McpInProcessHost Guarded { get; private set; } = null!;
+
+ internal McpInProcessHost Unguarded { get; private set; } = null!;
+
+ public async ValueTask InitializeAsync()
+ {
+ Guarded = await McpWholeNumberArgumentTests.StartHostAsync(true, typeof(McpArgumentTypeProbeTools));
+ Unguarded = await McpWholeNumberArgumentTests.StartHostAsync(false, typeof(McpArgumentTypeProbeTools));
+ }
+
+ public async ValueTask DisposeAsync()
+ {
+ await Guarded.DisposeAsync();
+ await Unguarded.DisposeAsync();
+ }
+ }
+
+ private readonly Hosts _hosts;
+
+ public McpArgumentTypeTests(Hosts hosts) => _hosts = hosts;
+
+ public static TheoryData Rows() => McpArgumentTypeRows.All();
+
+ [Theory]
+ [MemberData(nameof(Rows))]
+ public async Task EachValue_IsRefusedByName_ExactlyWhenTheBinderCannotReadIt(
+ string type, string raw, bool refused, string says)
+ {
+ var toolTypes = McpWholeNumberArgumentTests.RegisteredToolTypes()
+ .Append(typeof(McpArgumentTypeProbeTools))
+ .ToList();
+
+ await McpArgumentTypeRows.CheckAsync(
+ _hosts.Guarded, _hosts.Unguarded, toolTypes, type, raw, refused, says, TestContext.Current.CancellationToken);
+ }
+
+ /// The host records a type for every parameter each tool advertises, and the types agree with the schema,
+ /// so the guard's typed check covers every shipped tool instead of quietly falling back to the schema alone.
+ [Fact]
+ public void EveryAdvertisedParameter_HasARecordedType_ThatAgreesWithTheSchema()
+ {
+ var problems = McpArgumentTypeRows.RecordedTypeProblems(_hosts.Guarded);
+
+ Assert.True(problems.Count == 0, $"{problems.Count} problems:\n" + string.Join("\n", problems.Take(40)));
+ }
+
+ /// The reverse of the check above: the record holds only what a caller may send, so every parameter it holds
+ /// is one the tool's schema advertises. A parameter the schema leaves out (a cancellation token the SDK supplies, a
+ /// service resolved from DI) is not recorded, whatever the tool method declares.
+ [Fact]
+ public void EveryRecordedParameter_IsOneTheSchemaAdvertises()
+ {
+ var problems = McpArgumentTypeRows.RecordedButNotAdvertisedProblems(_hosts.Guarded);
+
+ Assert.True(problems.Count == 0, $"{problems.Count} problems:\n" + string.Join("\n", problems.Take(40)));
+ }
+
+ /// The table is only worth having if it reaches every CLR type the shipped tools declare, so a tool added
+ /// with a type the table lacks fails here by name instead of passing through unchecked.
+ [Fact]
+ public void EveryClrTypeAShippedToolDeclares_IsOneTheTableCovers()
+ {
+ var covered = McpArgumentTypeRows.CoveredTypeKeys();
+ var declared = McpArgumentTypeRows.ShippedParameterTypeKeys(
+ _hosts.Guarded, McpWholeNumberArgumentTests.RegisteredToolTypes());
+
+ var missing = declared.Where(key => !covered.Contains(key)).ToList();
+ Assert.True(
+ missing.Count == 0,
+ "Shipped tools declare parameters of these types, which the table has no row for: " + string.Join(", ", missing));
+ }
+}
diff --git a/Darling/Darling.Tests/McpInProcessHost.cs b/Darling/Darling.Tests/McpInProcessHost.cs
index 00c127a231..bc9bdb109b 100644
--- a/Darling/Darling.Tests/McpInProcessHost.cs
+++ b/Darling/Darling.Tests/McpInProcessHost.cs
@@ -65,6 +65,10 @@ private McpInProcessHost(
/// The tools the server registered, with the schemas it advertises.
public IReadOnlyList RegisteredTools => _provider.GetServices().ToList();
+ /// The declared parameter types the host recorded as it created the tools: what the guard reads through
+ /// the request's services, so a test can hand them to .
+ public McpToolParameterTypes ParameterTypes => _provider.GetRequiredService();
+
/// The tool classes to register, as the host registers them.
/// Which parameter types the host resolves from DI rather than from the call.
/// An inert instance for a service type that cannot be left uninitialized (an
@@ -292,6 +296,11 @@ internal sealed class ToolExceptionLog : ILoggerProvider
///
public bool HasBindingFailure => Entries.Any(e => Chain(e.Exception).Any(x => x is JsonException));
+ /// Whether any exception logged after the first entries is a binding
+ /// failure, so one call among many on a shared host can be judged on its own.
+ public bool BindingFailureSince(int alreadySeen) =>
+ Entries.Skip(alreadySeen).Any(e => Chain(e.Exception).Any(x => x is JsonException));
+
/// Each logged exception as its category and the types in its chain, for a failure message.
public string Describe() =>
Entries.Count == 0
diff --git a/Darling/Darling.Tests/McpUnknownArgumentGuardTests.cs b/Darling/Darling.Tests/McpUnknownArgumentGuardTests.cs
index db8c1981a6..5dbc5a33df 100644
--- a/Darling/Darling.Tests/McpUnknownArgumentGuardTests.cs
+++ b/Darling/Darling.Tests/McpUnknownArgumentGuardTests.cs
@@ -7,6 +7,7 @@
using System.Linq;
using System.Reflection;
using System.Text.Json;
+using System.Text.Json.Serialization;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
@@ -389,6 +390,210 @@ public void ProtocolMetadata_TravelsOutsideTheArgumentsObject()
Assert.Null(McpUnknownArgumentGuard.Refuse(parameters, tool));
}
+ ///
+ /// The guard reads each value into its parameter's declared type to learn what the binder would do, and refuses
+ /// only on a . A converter that throws anything else (an
+ /// here) is a case the guard cannot judge, so the call passes to the binder, which
+ /// answers it itself. An exception out of would escape the call-tool
+ /// filter and fail a call the binder owns.
+ ///
+ [Fact]
+ public void AValueWhoseConverterThrowsAnythingButAJsonError_PassesToTheBinder()
+ {
+ var method = typeof(ConverterThrowsProbeTool).GetMethod(nameof(ConverterThrowsProbeTool.Take))!;
+ var tool = McpServerTool.Create(method, target: null, options: new McpServerToolCreateOptions());
+ var name = tool.ProtocolTool.Name;
+ var types = new McpToolParameterTypes();
+ types.Register(name, method, include: null);
+
+ /* The control: this tool's parameters are judged by their declared types, so the null below is a verdict and
+ not a guard that never got as far as reading the value. */
+ var control = McpUnknownArgumentGuard.Refuse(Call(name, Args(("count", "abc"))), tool, types);
+ Assert.NotNull(control);
+ using var refusal = JsonDocument.Parse(TextOf(control!));
+ Assert.Contains("'count'", refusal.RootElement.GetProperty("message").GetString()!, StringComparison.Ordinal);
+
+ Assert.Null(McpUnknownArgumentGuard.Refuse(Call(name, Args(("value", "anything"))), tool, types));
+ }
+
+ ///
+ /// A string takes a String token or a Null one and nothing else, so the guard decides it from the token. Reading a
+ /// string into a string copies the whole value only to throw the copy away, and the binder reads it again right
+ /// after, so a 10 MB plan_xml would be held a second time for nothing. The check allocates a sliver of the
+ /// value's size where reading it would allocate a copy of all of it.
+ ///
+ [Fact]
+ public void ALargeString_IsPassedWithoutBeingCopied()
+ {
+ var text = new string('x', 5_000_000);
+ using var document = JsonDocument.Parse("\"" + text + "\"");
+ var declared = new McpParameterType(typeof(string), AllowsNull: true);
+
+ /* Once to warm every path the check takes, so the second run measures only what the check itself allocates. */
+ Assert.Null(McpArgumentValueCheck.Problem("analyze_query_plan", "plan_xml", document.RootElement, declared));
+
+ var before = GC.GetAllocatedBytesForCurrentThread();
+ var problem = McpArgumentValueCheck.Problem("analyze_query_plan", "plan_xml", document.RootElement, declared);
+ var allocated = GC.GetAllocatedBytesForCurrentThread() - before;
+
+ Assert.Null(problem);
+ Assert.True(
+ allocated < 100_000,
+ $"The check allocated {allocated:N0} bytes for a string of {text.Length:N0} characters; reading it would copy the whole value.");
+ }
+
+ ///
+ /// answers an enum's underlying integer type, so an enum parameter used to pass
+ /// for a whole-number one: a value it could not read was refused as "a whole number ... with no decimal point", with
+ /// no word of the names it takes. The refusal for an enum, or a list of one, names its members.
+ ///
+ [Fact]
+ public void AnEnumParameter_IsRefusedAsOneOfItsNames_NotAsAWholeNumber()
+ {
+ /* A fraction, a name no member has, and a whole number past the underlying int: each is a value the binder cannot
+ read for an enum, and none of them is a whole number problem. */
+ foreach (var raw in new[] { "0.5", "\"Magenta\"", "3000000000" })
+ {
+ var message = ProbeRefusal("color", raw);
+
+ Assert.NotNull(message);
+ Assert.Contains("'color'", message, StringComparison.Ordinal);
+ Assert.Contains("takes one of Crimson, Teal, Amber, and the call sent", message, StringComparison.Ordinal);
+ Assert.DoesNotContain("whole number", message, StringComparison.Ordinal);
+ Assert.DoesNotContain("too large", message, StringComparison.Ordinal);
+ }
+
+ var list = ProbeRefusal("colors", "[0.5]");
+
+ Assert.NotNull(list);
+ Assert.Contains("a list (a JSON array) of names from Crimson, Teal, Amber", list, StringComparison.Ordinal);
+ Assert.DoesNotContain("ProbeColor", list, StringComparison.Ordinal);
+ Assert.DoesNotContain("whole number", list, StringComparison.Ordinal);
+
+ /* The control: a member name, and a number the enum holds, are read by the binder, so they are not refused. */
+ Assert.Null(ProbeRefusal("color", "\"Amber\""));
+ Assert.Null(ProbeRefusal("color", "1"));
+ }
+
+ ///
+ /// A whole number the guard words as too large or too small is judged by its digits. The widest integer type holds
+ /// 20 of them, so a run longer than that is out of every range whatever it is, and is not handed to a big-integer
+ /// parse whose cost grows faster than its length. The caller sets the length of the value.
+ ///
+ [Theory]
+ [InlineData("", false, "too large")]
+ [InlineData("-", false, "too small")]
+ [InlineData("", true, "too large")]
+ [InlineData("-", true, "too small")]
+ public void AHundredThousandDigitValueForAnInt_IsRefusedAsTooLargeOrTooSmall(string sign, bool asText, string direction)
+ {
+ var digits = sign + new string('9', 100_000);
+ var message = ProbeRefusal("count", asText ? "\"" + digits + "\"" : digits);
+
+ Assert.NotNull(message);
+ Assert.Contains("'count'", message, StringComparison.Ordinal);
+ Assert.Contains("takes a whole number, and the call sent", message, StringComparison.Ordinal);
+ Assert.Contains($"which is {direction}.", message, StringComparison.Ordinal);
+ AssertStatesNoRange(message);
+ }
+
+ ///
+ /// A leading plus sign, and leading zeros, change neither which way a whole number is out of range nor whether it is:
+ /// the zeros do not count toward the 20 digits the widest integer type holds.
+ ///
+ [Theory]
+ [InlineData("+", 0, "3000000000", "too large")]
+ [InlineData("-", 0, "3000000000", "too small")]
+ [InlineData("", 30, "3000000000", "too large")]
+ [InlineData("+", 30, "3000000000", "too large")]
+ [InlineData("-", 30, "3000000000", "too small")]
+ [InlineData("", 30, "99999999999999999999999", "too large")]
+ [InlineData("-", 30, "99999999999999999999999", "too small")]
+ public void ASignAndLeadingZeros_DoNotChangeWhichWayAWholeNumberIsOutOfRange(
+ string sign, int zeros, string digits, string direction)
+ {
+ var message = ProbeRefusal("count", "\"" + sign + new string('0', zeros) + digits + "\"");
+
+ Assert.NotNull(message);
+ Assert.Contains("takes a whole number, and the call sent", message, StringComparison.Ordinal);
+ Assert.Contains($"which is {direction}.", message, StringComparison.Ordinal);
+ AssertStatesNoRange(message);
+ }
+
+ ///
+ /// A refusal for a whole number past an says the value is too large or too small, and states no
+ /// range. The range of the CLR type is not the range the tool takes (hours_back is an int, and
+ /// McpHelpers.ValidateHoursBack refuses anything outside 1-168), so quoting it gave the caller two ranges that
+ /// disagree, and invited a retry the tool's own validator refuses.
+ ///
+ private static void AssertStatesNoRange(string message)
+ {
+ Assert.DoesNotContain("2147483647", message, StringComparison.Ordinal);
+ Assert.DoesNotContain("-2147483648", message, StringComparison.Ordinal);
+ Assert.DoesNotContain(" from ", message, StringComparison.Ordinal);
+ }
+
+ ///
+ /// The same signs and zeros on a value the binder reads (5) are not refused at all, so the bounded digit run does
+ /// not turn a padded small number into one that is too large.
+ ///
+ [Theory]
+ [InlineData("+", 0)]
+ [InlineData("", 30)]
+ [InlineData("+", 30)]
+ public void ASignAndLeadingZeros_OnAWholeNumberThatFits_AreNotRefused(string sign, int zeros) =>
+ Assert.Null(ProbeRefusal("count", "\"" + sign + new string('0', zeros) + "5\""));
+
+ ///
+ /// A zero written with a plus sign and a long run of zeros is not out of range, so it is never worded as too large or
+ /// too small: the 20 digits the widest integer type holds are counted after the leading zeros, not with them. A minus
+ /// sign is the one exception, and only because an unsigned type reads no sign at all (the guard has a verdict to give
+ /// here): the sign alone makes it too small, as it does for "-0" (#4956), and it is never too large.
+ ///
+ [Theory]
+ [InlineData("+", false)]
+ [InlineData("-", true)]
+ public void ASignedRunOfZeros_IsWordedAsTooSmallOnlyWhenItCarriesAMinus(string sign, bool tooSmall)
+ {
+ var message = ProbeRefusal("huge", "\"" + sign + new string('0', 30) + "\"");
+
+ Assert.True(
+ message is null || !message.Contains("which is too large", StringComparison.Ordinal),
+ "A zero is never too large, but the refusal says: " + message);
+ Assert.True(
+ tooSmall ? message?.Contains("which is too small.", StringComparison.Ordinal) == true
+ : message is null || !message.Contains("which is too", StringComparison.Ordinal),
+ (tooSmall ? "A minus sign on an unsigned type is too small, but the refusal says: " : "A zero is within every range, but the refusal says: ")
+ + message);
+ }
+
+ ///
+ /// The refusal message for one JSON value sent for one parameter of , registered
+ /// the way the host records its tools, or null when the guard lets the call through.
+ ///
+ private static string? ProbeRefusal(string parameter, string rawJson)
+ {
+ var method = typeof(ColorCountProbeTool).GetMethod(nameof(ColorCountProbeTool.Pick))!;
+ var tool = McpServerTool.Create(method, target: null, options: new McpServerToolCreateOptions());
+ var name = tool.ProtocolTool.Name;
+ var types = new McpToolParameterTypes();
+ types.Register(name, method, include: null);
+
+ using var document = JsonDocument.Parse(rawJson);
+ var arguments = new Dictionary(StringComparer.Ordinal)
+ {
+ [parameter] = document.RootElement.Clone(),
+ };
+
+ if (McpUnknownArgumentGuard.Refuse(Call(name, arguments), tool, types) is not { } refused)
+ {
+ return null;
+ }
+
+ using var refusal = JsonDocument.Parse(TextOf(refused));
+ return refusal.RootElement.GetProperty("message").GetString();
+ }
+
///
/// The registrations named in the host source — the same derivation
/// uses, so this census covers the tools that actually ship
@@ -433,3 +638,43 @@ private static string HostSourcePath()
"Could not locate DarlingMcpHostService.cs by walking up from the test output directory.");
}
}
+
+/// A parameter type whose converter throws an when it reads a value, as a
+/// converter written without the serializer's own exceptions in mind can.
+[JsonConverter(typeof(ConverterThatThrowsOnRead))]
+internal sealed class ValueWithThrowingConverter
+{
+}
+
+internal sealed class ConverterThatThrowsOnRead : JsonConverter
+{
+ public override ValueWithThrowingConverter Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) =>
+ throw new ArgumentException("This converter reads nothing.");
+
+ public override void Write(Utf8JsonWriter writer, ValueWithThrowingConverter value, JsonSerializerOptions options) =>
+ throw new NotSupportedException();
+}
+
+/// The one method the guard test above registers by hand: a parameter whose converter throws, and a whole
+/// number the guard can judge, so a refusal for the second proves the guard reached the first.
+internal static class ConverterThrowsProbeTool
+{
+ public static string Take(ValueWithThrowingConverter? value = null, int count = 0) => "ok";
+}
+
+/// An enum for the tests above: its members are what a refusal for an enum parameter names.
+internal enum ProbeColor
+{
+ Crimson,
+ Teal,
+ Amber,
+}
+
+/// The one method the enum and digit-run tests register by hand: an enum, a list of that enum, an
+/// and a . Like it carries no tool
+/// attribute, so no census over the shipped tool types sees it.
+internal static class ColorCountProbeTool
+{
+ public static string Pick(
+ ProbeColor color = ProbeColor.Teal, ProbeColor[]? colors = null, int count = 0, ulong huge = 0) => "ok";
+}
diff --git a/Darling/Darling.Tests/McpWholeNumberArgumentTests.cs b/Darling/Darling.Tests/McpWholeNumberArgumentTests.cs
index 59fbaf02c9..ea191ea537 100644
--- a/Darling/Darling.Tests/McpWholeNumberArgumentTests.cs
+++ b/Darling/Darling.Tests/McpWholeNumberArgumentTests.cs
@@ -34,7 +34,7 @@ namespace Darling.Tests;
///
public sealed class McpWholeNumberArgumentTests
{
- private static List RegisteredToolTypes()
+ internal static List RegisteredToolTypes()
{
var registered = McpUnknownArgumentGuardTests.RegisteredToolTypeNames();
@@ -46,16 +46,25 @@ private static List RegisteredToolTypes()
}
/// Inert stand-ins for the two service types that cannot be left uninitialized. The data source points
- /// at a closed local port and is never opened by these tests: every call they make is refused before the body.
+ /// at a closed local port. This class's calls are all refused before the tool body, so it is never opened here.
+ /// Two other places share this host and DO reach a tool body. In one
+ /// test does: WithoutTheGuard_TheBinderDropsAKeyDifferingOnlyByCase, through its HOURS_BACK call (the
+ /// binder drops that key, so get_wait_stats runs at its default window); the other test there that starts this
+ /// host is refused before the binder. And runs a real tool body for every row the
+ /// binder reads, on its guard-less host, and on its guarded host too, which passes those rows on. The one-second
+ /// connect timeout on the closed port is what keeps those fast.
private static object? InertInstanceFor(Type serviceType) =>
serviceType == typeof(NpgsqlDataSource) ? NpgsqlDataSource.Create("Host=127.0.0.1;Port=1;Username=none;Database=none;Timeout=1")
: serviceType == typeof(ILogger) ? NullLogger.Instance
: null;
- internal static Task StartHostAsync(bool installGuard = true) =>
+ /// False to leave the call-tool guard out, so a call meets the SDK's binder alone.
+ /// Test-only tool classes to register beside the shipped ones, through the same
+ /// McpSchemaCompat path.
+ internal static Task StartHostAsync(bool installGuard = true, params Type[] extraToolTypes) =>
McpInProcessHost.StartAsync(
- RegisteredToolTypes(), McpUnknownArgumentGuardTests.IsServiceParameter, InertInstanceFor, installGuard,
- TestContext.Current.CancellationToken);
+ RegisteredToolTypes().Concat(extraToolTypes).ToList(), McpUnknownArgumentGuardTests.IsServiceParameter,
+ InertInstanceFor, installGuard, TestContext.Current.CancellationToken);
private static JsonElement Json(string raw) => JsonDocument.Parse(raw).RootElement.Clone();
@@ -141,11 +150,37 @@ public async Task AnotherIntegerParameter_RefusesAFraction_WithAMessageNamingIt(
Assert.Null(McpInProcessHost.WholeNumberRefusalProblem("get_wait_stats", "limit", "whole number", result));
}
+ ///
+ /// A whole number past sent for hours_back, through a real in-process server with the
+ /// declared parameter types recorded, is refused as too large, and the refusal states no range. The CLR type's range
+ /// is not the range the tool takes: hours_back is an int, but McpHelpers.ValidateHoursBack refuses
+ /// anything outside 1-168. A refusal that quoted "from -2147483648 to 2147483647" gave the caller two ranges that
+ /// disagree, and invited a retry the tool's own validator refuses.
+ ///
+ [Fact]
+ public async Task HoursBackPastInt_IsRefusedAsTooLarge_AndStatesNoRange()
+ {
+ var ct = TestContext.Current.CancellationToken;
+ await using var host = await StartHostAsync();
+
+ var result = await host.Client.CallToolAsync(
+ "get_wait_stats", new Dictionary { ["hours_back"] = Json("99999999999") }, cancellationToken: ct);
+
+ var problem = McpInProcessHost.WholeNumberRefusalProblem("get_wait_stats", "hours_back", "whole number of hours", result);
+ Assert.True(problem is null, problem);
+ var message = McpInProcessHost.RefusalMessage(result)!;
+ Assert.Contains("which is too large", message, StringComparison.Ordinal);
+ Assert.DoesNotContain("2147483647", message, StringComparison.Ordinal);
+ Assert.DoesNotContain("-2147483648", message, StringComparison.Ordinal);
+ }
+
///
/// Every integer parameter of every registered tool refuses 2.5, 1.0, "0.5" and true by name, and passes 1, "1"
- /// and null. Checked against the guard's decision, so no tool body runs. The binder reads 1 and "1" for every
- /// integer parameter, but null only for a nullable one: the guard passes null because the schema does not say
- /// which parameters are nullable, and a non-nullable one still gets the SDK's own error.
+ /// and null. Checked against the guard's decision, so no tool body runs, and with no recorded parameter types:
+ /// this is the schema-only path that a tool the record does not cover falls back to. The binder reads 1 and "1"
+ /// for every integer parameter, but null only for a nullable one, and the schema does not say which are, so this
+ /// path passes null. With the declared types recorded, which is how the host calls the guard, a non-nullable
+ /// parameter refuses null by name; pins that.
///
[Fact]
public async Task EveryIntegerParameter_RefusesWhatTheBinderCannotRead_AndAcceptsWhatItCan()
diff --git a/Darling/Darling.Tests/WebServerPageRangeTests.cs b/Darling/Darling.Tests/WebServerPageRangeTests.cs
index 2672840a62..68ec96040f 100644
--- a/Darling/Darling.Tests/WebServerPageRangeTests.cs
+++ b/Darling/Darling.Tests/WebServerPageRangeTests.cs
@@ -6,7 +6,9 @@
* Licensed under the MIT License. See LICENSE file in the project root for full license information.
*/
+using System.Globalization;
using System.Linq;
+using System.Text.Json;
using System.Text.RegularExpressions;
using PerformanceMonitor.Common;
using Xunit;
@@ -25,7 +27,9 @@ public sealed class WebServerPageRangeTests
{
///
/// Every range the page offers, on every tab of both registries: no ranged read is asked for more hours than it
- /// takes. A tab added later is built too, and a read it would over-ask fails here by range, tab and read.
+ /// takes. A tab added later is built too, and a read it would over-ask fails here by range, tab and read. A read
+ /// that comes late, after its tab has settled, is caught by a check of every read the run made, and what the tabs
+ /// draw is mounted, so a notice or an error at an offered range fails by range and tab as well.
///
[Fact]
public void EveryOfferedRange_IsOneEveryRangedReadOnEveryTabTakes()
@@ -35,14 +39,38 @@ public void EveryOfferedRange_IsOneEveryRangedReadOnEveryTabTakes()
var offered = r.GetProperty("offered").EnumerateArray().Select(e => e.GetInt32()).ToArray();
Assert.NotEmpty(offered);
- var beyond = r.GetProperty("beyond").EnumerateArray().Select(e => e.GetString()!).ToArray();
+ var beyond = Strings(r, "beyond");
Assert.True(
beyond.Length == 0,
$"The Range offers {string.Join(", ", offered)} hours, and {beyond.Length} ranged reads would be asked for more"
+ $" than {McpHelpers.MaxHoursBack} hours, the widest window the capped reads take:\n" + string.Join("\n", beyond.Take(40)));
+ /* The list above is each tab's reads, taken right after that tab settled. A read a tab makes later (behind a
+ timer, say) reaches the run's list of every fetch without reaching a tab's, and the last tab's has nothing
+ after it to be counted in. So the whole list is checked as well: no read the page makes asks for more than the
+ capped reads take, whether or not it can be tied to a tab. */
+ var tooWide = Strings(r, "fetches").Where(f => HoursAsked(f) > McpHelpers.MaxHoursBack).ToArray();
+ Assert.True(
+ tooWide.Length == 0,
+ $"{tooWide.Length} reads the page made asked for more than {McpHelpers.MaxHoursBack} hours, the widest window the"
+ + " capped reads take (this list is every read the run made, so a read that cannot be tied to a tab is in it):\n"
+ + string.Join("\n", tooWide.Take(40)));
+
Assert.Empty(r.GetProperty("rejections").EnumerateArray());
+ /* Every tab's drawing is mounted, so what the page shows at an offered range is seen. Each offered range is one
+ the reads take, so no panel may need a notice that it asked again for fewer hours, and none may fail. The
+ strips say which range and tab drew them. */
+ var notices = Strings(r, "notices");
+ Assert.True(
+ notices.Length == 0,
+ $"{notices.Length} notices were shown at an offered range, where no read is refused:\n" + string.Join("\n", notices.Take(40)));
+ var errors = Strings(r, "errors");
+ Assert.True(
+ errors.Length == 0,
+ $"{errors.Length} errors were shown at an offered range:\n" + string.Join("\n", errors.Take(40)));
+ Assert.Equal(0, r.GetProperty("loading").GetInt32());
+
/* "Nothing over-asked" must not pass because nothing was asked: each range builds the tabs of both registries,
and named ranged reads from each are seen at that range's hours. */
var observed = r.GetProperty("observed").EnumerateArray().Select(e => e.GetString()!).ToArray();
@@ -61,6 +89,15 @@ and named ranged reads from each are seen at that range's hours. */
}
}
+ private static string[] Strings(JsonElement node, string name) =>
+ node.GetProperty(name).EnumerateArray().Select(e => e.GetString()!).ToArray();
+
+ private static readonly Regex s_hoursParam = new(@"[?&]hours=([0-9]+(?:\.[0-9]+)?)", RegexOptions.CultureInvariant);
+
+ /// The hours a fetched URL asks for, or 0 when it takes no window.
+ private static double HoursAsked(string fetched) =>
+ s_hoursParam.Match(fetched) is { Success: true } hours ? double.Parse(hours.Groups[1].Value, CultureInfo.InvariantCulture) : 0;
+
/// Ranged reads each registry's tabs make at every range: the census must see them asked.
private static readonly (string Engine, string[] Reads)[] s_witnesses =
[
diff --git a/Darling/Darling.Tests/web-kept-history-harness.mjs b/Darling/Darling.Tests/web-kept-history-harness.mjs
index db6866f0de..ad0cf4f3dd 100644
--- a/Darling/Darling.Tests/web-kept-history-harness.mjs
+++ b/Darling/Darling.Tests/web-kept-history-harness.mjs
@@ -255,7 +255,8 @@ const scenarios = {
},
// Every range the server page's Range offers, through every tab of both registries, against reads that refuse
// more than the scenario's hours the way the capped reads do. Each read the page would ask for more than that is
- // listed by range, registry and tab.
+ // listed by range, registry and tab. What each tab draws is mounted too, under a node that names its range and tab,
+ // so a notice or an error the page shows at an offered range comes back saying where.
offeredRanges: async () => {
const maxHours = Number(scenarioValue);
answer = (url) => {
@@ -266,11 +267,15 @@ const scenarios = {
beyond = [];
observed = [];
notes = [];
+ const drawn = [];
for (const option of modules.server.RANGE_OPTIONS) {
for (const [engine, registry] of [["SQL Server", modules.tabs.SERVER_TABS], ["PostgreSQL", modules.tabs.POSTGRES_TABS]]) {
for (const tab of registry) {
const before = fetches.length;
- tab.build("SRV1", { hours: option.hours, label: option.label });
+ const holder = new FakeNode("div");
+ holder.where = option.label + ": " + engine + " " + tab.id + " tab";
+ modules.util.mount(holder, tab.build("SRV1", { hours: option.hours, label: option.label }));
+ drawn.push(holder);
await settleFast();
for (const fetched of fetches.slice(before)) {
const url = new URL(fetched, "http://viewer.test");
@@ -292,7 +297,7 @@ const scenarios = {
}
}
}
- return [];
+ return drawn;
},
};
@@ -303,14 +308,17 @@ const root = new FakeNode("main");
modules.util.mount(root, await chosen());
await settle();
+/* The text of every strip of one kind under the root. A node the scenario marked with `where` (the offeredRanges
+ scenario names the range and tab each one drew) puts that in front of the text of the strips beneath it. */
const strips = (kind) => {
const found = [];
- const walk = (n) => {
+ const walk = (n, where) => {
if (!n || typeof n !== "object") return;
- if (n.className === "strip " + kind) found.push(n.textContent);
- n.children.forEach(walk);
+ const here = n.where || where;
+ if (n.className === "strip " + kind) found.push(here ? here + ": " + n.textContent : n.textContent);
+ n.children.forEach((child) => walk(child, here));
};
- walk(root);
+ walk(root, null);
return found;
};
diff --git a/Lite.Tests/Lite.Tests.csproj b/Lite.Tests/Lite.Tests.csproj
index 6bb25870b9..b71318cd28 100644
--- a/Lite.Tests/Lite.Tests.csproj
+++ b/Lite.Tests/Lite.Tests.csproj
@@ -43,6 +43,9 @@
+
+
diff --git a/Lite.Tests/McpArgumentTypeTests.cs b/Lite.Tests/McpArgumentTypeTests.cs
new file mode 100644
index 0000000000..b168c2fcec
--- /dev/null
+++ b/Lite.Tests/McpArgumentTypeTests.cs
@@ -0,0 +1,99 @@
+/*
+ * 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.Linq;
+using System.Threading.Tasks;
+using Darling.Tests;
+using Xunit;
+
+namespace Lite.Tests;
+
+///
+/// Lite's half of the per-type argument pins: the shared call-tool guard checks each argument against the CLR type
+/// of its parameter, so a value the SDK's binder cannot read (an int past int range, null for a parameter that is not
+/// nullable, a number for a string, a string for a boolean, an integer array with a fraction in it) is refused by a
+/// message that names the argument. The table is , the one Darling runs; the tools
+/// it resolves are Lite's own, and a CLR type none of them declares uses the test-only probe tool.
+///
+public sealed class McpArgumentTypeTests : IClassFixture
+{
+ /// The two in-process servers every row shares: one with the guard in, one with it left out.
+ public sealed class Hosts : IAsyncLifetime
+ {
+ internal McpInProcessHost Guarded { get; private set; } = null!;
+
+ internal McpInProcessHost Unguarded { get; private set; } = null!;
+
+ public async ValueTask InitializeAsync()
+ {
+ Guarded = await McpWholeNumberArgumentTests.StartHostAsync(true, typeof(McpArgumentTypeProbeTools));
+ Unguarded = await McpWholeNumberArgumentTests.StartHostAsync(false, typeof(McpArgumentTypeProbeTools));
+ }
+
+ public async ValueTask DisposeAsync()
+ {
+ await Guarded.DisposeAsync();
+ await Unguarded.DisposeAsync();
+ }
+ }
+
+ private readonly Hosts _hosts;
+
+ public McpArgumentTypeTests(Hosts hosts) => _hosts = hosts;
+
+ public static TheoryData Rows() => McpArgumentTypeRows.All();
+
+ [Theory]
+ [MemberData(nameof(Rows))]
+ public async Task EachValue_IsRefusedByName_ExactlyWhenTheBinderCannotReadIt(
+ string type, string raw, bool refused, string says)
+ {
+ var toolTypes = McpWholeNumberArgumentTests.LiteToolTypes()
+ .Append(typeof(McpArgumentTypeProbeTools))
+ .ToList();
+
+ await McpArgumentTypeRows.CheckAsync(
+ _hosts.Guarded, _hosts.Unguarded, toolTypes, type, raw, refused, says, TestContext.Current.CancellationToken);
+ }
+
+ /// The host records a type for every parameter each tool advertises, and the types agree with the schema,
+ /// so the guard's typed check covers every shipped tool instead of quietly falling back to the schema alone.
+ [Fact]
+ public void EveryAdvertisedParameter_HasARecordedType_ThatAgreesWithTheSchema()
+ {
+ var problems = McpArgumentTypeRows.RecordedTypeProblems(_hosts.Guarded);
+
+ Assert.True(problems.Count == 0, $"{problems.Count} problems:\n" + string.Join("\n", problems.Take(40)));
+ }
+
+ /// The reverse of the check above: the record holds only what a caller may send, so every parameter it holds
+ /// is one the tool's schema advertises. A parameter the schema leaves out (a cancellation token the SDK supplies, a
+ /// service resolved from DI) is not recorded, whatever the tool method declares.
+ [Fact]
+ public void EveryRecordedParameter_IsOneTheSchemaAdvertises()
+ {
+ var problems = McpArgumentTypeRows.RecordedButNotAdvertisedProblems(_hosts.Guarded);
+
+ Assert.True(problems.Count == 0, $"{problems.Count} problems:\n" + string.Join("\n", problems.Take(40)));
+ }
+
+ /// The table is only worth having if it reaches every CLR type the shipped tools declare, so a tool added
+ /// with a type the table lacks fails here by name instead of passing through unchecked.
+ [Fact]
+ public void EveryClrTypeAShippedToolDeclares_IsOneTheTableCovers()
+ {
+ var covered = McpArgumentTypeRows.CoveredTypeKeys();
+ var declared = McpArgumentTypeRows.ShippedParameterTypeKeys(
+ _hosts.Guarded, McpWholeNumberArgumentTests.LiteToolTypes());
+
+ var missing = declared.Where(key => !covered.Contains(key)).ToList();
+ Assert.True(
+ missing.Count == 0,
+ "Shipped tools declare parameters of these types, which the table has no row for: " + string.Join(", ", missing));
+ }
+}
diff --git a/Lite.Tests/McpWholeNumberArgumentTests.cs b/Lite.Tests/McpWholeNumberArgumentTests.cs
index 2bf63fb6e2..843fffa9b1 100644
--- a/Lite.Tests/McpWholeNumberArgumentTests.cs
+++ b/Lite.Tests/McpWholeNumberArgumentTests.cs
@@ -28,7 +28,7 @@ namespace Lite.Tests;
///
public sealed class McpWholeNumberArgumentTests
{
- private static List LiteToolTypes() =>
+ internal static List LiteToolTypes() =>
typeof(PerformanceMonitorLite.Mcp.McpWaitTools).Assembly
.GetTypes()
.Where(t => t.GetCustomAttribute() is not null)
@@ -41,8 +41,13 @@ private static bool IsServiceParameter(Type t) =>
McpServedSchema.IsServiceParameter(t) && t != typeof(McpToolGuideCatalog);
/// Lite's tool services are all concrete classes, so none needs a hand-made stand-in.
- internal static Task StartHostAsync(bool installGuard = true) =>
- McpInProcessHost.StartAsync(LiteToolTypes(), IsServiceParameter, _ => null, installGuard, TestContext.Current.CancellationToken);
+ /// False to leave the call-tool guard out, so a call meets the SDK's binder alone.
+ /// Test-only tool classes to register beside the shipped ones, through the same
+ /// McpSchemaCompat path.
+ internal static Task StartHostAsync(bool installGuard = true, params Type[] extraToolTypes) =>
+ McpInProcessHost.StartAsync(
+ LiteToolTypes().Concat(extraToolTypes).ToList(), IsServiceParameter, _ => null, installGuard,
+ TestContext.Current.CancellationToken);
private static JsonElement Json(string raw) => JsonDocument.Parse(raw).RootElement.Clone();
@@ -122,10 +127,34 @@ public async Task AnotherIntegerParameter_RefusesAFraction_WithAMessageNamingIt(
Assert.Null(McpInProcessHost.WholeNumberRefusalProblem("get_wait_stats", "limit", "whole number", result));
}
+ /// A whole number past sent for hours_back, through a real in-process server with
+ /// the declared parameter types recorded, is refused as too large, and the refusal states no range. The CLR type's
+ /// range is not the range the tool takes: hours_back is an int, but McpHelpers.ValidateHoursBack
+ /// refuses anything outside 1-168. A refusal that quoted "from -2147483648 to 2147483647" gave the caller two
+ /// ranges that disagree, and invited a retry the tool's own validator refuses.
+ [Fact]
+ public async Task HoursBackPastInt_IsRefusedAsTooLarge_AndStatesNoRange()
+ {
+ var ct = TestContext.Current.CancellationToken;
+ await using var host = await StartHostAsync();
+
+ var result = await host.Client.CallToolAsync(
+ "get_wait_stats", new Dictionary { ["hours_back"] = Json("99999999999") }, cancellationToken: ct);
+
+ var problem = McpInProcessHost.WholeNumberRefusalProblem("get_wait_stats", "hours_back", "whole number of hours", result);
+ Assert.True(problem is null, problem);
+ var message = McpInProcessHost.RefusalMessage(result)!;
+ Assert.Contains("which is too large", message, StringComparison.Ordinal);
+ Assert.DoesNotContain("2147483647", message, StringComparison.Ordinal);
+ Assert.DoesNotContain("-2147483648", message, StringComparison.Ordinal);
+ }
+
/// Every integer parameter of every Lite tool refuses 2.5, 1.0, "0.5" and true by name, and passes 1, "1"
- /// and null. Checked against the guard's decision, so no tool body runs. The binder reads 1 and "1" for every
- /// integer parameter, but null only for a nullable one: the guard passes null because the schema does not say
- /// which parameters are nullable, and a non-nullable one still gets the SDK's own error.
+ /// and null. Checked against the guard's decision, so no tool body runs, and with no recorded parameter types:
+ /// this is the schema-only path that a tool the record does not cover falls back to. The binder reads null only
+ /// for a nullable parameter, and the schema does not say which are, so this path passes null. With the declared
+ /// types recorded, which is how the host calls the guard, a non-nullable parameter refuses null by name;
+ /// pins that.
[Fact]
public async Task EveryIntegerParameter_RefusesWhatTheBinderCannotRead_AndAcceptsWhatItCan()
{
diff --git a/PerformanceMonitor.Common/Mcp/McpHelpers.cs b/PerformanceMonitor.Common/Mcp/McpHelpers.cs
index bb0c387fa4..92c3aee346 100644
--- a/PerformanceMonitor.Common/Mcp/McpHelpers.cs
+++ b/PerformanceMonitor.Common/Mcp/McpHelpers.cs
@@ -87,7 +87,9 @@ internal static class McpHelpers
/// The walk starts at the last position at or before the limit whose two neighbours are both ASCII and are
/// not CR then LF. No ASCII character extends, joins or prefixes the character next to it, so such a position is a
/// text-element boundary however the text before it segments. Most values are ASCII at the cut, so most calls
- /// return without segmenting anything, and a 512,000-unit limit costs no more than a 40-unit one.
+ /// return without segmenting anything, and when the text is ASCII at the cut a 512,000-unit limit costs no more
+ /// than a 40-unit one. When it is not (a non-ASCII character or a CR LF pair at the cut), the walk goes back toward
+ /// the start of the text and segments forward from there, so the cost then grows with the limit.
///
internal static int TextElementCutLength(ReadOnlySpan text, int limit)
{
diff --git a/PerformanceMonitor.Common/Mcp/McpSchemaCompat.cs b/PerformanceMonitor.Common/Mcp/McpSchemaCompat.cs
index 1ba0c30afc..45536d0a07 100644
--- a/PerformanceMonitor.Common/Mcp/McpSchemaCompat.cs
+++ b/PerformanceMonitor.Common/Mcp/McpSchemaCompat.cs
@@ -11,6 +11,7 @@
using System.ComponentModel;
using System.Diagnostics.CodeAnalysis;
using System.Reflection;
+using System.Text.Json;
using System.Text.Json.Nodes;
using Microsoft.Extensions.AI;
using ModelContextProtocol.Server;
@@ -122,6 +123,7 @@ internal static AIJsonSchemaCreateOptions SchemaOptionsFor(IServiceProvider serv
ArgumentNullException.ThrowIfNull(builder);
var catalog = GuideCatalogOf(builder.Services);
+ var parameterTypes = ParameterTypesOf(builder.Services);
foreach (var toolMethod in typeof(TToolType).GetMethods(
BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static | BindingFlags.Instance))
@@ -161,7 +163,8 @@ which the SDK copies onto the served Tool.Meta (ModelContextProtocol.Core 2.2.0)
Services = the DI provider so service-typed parameters are excluded from the schema and
resolved per-request. The additions are SchemaCreateOptions, the served Description, and Meta. */
builder.Services.AddSingleton((Func)(services =>
- McpServerTool.Create(
+ {
+ var tool = McpServerTool.Create(
toolMethod,
target: null,
options: new McpServerToolCreateOptions
@@ -170,7 +173,20 @@ which the SDK copies onto the served Tool.Meta (ModelContextProtocol.Core 2.2.0)
SchemaCreateOptions = SchemaOptionsFor(services),
Description = served,
Meta = alwaysLoad ? new JsonObject { ["anthropic/alwaysLoad"] = true } : null
- })));
+ });
+
+ /* The CLR type and nullability of each parameter the caller supplies, for the call-tool guard
+ (McpUnknownArgumentGuard), which refuses a value the SDK's binder cannot read into that type. The
+ served schema cannot say it: "integer" covers int, long, short and byte alike, and nullability is
+ absent. Which parameters a caller supplies is read back from the schema the created tool serves, so
+ the record holds exactly what that schema lists: the DI services it leaves out, and a
+ CancellationToken the SDK binds from the request, are left out here too, and neither rule is
+ repeated. */
+ var advertised = AdvertisedParameterNames(tool);
+ parameterTypes.Register(toolName, toolMethod, parameter => parameter.Name is { } name && advertised.Contains(name));
+
+ return tool;
+ }));
}
return builder;
@@ -198,6 +214,49 @@ private static McpToolGuideCatalog GuideCatalogOf(IServiceCollection services)
return catalog;
}
+ ///
+ /// The parameter names the created tool's served schema lists, matched exactly, as the SDK's binder matches them.
+ /// Empty for a schema that is not an object, or lists no properties: a tool that takes nothing a caller may send.
+ ///
+ private static HashSet AdvertisedParameterNames(McpServerTool tool)
+ {
+ var names = new HashSet(StringComparer.Ordinal);
+ var schema = tool.ProtocolTool.InputSchema;
+
+ if (schema.ValueKind == JsonValueKind.Object
+ && schema.TryGetProperty("properties", out var properties)
+ && properties.ValueKind == JsonValueKind.Object)
+ {
+ foreach (var property in properties.EnumerateObject())
+ {
+ names.Add(property.Name);
+ }
+ }
+
+ return names;
+ }
+
+ ///
+ /// The one per service collection, found-or-added the way
+ /// is. The call-tool guard reads it from the request's services.
+ ///
+ private static McpToolParameterTypes ParameterTypesOf(IServiceCollection services)
+ {
+ foreach (var descriptor in services)
+ {
+ if (descriptor.ServiceType == typeof(McpToolParameterTypes)
+ && !descriptor.IsKeyedService
+ && descriptor.ImplementationInstance is McpToolParameterTypes existing)
+ {
+ return existing;
+ }
+ }
+
+ var types = new McpToolParameterTypes();
+ services.AddSingleton(types);
+ return types;
+ }
+
///
/// Rewrites a single generated schema node into the subset Gemini's function-calling validator
/// accepts: collapses nullable type unions to the single non-null type and removes the
diff --git a/PerformanceMonitor.Common/Mcp/McpToolParameterTypes.cs b/PerformanceMonitor.Common/Mcp/McpToolParameterTypes.cs
new file mode 100644
index 0000000000..f965d67858
--- /dev/null
+++ b/PerformanceMonitor.Common/Mcp/McpToolParameterTypes.cs
@@ -0,0 +1,338 @@
+/*
+ * 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.Concurrent;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Linq;
+using System.Numerics;
+using System.Reflection;
+using System.Text.Json;
+using ModelContextProtocol;
+
+namespace PerformanceMonitor.Common;
+
+/// One tool parameter as the tool method declares it: its CLR type, and whether it can take a null.
+/// The declared type. int? is of .
+/// A nullable value type, or a reference type annotated as nullable. Only the tests read it, as a
+/// cross-check; the refusal does not, because it asks the binder whether it can read the value.
+public sealed record McpParameterType(Type Type, bool AllowsNull);
+
+///
+/// The CLR type and nullability of each parameter the caller supplies, per tool. Filled by
+/// McpSchemaCompat.WithGeminiCompatibleTools as each tool is created (one instance per service collection,
+/// found-or-added like ), and read by through
+/// the request's services.
+///
+/// The served schema cannot stand in for this. It says "integer" for an int, a long, a
+/// short and a byte alike, carries no format, and does not say which parameters take a null, while
+/// the SDK's binder reads each value into the real type and throws on a value that type cannot hold. Only what a caller
+/// may send is here: the host records the parameters the created tool's served schema lists, so a parameter it resolves
+/// from DI (a data source, a logger) or the SDK supplies (a cancellation token) is left out of the record as it is out
+/// of the schema.
+///
+public sealed class McpToolParameterTypes
+{
+ private readonly ConcurrentDictionary> _tools =
+ new(StringComparer.Ordinal);
+
+ /// Records one tool's parameters, skipping any that rejects. A second
+ /// registration of the same name replaces the first, so a host that builds its tools twice records the same
+ /// answer twice.
+ /// The name the tool is served under.
+ /// The tool method.
+ /// Whether a parameter is one the caller supplies; null includes every parameter.
+ public void Register(string toolName, MethodInfo method, Func? include)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(toolName);
+ ArgumentNullException.ThrowIfNull(method);
+
+ var context = new NullabilityInfoContext();
+ var parameters = new Dictionary(StringComparer.Ordinal);
+
+ foreach (var parameter in method.GetParameters())
+ {
+ if (parameter.Name is null || (include is not null && !include(parameter)))
+ {
+ continue;
+ }
+
+ var type = parameter.ParameterType;
+ var allowsNull = type.IsValueType
+ ? Nullable.GetUnderlyingType(type) is not null
+ : context.Create(parameter).WriteState != NullabilityState.NotNull;
+
+ parameters[parameter.Name] = new McpParameterType(type, allowsNull);
+ }
+
+ _tools[toolName] = parameters;
+ }
+
+ /// The parameters recorded for a tool, by name. False for a tool nothing recorded.
+ public bool TryGet(string toolName, out IReadOnlyDictionary parameters)
+ {
+ if (_tools.TryGetValue(toolName, out var found))
+ {
+ parameters = found;
+ return true;
+ }
+
+ parameters = new Dictionary();
+ return false;
+ }
+}
+
+///
+/// Decides whether the SDK's binder can read an argument's value into a parameter's declared type, and words the
+/// refusal when it cannot.
+///
+/// The decision is the binder's own. The SDK binds a tool's arguments with
+/// System.Text.Json, using unless a tool is created with its
+/// own. This reads the value into the same declared type with the same options and refuses exactly when
+/// is thrown, which is what the binder would throw. So it needs no table of what each type
+/// accepts (an int reads "5", a bool does not read "true", a ulong reads
+/// 18446744073709551615, a string reads no number), and it cannot refuse a value the binder reads. Anything else the
+/// serializer does (no converter for the type, or a converter that throws something other than a
+/// , say) is a case this cannot judge, and the value passes. The one type it does not read
+/// is a string, which takes a String token or a Null one and nothing else: that is decided from the token, so a
+/// caller-sized text (a plan's XML) is not copied only to be thrown away.
+///
+internal static class McpArgumentValueCheck
+{
+ ///
+ /// The refusal sentence for one argument, or null when the binder reads into the declared
+ /// type. It names the argument and the tool, says what the parameter takes, and quotes the value sent.
+ ///
+ internal static string? Problem(string toolName, string parameter, JsonElement value, McpParameterType declared)
+ {
+ if (CanBind(value, declared.Type))
+ {
+ return null;
+ }
+
+ var type = Nullable.GetUnderlyingType(declared.Type) ?? declared.Type;
+ var takes = Takes(parameter, type);
+ var sent = McpUnknownArgumentGuard.Shorten(value.GetRawText());
+ var prefix = $"Argument '{parameter}' for tool '{toolName}' takes ";
+
+ /* Only a value type with no null in it gets here with a null: a reference type, or a Nullable, binds one. */
+ if (value.ValueKind == JsonValueKind.Null)
+ {
+ return $"{prefix}{takes}, and cannot be null, and the call sent null.";
+ }
+
+ if (Bounds(type) is { } bounds)
+ {
+ /* A whole number outside the type's range is too large or too small, which "no decimal point" would
+ misstate; anything else the type cannot read (0.5, "abc", true) is not a whole number. The range itself
+ is not quoted: the CLR type's range is not the range the tool takes (hours_back is an int, and its own
+ validator refuses anything outside 1-168), so quoting it gave the caller two ranges that disagree, and
+ invited a retry the validator refuses. */
+ return OutOfRange(value, bounds) is string direction
+ ? $"{prefix}{takes}, and the call sent {sent}, which is {direction}."
+ : $"{prefix}{takes} with no decimal point, such as 1, and the call sent {sent}.";
+ }
+
+ return $"{prefix}{takes}, and the call sent {sent}.";
+ }
+
+ private static bool CanBind(JsonElement value, Type type)
+ {
+ /* A string reads a String token or a Null one and nothing else, so that is decided from the token. Reading it
+ would copy the whole value only to throw the copy away (a 10 MB plan_xml is a second 10 MB string), and the
+ binder reads it again right after. */
+ if (type == typeof(string))
+ {
+ return value.ValueKind is JsonValueKind.String or JsonValueKind.Null;
+ }
+
+ try
+ {
+ JsonSerializer.Deserialize(value, type, McpJsonUtilities.DefaultOptions);
+ return true;
+ }
+ catch (Exception ex)
+ {
+ /* Only a JsonException is the binder's "cannot read this". Whatever else the read throws (no converter for
+ the type, or a converter that throws an ArgumentException or anything else of its own) is a case this
+ cannot judge, so the value passes and the binder answers the call itself; an exception out of here would
+ escape the call-tool filter and fail a call the binder owns. */
+ return ex is not JsonException;
+ }
+ }
+
+ /// The range of an integer type, or null when is not one. An enum is not one,
+ /// although answers its underlying integer type: what it takes is a member name,
+ /// and a refusal that called it a whole number would not name them (#4956).
+ private static (BigInteger Min, BigInteger Max)? Bounds(Type type)
+ {
+ if (type.IsEnum)
+ {
+ return null;
+ }
+
+ return Type.GetTypeCode(type) switch
+ {
+ TypeCode.SByte => (sbyte.MinValue, sbyte.MaxValue),
+ TypeCode.Byte => (byte.MinValue, byte.MaxValue),
+ TypeCode.Int16 => (short.MinValue, short.MaxValue),
+ TypeCode.UInt16 => (ushort.MinValue, ushort.MaxValue),
+ TypeCode.Int32 => (int.MinValue, int.MaxValue),
+ TypeCode.UInt32 => (uint.MinValue, uint.MaxValue),
+ TypeCode.Int64 => (long.MinValue, long.MaxValue),
+ TypeCode.UInt64 => (ulong.MinValue, ulong.MaxValue),
+ _ => null,
+ };
+ }
+
+ /// "too large" or "too small" when is a whole number (a JSON number with no
+ /// fraction or exponent, or a string of digits with an optional sign) outside the type's range; otherwise null. For
+ /// an unsigned type a minus sign is too small on its own, "-0" included, because the type does not read the sign.
+ ///
+ /// The caller sets the length of the text, and parsing a long run of digits as a big integer costs more than
+ /// the length grows. The widest integer type () holds 20 digits, so after the leading zeros a
+ /// longer run is out of every range, and is answered by its length and sign without being parsed (#4956).
+ private static string? OutOfRange(JsonElement value, (BigInteger Min, BigInteger Max) bounds)
+ {
+ var text = value.ValueKind switch
+ {
+ JsonValueKind.Number when value.GetRawText().AsSpan().IndexOfAny('.', 'e', 'E') < 0 => value.GetRawText(),
+ JsonValueKind.String => value.GetString(),
+ _ => null,
+ };
+
+ if (text is null)
+ {
+ return null;
+ }
+
+ var digits = text.AsSpan();
+ var negative = digits.StartsWith("-");
+ if (negative || digits.StartsWith("+"))
+ {
+ digits = digits[1..];
+ }
+
+ if (digits.IsEmpty || digits.ContainsAnyExceptInRange('0', '9'))
+ {
+ return null;
+ }
+
+ /* An unsigned type reads no minus sign at all, so "-0" is refused though it is no smaller than zero, and the
+ sign alone makes it too small. Nothing negative needs parsing then. */
+ if (negative && bounds.Min.IsZero)
+ {
+ return "too small";
+ }
+
+ digits = digits.TrimStart('0');
+ if (digits.Length > 20)
+ {
+ return negative ? "too small" : "too large";
+ }
+
+ var whole = digits.IsEmpty ? BigInteger.Zero : BigInteger.Parse(digits, NumberStyles.None, CultureInfo.InvariantCulture);
+ if (negative)
+ {
+ whole = -whole;
+ }
+
+ return whole > bounds.Max ? "too large" : whole < bounds.Min ? "too small" : null;
+ }
+
+ /// What a parameter of the type takes, in words: "a whole number of hours", "true or false", "text".
+ private static string Takes(string parameter, Type type)
+ {
+ if (Bounds(type) is not null)
+ {
+ return McpUnknownArgumentGuard.WholeNumberOf(parameter);
+ }
+
+ if (type == typeof(bool))
+ {
+ return "true or false";
+ }
+
+ if (type == typeof(string))
+ {
+ return "text";
+ }
+
+ if (type == typeof(char))
+ {
+ return "a single character as text";
+ }
+
+ if (type == typeof(double) || type == typeof(float) || type == typeof(decimal) || type == typeof(Half))
+ {
+ return "a number";
+ }
+
+ if (type.IsEnum)
+ {
+ return "one of " + string.Join(", ", Enum.GetNames(type));
+ }
+
+ if (type == typeof(DateTime) || type == typeof(DateTimeOffset))
+ {
+ return "a date and time as text, such as 2026-10-02T14:30:00Z";
+ }
+
+ if (type == typeof(DateOnly))
+ {
+ return "a date as text, such as 2026-10-02";
+ }
+
+ if (type == typeof(TimeSpan))
+ {
+ return "a duration as text, such as 01:30:00";
+ }
+
+ if (type == typeof(Guid))
+ {
+ return "a GUID as text";
+ }
+
+ if (ElementTypeOf(type) is { } element)
+ {
+ return "a list (a JSON array) of " + Plural(Nullable.GetUnderlyingType(element) ?? element);
+ }
+
+ return "a value of type " + type.Name;
+ }
+
+ private static string Plural(Type element) =>
+ Bounds(element) is not null ? "whole numbers"
+ : element == typeof(bool) ? "true or false values"
+ : element == typeof(string) ? "text values"
+ : element == typeof(double) || element == typeof(float) || element == typeof(decimal) ? "numbers"
+ : element.IsEnum ? "names from " + string.Join(", ", Enum.GetNames(element))
+ : element.Name + " values";
+
+ /// The element type of an array or of a generic list-like collection, or null for any other type.
+ private static Type? ElementTypeOf(Type type)
+ {
+ if (type.IsArray)
+ {
+ return type.GetElementType();
+ }
+
+ if (!type.IsGenericType || type.GetGenericArguments().Length != 1)
+ {
+ return null;
+ }
+
+ var definition = type.GetGenericTypeDefinition();
+ return definition == typeof(List<>) || definition == typeof(IList<>) || definition == typeof(ICollection<>)
+ || definition == typeof(IEnumerable<>) || definition == typeof(IReadOnlyList<>)
+ || definition == typeof(IReadOnlyCollection<>) || definition == typeof(HashSet<>)
+ ? type.GetGenericArguments()[0]
+ : null;
+ }
+}
diff --git a/PerformanceMonitor.Common/Mcp/McpUnknownArgumentGuard.cs b/PerformanceMonitor.Common/Mcp/McpUnknownArgumentGuard.cs
index 936afc59fa..80404bf1f0 100644
--- a/PerformanceMonitor.Common/Mcp/McpUnknownArgumentGuard.cs
+++ b/PerformanceMonitor.Common/Mcp/McpUnknownArgumentGuard.cs
@@ -11,6 +11,9 @@
using System.Globalization;
using System.Linq;
using System.Text.Json;
+using System.Threading;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
@@ -60,14 +63,25 @@ namespace PerformanceMonitor.Common;
/// case this exists for), the sentence names the candidate, because "did you mean hours_back" is the whole
/// remedy for the call that motivated the issue.
///
-/// Integer values. The same pass also reads the VALUE of each argument whose parameter is advertised
-/// as an integer. The binder reads an integer parameter only from an integer literal or a string holding one, so
-/// hours_back: 0.5 (or 1.0, "0.5", true) threw inside the SDK before the tool ran,
-/// and the caller got a bare "An error occurred invoking ..." with no word on which argument was wrong or why.
-/// Such a call is now refused with a message that names the argument, says it takes a whole number (of hours,
-/// for hours_back), quotes the value sent and lists the accepted parameters. For the integer types the
-/// tools declare (int, int? and long), the check refuses only values that none of the three
-/// can read, so it cannot refuse a call that would have worked.
+/// Values. The same pass also reads the VALUE of each argument against its parameter. The binder reads
+/// a value into the parameter's declared type and throws on one that type cannot hold, so hours_back: 0.5
+/// (or 1.0, "0.5", true, a number past , a null for an int, a
+/// number sent to a string, a string sent to a boolean, an integer array with a fraction in it) threw inside the SDK
+/// before the tool ran, and the caller got a bare "An error occurred invoking ..." with no word on which argument
+/// was wrong or why. Such a call is now refused with a message that names the argument, says what it takes (a whole
+/// number of hours, for hours_back), quotes the value sent and lists the accepted parameters. The guard reads
+/// every argument once before the binder reads it again, so a caller-sized list is read twice.
+///
+/// The advertised schema cannot make that decision: it says "integer" for an int, a long, a
+/// short and a byte alike, and does not say which parameters take a null. So McpSchemaCompat
+/// records each tool's parameter types as it creates the tool (), this reads them
+/// from the request's services, and refuses exactly the values
+/// System.Text.Json throws on when it reads them into that type, so it cannot refuse a call the binder would
+/// have read. The record keeps each parameter's nullability only for the tests' cross-check: the refusal never reads
+/// it, because it asks the binder whether it can read the value. A tool with no record, or a call made without the
+/// services, falls back to the schema alone: a parameter advertised as an integer refuses what no integer type can
+/// read (a fraction, a word, a boolean, a whole number past ), and passes the rest, null
+/// included.
///
/// The shape., the house's one refusal envelope
/// (status = invalid, hints.parameter = the offending key): the request as given cannot
@@ -88,7 +102,44 @@ public static class McpUnknownArgumentGuard
///
public static McpRequestFilter Instance =>
next => async (request, cancellationToken) =>
- Refuse(request.Params, FindTool(request)) ?? await next(request, cancellationToken);
+ Refuse(request.Params, FindTool(request), RecordOf(request))
+ ?? await next(request, cancellationToken);
+
+ ///
+ /// The parameter types the host recorded, from the request's services. Null when they do not resolve: a transport
+ /// that hands the filter no services, or a host that created its tools some other way. The guard then judges by
+ /// the schema alone, which passes a null for an integer parameter and leaves the SDK's bare error, so it still
+ /// runs but with less to go on. That is said once in the log, not on every call.
+ ///
+ private static McpToolParameterTypes? RecordOf(RequestContext request)
+ {
+ var record = request.Services?.GetService();
+ if (record is null)
+ {
+ WarnOnceThatTheRecordDidNotResolve(request.Services ?? request.Server?.Services);
+ }
+
+ return record;
+ }
+
+ private static int s_recordWarned;
+
+ /// Warns, the first time only, that the tools' parameter types did not resolve. With no logger to ask there
+ /// is nothing to say it to, and the warning is kept for a call that has one.
+ private static void WarnOnceThatTheRecordDidNotResolve(IServiceProvider? services)
+ {
+ if (services?.GetService() is not { } loggerFactory
+ || Interlocked.Exchange(ref s_recordWarned, 1) != 0)
+ {
+ return;
+ }
+
+ loggerFactory.CreateLogger(typeof(McpUnknownArgumentGuard).FullName!).LogWarning(
+ "The tools' parameter types did not resolve from the request's services, so the argument guard judges an integer "
+ + "parameter by the schema alone: a null for a whole-number parameter reaches the SDK's binder, which answers with "
+ + "its own error instead of naming the argument. Register the tools through WithGeminiCompatibleTools on the same "
+ + "service collection the transport serves from.");
+ }
///
/// The refusal, or null when the call is clean. Split from so the decision is
@@ -96,7 +147,11 @@ public static class McpUnknownArgumentGuard
///
/// The call's parameters; only is read.
/// The tool being called, for its advertised input schema. Null (a name the server does not know) is left to the SDK, which owns that error.
- public static CallToolResult? Refuse(CallToolRequestParams? parameters, McpServerTool? tool)
+ /// The declared parameter types the host recorded as it registered its tools, or null. With
+ /// them, each argument is checked against the CLR type of its parameter; without them, or for a tool they do not
+ /// cover, an integer parameter is checked against the schema alone.
+ public static CallToolResult? Refuse(
+ CallToolRequestParams? parameters, McpServerTool? tool, McpToolParameterTypes? types = null)
{
if (parameters?.Arguments is not { Count: > 0 } arguments || tool is null)
{
@@ -128,18 +183,45 @@ the caller believes it narrowed a fleet-wide answer. */
return Envelope(tool.ProtocolTool.Name, unknown, accepted);
}
- /* Every key is known; now the values of the integer parameters. The binder reads an integer
- parameter only from an integer literal or a string holding one, and anything else (0.5, 1.0, 1e1,
- "0.5", true) throws inside the SDK before the tool runs, which the SDK answers with a bare
- "An error occurred invoking ..." that names neither the argument nor the reason. */
- var notWhole = arguments
- .Where(argument => parameterSchemas.TryGetValue(argument.Key, out var schema)
- && IsIntegerParameter(schema)
- && !BinderReadsAsInteger(argument.Value))
+ /* Every key is known; now the values. The binder reads each into its parameter's declared type and
+ throws inside the SDK, before the tool runs, on one that type cannot hold (0.5 for an int, a number past
+ int range, a null for a non-nullable value, a number for a string, a word for a boolean), which the SDK
+ answers with a bare "An error occurred invoking ..." that names neither the argument nor the reason.
+ Where the declared type is on record the check is the binder's own; where it is not, an integer
+ parameter is judged by the schema alone, as before. */
+ var toolName = tool.ProtocolTool.Name;
+ IReadOnlyDictionary? declared = null;
+ _ = types?.TryGet(toolName, out declared);
+
+ var problems = arguments
.OrderBy(argument => argument.Key, StringComparer.Ordinal)
+ .Select(argument => (argument.Key, Sentence: declared is not null && declared.TryGetValue(argument.Key, out var type)
+ ? McpArgumentValueCheck.Problem(toolName, argument.Key, argument.Value, type)
+ : SchemaProblem(toolName, argument.Key, argument.Value, parameterSchemas)))
+ .Where(problem => problem.Sentence is not null)
+ .Select(problem => (problem.Key, Sentence: problem.Sentence!))
.ToList();
- return notWhole.Count == 0 ? null : WholeNumberEnvelope(tool.ProtocolTool.Name, notWhole, accepted);
+ return problems.Count == 0 ? null : ValueEnvelope(toolName, problems, accepted);
+ }
+
+ ///
+ /// The whole-number sentence for an argument whose parameter the schema advertises as an integer and whose value
+ /// no integer type can read, or null. Used for a tool with no recorded parameter types.
+ ///
+ private static string? SchemaProblem(
+ string toolName, string key, JsonElement value, Dictionary parameterSchemas)
+ {
+ if (!parameterSchemas.TryGetValue(key, out var schema) || !IsIntegerParameter(schema) || BinderReadsAsInteger(value))
+ {
+ return null;
+ }
+
+ return OutOfRangeWord(value) is string reason
+ ? $"Argument '{key}' for tool '{toolName}' takes {WholeNumberOf(key)},"
+ + $" and the call sent {Shorten(value.GetRawText())}, which is {reason}."
+ : $"Argument '{key}' for tool '{toolName}' takes {WholeNumberOf(key)} with no decimal point,"
+ + $" such as 1, and the call sent {Shorten(value.GetRawText())}.";
}
///
@@ -176,9 +258,10 @@ private static bool TryReadParameters(McpServerTool tool, out Dictionary
- /// Whether a parameter is advertised as an integer. The schema says "integer" for every int,
- /// int? and long parameter alike, so this cannot tell them apart, and the value check below is
- /// built to be right for all three.
+ /// Whether a parameter is advertised as an integer. The schema says "integer" for every integer type
+ /// (int, int?, long, short, byte) alike, so this cannot tell them apart. It is the
+ /// fallback for a tool whose parameter types were not recorded (), and the value
+ /// check below is built to be right for every integer type.
///
private static bool IsIntegerParameter(JsonElement parameterSchema)
{
@@ -214,10 +297,11 @@ a fraction or a word could be a value the parameter takes. */
/// within , or a string that holds one (the SDK reads numbers from strings). Null passes,
/// because a nullable parameter takes it and the schema does not say which parameters are nullable.
///
- /// This errs only toward passing. A value it passes that the binder still cannot read (above
- /// for an int, or null for a parameter that is not nullable) gets the SDK's
- /// own error, as before. A value it refuses is one that int, int? and long parameters all
- /// fail to read; no tool declares an unsigned or smaller integer type.
+ /// This is the schema-only path, so it errs only toward passing. A value it passes that the binder still
+ /// cannot read (above for an int, or null for a parameter that is not nullable)
+ /// gets the SDK's own error. A value it refuses is one that no integer type reads, and a whole number past
+ /// is the one it cannot tell from a ulong parameter's valid range, which is why a tool
+ /// with recorded types never comes here.
///
private static bool BinderReadsAsInteger(JsonElement value) => value.ValueKind switch
{
@@ -257,31 +341,24 @@ JsonValueKind.Number when value.GetRawText().AsSpan().IndexOfAny('.', 'e', 'E')
}
///
- /// Builds the refusal for integer parameters given a value they cannot take. One sentence per argument, naming
- /// the argument, the unit its name gives (hours for hours_back) and the value that was sent, then the
- /// accepted parameters, as the unknown-argument refusal lists them. A whole number past is
- /// called too large or too small (), since asking for "no decimal point" would
- /// misstate what is wrong with it. hints.parameter is the first argument, the knob the caller must change.
+ /// Builds the refusal for arguments whose values their parameters cannot take. One sentence per argument, naming
+ /// the argument, what it takes (a whole number of hours, for hours_back; true or false; text) and the value
+ /// that was sent, then the accepted parameters, as the unknown-argument refusal lists them.
+ /// hints.parameter is the first argument, the knob the caller must change.
///
- private static CallToolResult WholeNumberEnvelope(
- string toolName, List> notWhole, HashSet accepted)
+ private static CallToolResult ValueEnvelope(
+ string toolName, List<(string Key, string Sentence)> problems, HashSet accepted)
{
- var sentences = notWhole.Select(argument => OutOfRangeWord(argument.Value) is string reason
- ? $"Argument '{argument.Key}' for tool '{toolName}' takes {WholeNumberOf(argument.Key)},"
- + $" and the call sent {Shorten(argument.Value.GetRawText())}, which is {reason}."
- : $"Argument '{argument.Key}' for tool '{toolName}' takes {WholeNumberOf(argument.Key)} with no decimal point,"
- + $" such as 1, and the call sent {Shorten(argument.Value.GetRawText())}.");
-
- var message = string.Join(" ", sentences)
+ var message = string.Join(" ", problems.Select(problem => problem.Sentence))
+ $" Accepted parameters: {string.Join(", ", accepted.OrderBy(name => name, StringComparer.Ordinal))}."
+ " The call was refused before it ran, because the tool cannot read "
- + (notWhole.Count == 1 ? "this value." : "these values.");
+ + (problems.Count == 1 ? "this value." : "these values.");
return new CallToolResult
{
Content = new List
{
- new TextContentBlock { Text = McpHelpers.Refusal(notWhole[0].Key, message) }
+ new TextContentBlock { Text = McpHelpers.Refusal(problems[0].Key, message) }
},
IsError = true,
};
@@ -289,7 +366,7 @@ private static CallToolResult WholeNumberEnvelope(
/// "a whole number of hours" for hours_back, from the unit word in the parameter's name, or
/// "a whole number" when its name has none (limit, top).
- private static string WholeNumberOf(string parameter)
+ internal static string WholeNumberOf(string parameter)
{
var unit = parameter
.Split('_')
@@ -305,7 +382,7 @@ private static string WholeNumberOf(string parameter)
/// The value as it was sent, cut short so a long string cannot flood the message. The shared
/// , so the cut never splits a character.
- private static string Shorten(string rawValue) => McpHelpers.Truncate(rawValue, 40)!;
+ internal static string Shorten(string rawValue) => McpHelpers.Truncate(rawValue, 40)!;
///
/// Builds the refusal. The unknown key goes in hints.parameter — the house convention is that a
From 8b103fee3cb89b733c8baa8d41656635a52dbf04 Mon Sep 17 00:00:00 2001
From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com>
Date: Fri, 2 Oct 2026 20:42:39 -0400
Subject: [PATCH 011/399] Darling builds a background index so
get_query_store_top no longer reads every entry a server holds (#4952)
(#4969)
* Darling builds its Query Store read indexes through one shared background builder, starting with the shared machinery and two new indexes (#4952)
* Live tests pin the two Query Store indexes: valid per chunk, identical reads with and without them, HOT updates kept (#4952)
* Live test comment: name the test roles, not the environment (#4952)
* Tests for deferring the Query Store partial index while the newest chunk is large, and retrying it later; signatures only, behavior still the old single attempt (#4952)
* The Query Store partial index builds only while the newest chunk is small, and a deferred build is retried every hour (#4952)
* The same-results pin also covers the web viewer's table read and the duration trend's table read, with a NULL-start row in the wide table, and the HOT check proves its index is present (#4952)
* Comment: the collector's live and backfill writes both land in the newest chunk (#4952)
* The Query Store partial index retries a deferred build every 5 minutes, so it cannot skip the small-chunk window after a chunk boundary (#4952)
The newest chunk of collect.query_store_stats is under the 256 MB build limit only for a while after each chunk boundary: at 5-9 GB per chunk, roughly the first 40-75 minutes. A retry every hour keeps a fixed phase from the service start, so on such a store it could miss that window every day and never build the index. RetryInterval is now the Query Store collection cycle (the query_store default in CollectorScheduleDefaults, 5 minutes); a try is one catalog read, so the extra tries cost almost nothing. The pin asserts the interval is at most 5 minutes and not zero, and is named for that reason. Comments that described the retry as hourly now say why the interval is short.
* The Query Store background indexes are the BRIN and the wide btree only: the partial index on the raw hypertable, its newest-chunk size check and its retry are removed (#4952)
The partial index was built per chunk on collect.query_store_stats, a hypertable. A per-chunk build takes a ShareLock on each chunk it builds, and the Query Store backfill writes backdated rows into older chunks, not only the newest, under the collector's 10 s COPY deadline, so no chunk is safe to lock while the service runs.
The background step is back to one attempt per service start. A hypertable is skipped with a warning, as before. IndexSpec loses its hypertable build and drop forms, the ensure no longer returns an outcome, and the live tests build the BRIN and the btree.
* Drop the duplicate version-floor assertions from the background index registry test; the BRIN decision tests already cover them (#4952)
* Tests for the Query Store background index review: the shutdown lines name their index, the HOT census builds every background index, the table-read roster covers every per-server read, the hypertable probe splits the table name, and the per-server table floor read is answered by the wide btree; signature only for the split, behavior unchanged (#4952)
* The per-server table floor test vacuums the table before it plans, so the plan under test is the wide btree's index-only scan; on a table whose pages are not yet all-visible the planner takes the older first_execution_time index instead (#4952)
* The Query Store background index run names the index in every shutdown line, and its hypertable probe compares the schema and the name as two parameters; the comments that listed the table's indexes name both secondary btrees and say the wide one is built in the background (#4952)
* Pin the SQLSTATE in the Query Store background index failure warning: a server error names it, an error that carries none names none (#4952)
* The Query Store background index failure warning builds its SQLSTATE suffix in a block-bodied method with no property pattern, so the member scan reads it whole; the output is unchanged (#4952)
---
.../QueryStoreBackgroundIndexesLiveTests.cs | 463 ++++++++++++++++++
.../QueryStoreBackgroundIndexesTests.cs | 377 ++++++++++++++
...ueryStoreIntervalWideBrinIndexLiveTests.cs | 19 +-
.../QueryStoreIntervalWideBrinIndexTests.cs | 17 +-
...eryStoreIntervalWideFirstExecFloorTests.cs | 12 +-
.../DarlingWorker.cs | 18 +-
.../QueryStoreBackgroundIndexes.cs | 370 ++++++++++++++
.../QueryStoreIntervalWide.cs | 29 +-
.../QueryStoreIntervalWideBrinIndex.cs | 193 ++------
9 files changed, 1300 insertions(+), 198 deletions(-)
create mode 100644 Darling/Darling.Tests/QueryStoreBackgroundIndexesLiveTests.cs
create mode 100644 Darling/Darling.Tests/QueryStoreBackgroundIndexesTests.cs
create mode 100644 Darling/PerformanceMonitor.Darling.Storage/QueryStoreBackgroundIndexes.cs
diff --git a/Darling/Darling.Tests/QueryStoreBackgroundIndexesLiveTests.cs b/Darling/Darling.Tests/QueryStoreBackgroundIndexesLiveTests.cs
new file mode 100644
index 0000000000..e81ca4a50a
--- /dev/null
+++ b/Darling/Darling.Tests/QueryStoreBackgroundIndexesLiveTests.cs
@@ -0,0 +1,463 @@
+/*
+ * 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.Globalization;
+using System.Linq;
+using System.Text.Json;
+using System.Threading;
+using System.Threading.Tasks;
+using Npgsql;
+using NpgsqlTypes;
+using PerformanceMonitor.Darling.Service.Mcp;
+using PerformanceMonitor.Darling.Storage;
+using PerformanceMonitor.Darling.Viewer;
+using Xunit;
+
+namespace Darling.Tests;
+
+///
+/// The background Query Store indexes on a real store whose collect.query_store_stats is a hypertable: the
+/// BRIN and #4952's btree both build valid, a second ensure changes nothing, an INVALID leftover is dropped and
+/// rebuilt, the Query Store top reads and the duration trend's table read return identical rows with the indexes
+/// absent and present (a NULL-start row included), the writer's update stays HOT with both present, and a spec aimed
+/// at the hypertable is skipped with a warning and builds nothing.
+///
+/* #1776 own-store: deliberately NOT [Collection("live-postgres")]. Every test reaches DARLING_TEST_PG only to
+ CREATE and DROP its own database through ScratchPostgres and works entirely inside it, so it cannot race
+ live collection. */
+public sealed class QueryStoreBackgroundIndexesLiveTests
+{
+ private const string Raw = "collect.query_store_stats";
+ private const string Wide = "collect.query_store_interval_wide";
+
+ private static string? BaseConnectionString => Environment.GetEnvironmentVariable("DARLING_TEST_PG");
+
+ private static async Task OpenStoreAsync(ScratchPostgres scratch, CancellationToken ct)
+ {
+ var connection = new NpgsqlConnection(scratch.ConnectionString);
+ await connection.OpenAsync(ct);
+ await PgMigrations.MigrateAsync(connection, ct);
+ Assert.True(await LiveTimescaleProbe.TryEnableAsync(scratch.ConnectionString, ct), "TimescaleDB must be available for the #4952 live tests");
+ await TimescaleSupport.ConvertToHypertablesAsync(connection, null, ct);
+ return connection;
+ }
+
+ private static Task ExecAsync(NpgsqlConnection connection, string sql, CancellationToken ct) =>
+ QueryStoreIntervalWideBrinIndexLiveTests.ExecAsync(connection, sql, ct);
+
+ private static Task ScalarAsync(NpgsqlConnection connection, string sql, CancellationToken ct) =>
+ QueryStoreIntervalWideBrinIndexLiveTests.ScalarAsync(connection, sql, ct);
+
+ /* Both background indexes, in the order the worker's delayed task ensures them: the BRIN, then the btree. Shared with
+ the HOT census in QueryStoreIntervalWideBrinIndexLiveTests, which must see every spec in All. */
+ internal static async Task EnsureAllAsync(NpgsqlConnection connection, CancellationToken ct)
+ {
+ foreach (var spec in QueryStoreBackgroundIndexes.All)
+ {
+ await QueryStoreBackgroundIndexes.EnsureAsync(connection, spec, Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance, ct);
+ }
+ }
+
+ private static async Task IndexExistsAsync(NpgsqlConnection connection, string indexName, CancellationToken ct) =>
+ (bool)(await ScalarAsync(connection, $"SELECT to_regclass('{indexName}') IS NOT NULL", ct))!;
+
+ private static async Task IndexIsValidAsync(NpgsqlConnection connection, string indexName, CancellationToken ct) =>
+ (bool)(await ScalarAsync(connection, $"SELECT indisvalid FROM pg_index WHERE indexrelid = '{indexName}'::regclass", ct))!;
+
+ /* One hourly interval per query for servers 1 and 2 (60 queries each) in the wide table, up to the current hour,
+ and one NULL-start row (runtime_stats_interval_id -1, the legacy shape) for server 2 six hours ago, so the
+ duration trend's second arm returns a row beside its first. */
+ private static async Task SeedAsync(NpgsqlConnection connection, CancellationToken ct)
+ {
+ await ExecAsync(connection, @"
+INSERT INTO collect.query_store_interval_wide
+ (collection_time, server_id, database_name, query_id, plan_id, execution_type_desc, first_execution_time,
+ last_execution_time, query_text, execution_count, avg_duration_us, runtime_stats_interval_id, interval_start_time_utc)
+SELECT LEAST(timezone('UTC', now()), h + interval '1 hour'), s, 'db', q, q, 'Regular', h, LEAST(timezone('UTC', now()), h + interval '1 hour'),
+ 'select 1', 10 + q, 1000 + q, extract(epoch FROM h)::bigint / 3600, h
+FROM generate_series(date_trunc('hour', timezone('UTC', now())) - interval '60 hours', date_trunc('hour', timezone('UTC', now())), interval '1 hour') h
+CROSS JOIN generate_series(1, 2) s CROSS JOIN generate_series(1, 60) q;
+
+INSERT INTO collect.query_store_interval_wide
+ (collection_time, server_id, database_name, query_id, plan_id, execution_type_desc, first_execution_time,
+ last_execution_time, query_text, execution_count, avg_duration_us, runtime_stats_interval_id, interval_start_time_utc)
+VALUES (timezone('UTC', now()) - interval '6 hours', 2, 'db', 1, 1, 'Regular', timezone('UTC', now()) - interval '7 hours',
+ timezone('UTC', now()) - interval '6 hours', 'select 1', 5, 1000, -1, NULL);
+
+ANALYZE collect.query_store_interval_wide;", ct);
+ }
+
+ private static NpgsqlParameter Ts(DateTime value) => new NpgsqlParameter { TypedValue = DateTime.SpecifyKind(value, DateTimeKind.Unspecified) };
+
+ private static NpgsqlParameter Text() => new NpgsqlParameter { NpgsqlDbType = NpgsqlDbType.Text, Value = DBNull.Value };
+
+ private static NpgsqlParameter NullTs() => new NpgsqlParameter { NpgsqlDbType = NpgsqlDbType.Timestamp, Value = DBNull.Value };
+
+ /* Every row of a result, stringified (a timestamp round-trips to the tick, a NULL reads as NULL) and sorted, so a
+ comparison between two runs is exact and order-free. */
+ private static async Task> RowsAsync(NpgsqlCommand command, CancellationToken ct)
+ {
+ await using var reader = await command.ExecuteReaderAsync(ct);
+ var rows = new List();
+ while (await reader.ReadAsync(ct))
+ {
+ var values = new object[reader.FieldCount];
+ reader.GetValues(values);
+ rows.Add(string.Join("|", values.Select(v => v switch
+ {
+ DBNull => "NULL",
+ DateTime stamp => stamp.ToString("O", CultureInfo.InvariantCulture),
+ _ => Convert.ToString(v, CultureInfo.InvariantCulture),
+ })));
+ }
+
+ rows.Sort(StringComparer.Ordinal);
+ return rows;
+ }
+
+ /* Every read the indexes speed up, as the product binds each one, for both servers over a 24 h and a 60 h window
+ ending at , which the caller fixes so two runs read the same windows: the MCP table read,
+ the web viewer's table read (once with a closed window end and once with the open end a preset window binds as
+ NULL) and the duration trend's table read. Server 2 holds the NULL-start row, so the trend read's second arm
+ returns it. */
+ private static async Task> ReadAllAsync(NpgsqlConnection connection, DateTime end, CancellationToken ct)
+ {
+ var results = new List();
+ foreach (var serverId in new[] { 1, 2 })
+ {
+ foreach (var hours in new[] { 24, 60 })
+ {
+ var start = end.AddHours(-hours);
+
+ await using (var top = new NpgsqlCommand(DarlingDataReader.QueryStoreTopTableSql, connection))
+ {
+ top.Parameters.Add(new NpgsqlParameter { TypedValue = serverId });
+ top.Parameters.Add(Ts(start));
+ top.Parameters.Add(Ts(end));
+ top.Parameters.Add(new NpgsqlParameter { TypedValue = 500 });
+ top.Parameters.Add(Text());
+ top.Parameters.Add(Text());
+ top.Parameters.Add(Text());
+ var rows = await RowsAsync(top, ct);
+ results.Add($"mcp top|server {serverId}|{hours}h|{rows.Count} rows");
+ results.AddRange(rows.Select(r => $" {r}"));
+ }
+
+ foreach (var openEnd in new[] { false, true })
+ {
+ await using var viewerTop = new NpgsqlCommand(ViewerDataService.QueryStoreTopTableSql, connection);
+ viewerTop.Parameters.Add(new NpgsqlParameter { TypedValue = serverId });
+ viewerTop.Parameters.Add(Ts(start));
+ viewerTop.Parameters.Add(openEnd ? NullTs() : Ts(end));
+ viewerTop.Parameters.Add(new NpgsqlParameter { TypedValue = 500 });
+ viewerTop.Parameters.Add(ViewerDataService.DatabaseFilterParameter(null));
+ var rows = await RowsAsync(viewerTop, ct);
+ results.Add($"viewer top|server {serverId}|{hours}h|{(openEnd ? "open end" : "closed end")}|{rows.Count} rows");
+ results.AddRange(rows.Select(r => $" {r}"));
+ }
+
+ await using (var trend = new NpgsqlCommand(ViewerDataService.QueryStoreDurationTrendTableSql, connection))
+ {
+ trend.Parameters.Add(new NpgsqlParameter { TypedValue = serverId });
+ trend.Parameters.Add(Ts(start));
+ trend.Parameters.Add(Ts(end));
+ trend.Parameters.Add(Ts(end));
+ trend.Parameters.Add(ViewerDataService.DatabaseFilterParameter(null));
+ var rows = await RowsAsync(trend, ct);
+ results.Add($"trend|server {serverId}|{hours}h|{rows.Count} rows");
+ results.AddRange(rows.Select(r => $" {r}"));
+ }
+ }
+ }
+
+ return results;
+ }
+
+ [Fact]
+ public async Task TheReads_AreIdenticalWithTheIndexesAbsentAndPresent()
+ {
+ var baseCs = BaseConnectionString;
+ Assert.SkipWhen(string.IsNullOrEmpty(baseCs), "Set DARLING_TEST_PG to a Postgres connection string to run the #4952 live test.");
+ var ct = TestContext.Current.CancellationToken;
+
+ await using var scratch = await ScratchPostgres.CreateAsync(baseCs!, ct);
+ await using var connection = await OpenStoreAsync(scratch, ct);
+ await SeedAsync(connection, ct);
+
+ Assert.False(await IndexExistsAsync(connection, QueryStoreIntervalWideBrinIndex.IndexName, ct));
+ Assert.False(await IndexExistsAsync(connection, QueryStoreBackgroundIndexes.WideServerFirstExecIndexName, ct));
+ var end = DateTime.UtcNow;
+ var absent = await ReadAllAsync(connection, end, ct);
+
+ /* Non-vacuous: every read returns rows. */
+ Assert.DoesNotContain("mcp top|server 1|24h|0 rows", absent);
+ Assert.DoesNotContain("mcp top|server 2|60h|0 rows", absent);
+ Assert.DoesNotContain("viewer top|server 1|24h|closed end|0 rows", absent);
+ Assert.DoesNotContain("viewer top|server 2|60h|open end|0 rows", absent);
+ Assert.DoesNotContain("trend|server 1|24h|0 rows", absent);
+ Assert.DoesNotContain("trend|server 2|60h|0 rows", absent);
+
+ /* The NULL-start row of server 2 is in what the trend read returns (its second arm), so that arm is compared too. */
+ var legacyPoint = ((DateTime)(await ScalarAsync(connection, $"SELECT collection_time FROM {Wide} WHERE interval_start_time_utc IS NULL", ct))!).ToString("O", CultureInfo.InvariantCulture);
+ Assert.Contains(absent, line => line.TrimStart().StartsWith(legacyPoint, StringComparison.Ordinal));
+
+ await EnsureAllAsync(connection, ct);
+ await ExecAsync(connection, $"ANALYZE {Wide}", ct);
+ var present = await ReadAllAsync(connection, end, ct);
+
+ Assert.Equal(absent, present);
+ }
+
+ [Fact]
+ public async Task Ensure_BuildsTheBtreeAndTheBrinValid_AndASecondEnsureChangesNothing()
+ {
+ var baseCs = BaseConnectionString;
+ Assert.SkipWhen(string.IsNullOrEmpty(baseCs), "Set DARLING_TEST_PG to a Postgres connection string to run the #4952 live test.");
+ var ct = TestContext.Current.CancellationToken;
+
+ await using var scratch = await ScratchPostgres.CreateAsync(baseCs!, ct);
+ await using var connection = await OpenStoreAsync(scratch, ct);
+ await SeedAsync(connection, ct);
+ await EnsureAllAsync(connection, ct);
+
+ var names = new[] { QueryStoreIntervalWideBrinIndex.IndexName, QueryStoreBackgroundIndexes.WideServerFirstExecIndexName };
+ foreach (var name in names)
+ {
+ Assert.True(await IndexIsValidAsync(connection, name, ct), name);
+ }
+
+ var wideDefinition = (string)(await ScalarAsync(connection, $"SELECT pg_get_indexdef('{QueryStoreBackgroundIndexes.WideServerFirstExecIndexName}'::regclass)", ct))!;
+ Assert.Contains("USING btree (server_id, first_execution_time)", wideDefinition);
+ var brinDefinition = (string)(await ScalarAsync(connection, $"SELECT pg_get_indexdef('{QueryStoreIntervalWideBrinIndex.IndexName}'::regclass)", ct))!;
+ Assert.Contains("USING brin (collection_time)", brinDefinition);
+
+ var oids = new List();
+ foreach (var name in names)
+ {
+ oids.Add(await ScalarAsync(connection, $"SELECT '{name}'::regclass::oid", ct));
+ }
+
+ await EnsureAllAsync(connection, ct);
+ for (var i = 0; i < names.Length; i++)
+ {
+ Assert.Equal(oids[i], await ScalarAsync(connection, $"SELECT '{names[i]}'::regclass::oid", ct));
+ }
+ }
+
+ /* The Custom Views fleet loop resolves each server's read source in turn, and for a window of 12 hours or more on this
+ plain table each resolve runs PlainTableFloorSql: MIN(first_execution_time) for one server. With the wide btree the
+ planner answers it through its min/max path, a Limit over an ordered scan of that btree under an index condition on
+ server_id, instead of a pass over the table. The older single-column first_execution_time index also supports a
+ min/max path (it walks entries of every server until one matches), so a plan that merely shows a Limit over an
+ index scan would pass without the wide btree: the pin names the index. */
+ [Fact]
+ public async Task ThePerServerTableFloorRead_IsAnsweredByTheWideBtree_NotByAScan()
+ {
+ var baseCs = BaseConnectionString;
+ Assert.SkipWhen(string.IsNullOrEmpty(baseCs), "Set DARLING_TEST_PG to a Postgres connection string to run the #4952 live test.");
+ var ct = TestContext.Current.CancellationToken;
+
+ await using var scratch = await ScratchPostgres.CreateAsync(baseCs!, ct);
+ await using var connection = await OpenStoreAsync(scratch, ct);
+ await SeedAsync(connection, ct);
+ await EnsureAllAsync(connection, ct);
+
+ /* VACUUM as well as ANALYZE, as a store's autovacuum does: it sets the visibility map, which lets the wide btree
+ (it holds both columns the statement touches) answer with an index-only scan. On a table whose pages are not yet
+ marked all-visible the planner prices that scan's heap fetches at random cost and takes the older index instead,
+ whose order follows the heap. */
+ await ExecAsync(connection, "VACUUM (ANALYZE) collect.query_store_interval_wide", ct);
+
+ /* The premise: two servers, and a table of many pages, so a pass over the table is a real alternative. */
+ Assert.Equal(2L, await ScalarAsync(connection, "SELECT count(DISTINCT server_id) FROM collect.query_store_interval_wide", ct));
+ var pages = (int)(await ScalarAsync(connection, "SELECT relpages FROM pg_class WHERE oid = 'collect.query_store_interval_wide'::regclass", ct))!;
+ Assert.True(pages >= 20, $"the table spans {pages} pages; the pin needs a table whose pass would cost more than an index probe");
+
+ var plan = await ExplainPlainTableFloorAsync(connection, 1, ct);
+
+ var wideName = QueryStoreBackgroundIndexes.WideServerFirstExecIndexName[(QueryStoreBackgroundIndexes.WideServerFirstExecIndexName.IndexOf('.', StringComparison.Ordinal) + 1)..];
+ Assert.DoesNotContain("Seq Scan", plan.NodeTypes);
+ Assert.Contains("Limit", plan.NodeTypes);
+ Assert.Equal(new[] { wideName }, plan.IndexScansUnderLimit.Select(scan => scan.Index).Distinct().ToArray());
+ Assert.All(plan.IndexScansUnderLimit, scan => Assert.Contains("server_id", scan.Cond, StringComparison.Ordinal));
+ }
+
+ /* EXPLAIN of the product's own statement, its server bound as the product binds it: an integer parameter. */
+ private static async Task<(HashSet NodeTypes, List<(string Index, string Cond)> IndexScansUnderLimit)> ExplainPlainTableFloorAsync(
+ NpgsqlConnection connection, int serverId, CancellationToken ct)
+ {
+ await using var explain = new NpgsqlCommand("EXPLAIN (FORMAT JSON) " + QueryStoreIntervalWide.PlainTableFloorSql, connection);
+ explain.Parameters.Add(new NpgsqlParameter { NpgsqlDbType = NpgsqlDbType.Integer, Value = serverId });
+ var json = (string)(await explain.ExecuteScalarAsync(ct))!;
+
+ var nodeTypes = new HashSet(StringComparer.Ordinal);
+ var underLimit = new List<(string Index, string Cond)>();
+
+ void Walk(JsonElement element, bool limited)
+ {
+ if (element.ValueKind == JsonValueKind.Array)
+ {
+ foreach (var item in element.EnumerateArray())
+ {
+ Walk(item, limited);
+ }
+
+ return;
+ }
+
+ if (element.ValueKind != JsonValueKind.Object)
+ {
+ return;
+ }
+
+ var isLimit = false;
+ if (element.TryGetProperty("Node Type", out var type))
+ {
+ nodeTypes.Add(type.GetString()!);
+ isLimit = type.GetString() == "Limit";
+ }
+
+ if (limited && element.TryGetProperty("Index Name", out var index))
+ {
+ underLimit.Add((index.GetString()!, element.TryGetProperty("Index Cond", out var cond) ? cond.GetString()! : string.Empty));
+ }
+
+ foreach (var property in element.EnumerateObject())
+ {
+ Walk(property.Value, limited || isLimit);
+ }
+ }
+
+ using var document = JsonDocument.Parse(json);
+ Walk(document.RootElement, false);
+ return (nodeTypes, underLimit);
+ }
+
+ [Fact]
+ public async Task ASpecAimedAtTheRawHypertable_IsSkippedWithAWarning_AndBuildsNothing()
+ {
+ var baseCs = BaseConnectionString;
+ Assert.SkipWhen(string.IsNullOrEmpty(baseCs), "Set DARLING_TEST_PG to a Postgres connection string to run the #4952 live test.");
+ var ct = TestContext.Current.CancellationToken;
+
+ await using var scratch = await ScratchPostgres.CreateAsync(baseCs!, ct);
+ await using var connection = await OpenStoreAsync(scratch, ct);
+
+ /* The premise: the store holds the raw table as a hypertable, which refuses CREATE INDEX CONCURRENTLY and whose
+ per-chunk build is not offered, so an index there is skipped, never built. */
+ Assert.True((bool)(await ScalarAsync(connection, "SELECT EXISTS (SELECT 1 FROM timescaledb_information.hypertables WHERE hypertable_schema = 'collect' AND hypertable_name = 'query_store_stats')", ct))!);
+
+ var aimedAtTheHypertable = new QueryStoreBackgroundIndexes.IndexSpec(
+ "collect.ix_query_store_stats_skip_probe",
+ Raw,
+ "CREATE INDEX CONCURRENTLY IF NOT EXISTS ix_query_store_stats_skip_probe ON collect.query_store_stats (server_id, collection_time);",
+ "DROP INDEX CONCURRENTLY IF EXISTS collect.ix_query_store_stats_skip_probe;");
+ var logger = new CapturingTestLogger();
+ await QueryStoreBackgroundIndexes.EnsureAsync(connection, aimedAtTheHypertable, logger, ct);
+
+ Assert.Equal(1, logger.CountAtLevel(Microsoft.Extensions.Logging.LogLevel.Warning));
+ Assert.Contains("is a hypertable", logger.Joined, StringComparison.Ordinal);
+ Assert.False(await IndexExistsAsync(connection, aimedAtTheHypertable.IndexName, ct));
+ Assert.Equal(0L, await ScalarAsync(connection, "SELECT count(*) FROM pg_indexes WHERE indexname LIKE '%skip_probe%'", ct));
+
+ /* The real set builds nothing on the raw hypertable either: its index list is the same before and after. */
+ const string rawIndexCount = @"
+SELECT count(*)
+FROM pg_index i
+WHERE i.indrelid = 'collect.query_store_stats'::regclass
+ OR i.indrelid IN (SELECT format('%I.%I', c.chunk_schema, c.chunk_name)::regclass
+ FROM timescaledb_information.chunks c
+ WHERE c.hypertable_schema = 'collect' AND c.hypertable_name = 'query_store_stats')";
+ var before = await ScalarAsync(connection, rawIndexCount, ct);
+ await EnsureAllAsync(connection, ct);
+ Assert.Equal(before, await ScalarAsync(connection, rawIndexCount, ct));
+ }
+
+ [Fact]
+ public async Task AnInvalidLeftover_IsDroppedAndRebuilt_ForTheBtreeAndTheBrin()
+ {
+ var baseCs = BaseConnectionString;
+ Assert.SkipWhen(string.IsNullOrEmpty(baseCs), "Set DARLING_TEST_PG to a Postgres connection string to run the #4952 live test.");
+ var ct = TestContext.Current.CancellationToken;
+
+ await using var scratch = await ScratchPostgres.CreateAsync(baseCs!, ct);
+ await using var connection = await OpenStoreAsync(scratch, ct);
+ await EnsureAllAsync(connection, ct);
+
+ var names = new[] { QueryStoreIntervalWideBrinIndex.IndexName, QueryStoreBackgroundIndexes.WideServerFirstExecIndexName };
+ var oldOids = new Dictionary();
+ foreach (var name in names)
+ {
+ oldOids[name] = await ScalarAsync(connection, $"SELECT '{name}'::regclass::oid", ct);
+
+ /* An interrupted build leaves exactly this catalog state; the test roles are superuser, so flipping
+ indisvalid reproduces it deterministically. */
+ await ExecAsync(connection, $"UPDATE pg_index SET indisvalid = false WHERE indexrelid = '{name}'::regclass", ct);
+ Assert.False(await IndexIsValidAsync(connection, name, ct));
+ }
+
+ await EnsureAllAsync(connection, ct);
+
+ foreach (var name in names)
+ {
+ Assert.True(await IndexIsValidAsync(connection, name, ct), name);
+ Assert.NotEqual(oldOids[name], await ScalarAsync(connection, $"SELECT '{name}'::regclass::oid", ct));
+ }
+ }
+
+ [Fact]
+ public async Task TheUpsertsWholeSetList_StaysHotWithTheWideBtreePresent_AndNotWithABtreeOnASetColumn()
+ {
+ var baseCs = BaseConnectionString;
+ Assert.SkipWhen(string.IsNullOrEmpty(baseCs), "Set DARLING_TEST_PG to a Postgres connection string to run the #4952 live test.");
+ var ct = TestContext.Current.CancellationToken;
+
+ await using var scratch = await ScratchPostgres.CreateAsync(baseCs!, ct);
+ await using var connection = await OpenStoreAsync(scratch, ct);
+ await SeedAsync(connection, ct);
+ await EnsureAllAsync(connection, ct);
+
+ /* The claim is about the wide btree being present, so prove it is: with the ensure taken out the update below
+ stays heap-only trivially, and this test passed without the index until it asserted it. */
+ Assert.True(await IndexIsValidAsync(connection, QueryStoreBackgroundIndexes.WideServerFirstExecIndexName, ct));
+
+ /* Exactly the columns the writer's ON CONFLICT ... DO UPDATE sets, each given a changed value by its type. */
+ var setColumns = QueryStoreIntervalWideBrinIndexLiveTests.UpsertSetColumns();
+ var types = new Dictionary(StringComparer.Ordinal);
+ await using (var typeCommand = new NpgsqlCommand(
+ "SELECT column_name, data_type FROM information_schema.columns WHERE table_schema = 'collect' AND table_name = 'query_store_interval_wide'", connection))
+ await using (var reader = await typeCommand.ExecuteReaderAsync(ct))
+ {
+ while (await reader.ReadAsync(ct))
+ {
+ types[reader.GetString(0)] = reader.GetString(1);
+ }
+ }
+
+ var assignments = setColumns.OrderBy(c => c, StringComparer.Ordinal).Select(column => types[column] switch
+ {
+ "timestamp without time zone" => $"{column} = {column} + interval '1 minute'",
+ "boolean" => $"{column} = NOT COALESCE({column}, false)",
+ "text" => $"{column} = COALESCE({column}, '') || 'x'",
+ _ => $"{column} = COALESCE({column}, 0) + 1",
+ });
+ var setList = string.Join(", ", assignments);
+
+ var (updated, hot) = await QueryStoreIntervalWideBrinIndexLiveTests.UpdateAndReadHotAsync(connection, setList, "server_id = 1 AND query_id = 1", ct);
+ Assert.True(updated >= 20, $"the update must touch rows; touched {updated}");
+ Assert.Equal(updated, hot);
+
+ /* The control: a btree on a SET column makes the same update non-HOT, so the counters can fail. */
+ await ExecAsync(connection, $"CREATE INDEX ix_btree_probe ON {Wide} (execution_count)", ct);
+ var (controlUpdated, controlHot) = await QueryStoreIntervalWideBrinIndexLiveTests.UpdateAndReadHotAsync(connection, setList, "server_id = 1 AND query_id = 1", ct);
+ Assert.Equal(updated, controlUpdated);
+ Assert.Equal(0, controlHot);
+ }
+}
diff --git a/Darling/Darling.Tests/QueryStoreBackgroundIndexesTests.cs b/Darling/Darling.Tests/QueryStoreBackgroundIndexesTests.cs
new file mode 100644
index 0000000000..45eaa7123a
--- /dev/null
+++ b/Darling/Darling.Tests/QueryStoreBackgroundIndexesTests.cs
@@ -0,0 +1,377 @@
+/*
+ * 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.Threading;
+using System.Threading.Tasks;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
+using Npgsql;
+using PerformanceMonitor.Darling.Service.Mcp;
+using PerformanceMonitor.Darling.Storage;
+using PerformanceMonitor.Darling.Viewer;
+using Xunit;
+
+namespace Darling.Tests;
+
+///
+/// The background Query Store indexes without a store: which indexes there are (the BRIN and #4952's btree, none on
+/// the raw hypertable), the build-or-skip decision, the DDL each ensure runs, that the btree's shape is the shape of
+/// the read it serves, and that the worker's one delayed task makes one in-order attempt at each of them.
+///
+public sealed class QueryStoreBackgroundIndexesTests
+{
+ private static readonly QueryStoreBackgroundIndexes.IndexSpec Wide = QueryStoreBackgroundIndexes.WideServerFirstExec;
+ private static readonly QueryStoreBackgroundIndexes.IndexSpec Brin = QueryStoreIntervalWideBrinIndex.Spec;
+
+ [Theory]
+ [InlineData(140000, false, QueryStoreBackgroundIndexes.IndexAction.Build)]
+ [InlineData(180006, false, QueryStoreBackgroundIndexes.IndexAction.Build)]
+ [InlineData(140000, true, QueryStoreBackgroundIndexes.IndexAction.SkipHypertable)]
+ [InlineData(180006, true, QueryStoreBackgroundIndexes.IndexAction.SkipHypertable)]
+ public void TheWideBtree_BuildsConcurrentlyOnTheHeap_AndSkipsAHypertable(
+ int serverVersionNum, bool hypertable, QueryStoreBackgroundIndexes.IndexAction expected)
+ {
+ Assert.Equal(expected, QueryStoreBackgroundIndexes.Decide(Wide, serverVersionNum, hypertable).Action);
+ }
+
+ [Fact]
+ public void TheWideBtreeDdl_IsAConcurrentBtreeOnServerAndFirstExecutionTime()
+ {
+ var sql = Wide.PlainCreateSql;
+ Assert.Contains("CREATE INDEX CONCURRENTLY IF NOT EXISTS ix_query_store_interval_wide_server_first_exec", sql);
+ Assert.Contains("ON collect.query_store_interval_wide (server_id, first_execution_time)", sql);
+ Assert.DoesNotContain("brin", sql, StringComparison.OrdinalIgnoreCase);
+ Assert.DoesNotContain(" WHERE ", sql, StringComparison.Ordinal);
+ Assert.Contains("DROP INDEX CONCURRENTLY IF EXISTS", Wide.PlainDropSql);
+ Assert.Equal(QueryStoreBackgroundIndexes.WideServerFirstExecIndexName, Wide.IndexName);
+ }
+
+ [Fact]
+ public void TheWideBtree_LeadsWithTheServerAndCarriesTheFirstExecutionBoundTheTableReadsFilterOn()
+ {
+ /* Every per-server read of the table, the duration trend once per arm (its two arms are joined by UNION ALL and
+ each carries its own server filter and floor). QueryStoreIntervalWideFirstExecFloorTests pins the rest of each
+ read's floor: its margin, its position after the time bounds, and that the raw reads carry none. */
+ var trendArms = ViewerDataService.QueryStoreDurationTrendTableSql.Split("UNION ALL", StringSplitOptions.None);
+ Assert.Equal(2, trendArms.Length);
+
+ var reads = new (string Name, string Sql)[]
+ {
+ ("DarlingDataReader.QueryStoreTopTableSql", DarlingDataReader.QueryStoreTopTableSql),
+ ("ViewerDataService.QueryStoreTopTableSql", ViewerDataService.QueryStoreTopTableSql),
+ ("ViewerDataService.QueryStoreDurationTrendTableSql, interval arm", trendArms[0]),
+ ("ViewerDataService.QueryStoreDurationTrendTableSql, legacy arm", trendArms[1]),
+ };
+
+ foreach (var (name, sql) in reads)
+ {
+ Assert.True(sql.Contains("WHERE server_id = $1", StringComparison.Ordinal), name + " must filter on the server first");
+ Assert.True(sql.Contains("first_execution_time >= $2 - ", StringComparison.Ordinal), name + " must bound first_execution_time on the window start");
+ }
+
+ Assert.Contains("(server_id, first_execution_time)", Wide.PlainCreateSql);
+ }
+
+ /* The hypertable probe compares the schema and the name as two bound values, so the spelling of a spec's TableName
+ is not matched through a concatenation. */
+ [Theory]
+ [InlineData("collect.query_store_interval_wide", "collect", "query_store_interval_wide")]
+ [InlineData("collect.query_store_stats", "collect", "query_store_stats")]
+ [InlineData("a.b.c", "a.b", "c")]
+ public void ATableName_SplitsAtItsLastDot_IntoItsSchemaAndItsName(string tableName, string schema, string name)
+ {
+ Assert.Equal((schema, name), QueryStoreBackgroundIndexes.SplitTableName(tableName));
+ }
+
+ [Theory]
+ [InlineData("query_store_interval_wide")]
+ [InlineData(".query_store_interval_wide")]
+ [InlineData("collect.")]
+ public void ATableNameWithoutBothAParts_IsRejected(string tableName)
+ {
+ Assert.Throws(() => QueryStoreBackgroundIndexes.SplitTableName(tableName));
+ }
+
+ [Fact]
+ public void EveryBackgroundIndexsTableName_SplitsIntoItsRealSchemaAndName()
+ {
+ foreach (var spec in QueryStoreBackgroundIndexes.All)
+ {
+ var (schema, name) = QueryStoreBackgroundIndexes.SplitTableName(spec.TableName);
+ Assert.Equal("collect", schema);
+ Assert.Equal(spec.TableName, schema + "." + name);
+ }
+ }
+
+ [Fact]
+ public void TheHypertableProbe_ComparesTheSchemaAndTheNameAsTwoParameters_NotAConcatenation()
+ {
+ var sql = QueryStoreBackgroundIndexes.HypertableSql;
+ Assert.Contains("h.hypertable_schema = $1", sql, StringComparison.Ordinal);
+ Assert.Contains("h.hypertable_name = $2", sql, StringComparison.Ordinal);
+ Assert.DoesNotContain("||", sql, StringComparison.Ordinal);
+ Assert.DoesNotContain("'.'", sql, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void TheWritersDoUpdateSetList_NeverSetsTheServerOrTheFirstExecutionTime_SoTheWideBtreeKeepsUpdatesHot()
+ {
+ /* The HOT premise of the wide btree: an update stays heap-only only while no indexed column changes, and the
+ btree's two columns are the first key and part of the upsert's identity, so ON CONFLICT never rewrites them. */
+ var setColumns = QueryStoreIntervalWideBrinIndexLiveTests.UpsertSetColumns();
+ Assert.True(setColumns.Count >= 50, $"the parse found {setColumns.Count} SET columns; the upsert sets about 55");
+ Assert.Contains("collection_time", setColumns);
+ Assert.DoesNotContain("server_id", setColumns);
+ Assert.DoesNotContain("first_execution_time", setColumns);
+
+ var identity = QueryStoreIntervalWide.IdentityColumns.Split(',', StringSplitOptions.TrimEntries);
+ Assert.Contains("server_id", identity);
+ Assert.Contains("first_execution_time", identity);
+ }
+
+ /* collect.query_store_stats is a hypertable, and an index on one can only be built per chunk, which locks each
+ chunk it builds. The Query Store backfill writes backdated rows into older chunks as well as the newest, under
+ the collector's 10 s COPY deadline, so no chunk is safe to lock and no background index targets that table. */
+ [Fact]
+ public void TheBackgroundIndexes_AreTheBrinThenTheWideBtree_AndNoneTargetsTheRawHypertable()
+ {
+ Assert.Equal(
+ new[]
+ {
+ QueryStoreIntervalWideBrinIndex.IndexName,
+ QueryStoreBackgroundIndexes.WideServerFirstExecIndexName,
+ },
+ QueryStoreBackgroundIndexes.All.Select(spec => spec.IndexName).ToArray());
+
+ foreach (var spec in QueryStoreBackgroundIndexes.All)
+ {
+ Assert.NotEqual("collect.query_store_stats", spec.TableName);
+ Assert.DoesNotContain("query_store_stats", spec.PlainCreateSql, StringComparison.Ordinal);
+ Assert.DoesNotContain("query_store_stats", spec.PlainDropSql, StringComparison.Ordinal);
+ }
+ }
+
+ [Fact]
+ public void EveryBackgroundIndex_IsRegisteredOnce_BuildsConcurrentlyAndIdempotently_AndHasAConcurrentDrop()
+ {
+ var all = QueryStoreBackgroundIndexes.All;
+ Assert.Equal(all.Count, all.Select(spec => spec.IndexName).Distinct(StringComparer.Ordinal).Count());
+
+ foreach (var spec in all)
+ {
+ Assert.StartsWith("collect.", spec.IndexName, StringComparison.Ordinal);
+ Assert.StartsWith("collect.", spec.TableName, StringComparison.Ordinal);
+
+ /* Only the plain form exists: a hypertable takes no per-chunk build, it is skipped. */
+ Assert.Contains("CREATE INDEX CONCURRENTLY IF NOT EXISTS", spec.PlainCreateSql);
+ Assert.DoesNotContain("transaction_per_chunk", spec.PlainCreateSql, StringComparison.Ordinal);
+ Assert.Contains("DROP INDEX CONCURRENTLY IF EXISTS", spec.PlainDropSql);
+ }
+ }
+
+ [Fact]
+ public async Task TheDelayedRun_AttemptsEachIndexOnceInOrder()
+ {
+ var calls = new List();
+
+ await QueryStoreBackgroundIndexes.RunDelayedAsync(
+ NullLogger.Instance,
+ TimeSpan.Zero,
+ QueryStoreBackgroundIndexes.All,
+ (spec, _) =>
+ {
+ calls.Add(spec.IndexName);
+ return Task.CompletedTask;
+ },
+ CancellationToken.None);
+
+ Assert.Equal(QueryStoreBackgroundIndexes.All.Select(spec => spec.IndexName).ToArray(), calls);
+ }
+
+ [Fact]
+ public async Task AFailedAttempt_IsNotRetriedInTheSameRun_AndDoesNotStopTheNextIndex()
+ {
+ var calls = new List();
+ var logger = new CapturingTestLogger();
+
+ await QueryStoreBackgroundIndexes.RunDelayedAsync(
+ logger,
+ TimeSpan.Zero,
+ new[] { Wide, Brin },
+ (spec, _) =>
+ {
+ calls.Add(spec.IndexName);
+ return spec == Wide
+ ? throw new InvalidOperationException("the build failed")
+ : Task.CompletedTask;
+ },
+ CancellationToken.None);
+
+ Assert.Equal(new[] { Wide.IndexName, Brin.IndexName }, calls);
+ Assert.Equal(1, logger.CountAtLevel(LogLevel.Warning));
+ Assert.Contains("retried at the next start", logger.Joined, StringComparison.Ordinal);
+ Assert.Contains(Wide.IndexName, logger.Joined, StringComparison.Ordinal);
+ Assert.DoesNotContain("(shutdown)", logger.Joined, StringComparison.Ordinal);
+ }
+
+ /* A failed ensure's warning names the server error's SQLSTATE, so a full disk (53100) reads differently from a lock
+ timeout without opening the exception. An error that carries no SQLSTATE names none. */
+ [Fact]
+ public async Task AFailedEnsure_NamesTheServerErrorsSqlState_InTheWarning()
+ {
+ var line = await LogOneFailedEnsureAsync(new PostgresException("could not extend file", "ERROR", "ERROR", "53100"));
+
+ Assert.StartsWith("Warning:", line, StringComparison.Ordinal);
+ Assert.Contains(nameof(PostgresException) + ", SQLSTATE 53100:", line, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public async Task AFailedEnsure_NamesNoSqlState_WhenTheErrorCarriesNone()
+ {
+ var notFromTheServer = await LogOneFailedEnsureAsync(new InvalidOperationException("the build failed"));
+ var noStateOnTheError = await LogOneFailedEnsureAsync(new NpgsqlException("the connection was closed"));
+
+ Assert.DoesNotContain("SQLSTATE", notFromTheServer, StringComparison.Ordinal);
+ Assert.Contains(nameof(InvalidOperationException) + ": the build failed", notFromTheServer, StringComparison.Ordinal);
+ Assert.DoesNotContain("SQLSTATE", noStateOnTheError, StringComparison.Ordinal);
+ Assert.Contains(nameof(NpgsqlException) + ": the connection was closed", noStateOnTheError, StringComparison.Ordinal);
+ }
+
+ private static async Task LogOneFailedEnsureAsync(Exception failure)
+ {
+ var logger = new CapturingTestLogger();
+
+ await QueryStoreBackgroundIndexes.RunDelayedAsync(
+ logger,
+ TimeSpan.Zero,
+ new[] { Wide },
+ (_, _) => throw failure,
+ CancellationToken.None);
+
+ return Assert.Single(logger.Lines);
+ }
+
+ /* The delayed run's three shutdown lines each name the index they are about: the one in progress, or, before the
+ delay is over, every index the run would have built. None puts a reason where the index name goes. */
+ [Fact]
+ public async Task CancelledBeforeItStarted_NamesEveryIndexTheRunWouldHaveBuilt_AndAttemptsNone()
+ {
+ using var shutdown = new CancellationTokenSource();
+ shutdown.Cancel();
+ var calls = new List();
+ var logger = new CapturingTestLogger();
+
+ await QueryStoreBackgroundIndexes.RunDelayedAsync(
+ logger,
+ QueryStoreBackgroundIndexes.StartDelay,
+ QueryStoreBackgroundIndexes.All,
+ (spec, _) =>
+ {
+ calls.Add(spec.IndexName);
+ return Task.CompletedTask;
+ },
+ shutdown.Token);
+
+ Assert.Empty(calls);
+ var line = Assert.Single(logger.Lines);
+ Assert.StartsWith("Debug:", line, StringComparison.Ordinal);
+ Assert.Contains("cancelled before it started", line, StringComparison.Ordinal);
+ foreach (var spec in QueryStoreBackgroundIndexes.All)
+ {
+ Assert.Contains(spec.IndexName, line, StringComparison.Ordinal);
+ }
+
+ Assert.DoesNotContain("(shutdown)", line, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public async Task ShutdownDuringABuild_NamesTheIndexInProgress_AndOnlyThatOne()
+ {
+ using var shutdown = new CancellationTokenSource();
+ var logger = new CapturingTestLogger();
+
+ await QueryStoreBackgroundIndexes.RunDelayedAsync(
+ logger,
+ TimeSpan.Zero,
+ new[] { Brin, Wide },
+ (spec, token) =>
+ {
+ if (spec == Wide)
+ {
+ shutdown.Cancel();
+ token.ThrowIfCancellationRequested();
+ }
+
+ return Task.CompletedTask;
+ },
+ shutdown.Token);
+
+ var line = Assert.Single(logger.Lines);
+ Assert.StartsWith("Information:", line, StringComparison.Ordinal);
+ Assert.Contains("cancelled at shutdown", line, StringComparison.Ordinal);
+ Assert.Contains("next start retries", line, StringComparison.Ordinal);
+ Assert.Contains(Wide.IndexName, line, StringComparison.Ordinal);
+ Assert.DoesNotContain(Brin.IndexName, line, StringComparison.Ordinal);
+ Assert.DoesNotContain("(shutdown)", line, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public async Task AnErrorRaisedWhileShuttingDown_IsInformation_NamingTheIndexInProgressTheErrorAndTheRetry()
+ {
+ using var shutdown = new CancellationTokenSource();
+ var logger = new CapturingTestLogger();
+
+ await QueryStoreBackgroundIndexes.RunDelayedAsync(
+ logger,
+ TimeSpan.Zero,
+ new[] { Brin, Wide },
+ (spec, _) =>
+ {
+ if (spec == Wide)
+ {
+ shutdown.Cancel();
+ throw new InvalidOperationException("the connection closed under the build");
+ }
+
+ return Task.CompletedTask;
+ },
+ shutdown.Token);
+
+ Assert.Equal(0, logger.CountAtLevel(LogLevel.Warning));
+ var line = Assert.Single(logger.Lines);
+ Assert.StartsWith("Information:", line, StringComparison.Ordinal);
+ Assert.Contains(Wide.IndexName, line, StringComparison.Ordinal);
+ Assert.DoesNotContain(Brin.IndexName, line, StringComparison.Ordinal);
+ Assert.Contains("stopped at shutdown", line, StringComparison.Ordinal);
+ Assert.Contains(nameof(InvalidOperationException), line, StringComparison.Ordinal);
+ Assert.Contains("the connection closed under the build", line, StringComparison.Ordinal);
+ Assert.Contains("next start retries", line, StringComparison.Ordinal);
+ Assert.DoesNotContain("(shutdown)", line, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void TheWorker_RunsTheTwoEnsuresInOneDelayedTask_NotTwoConcurrentOnes()
+ {
+ var source = RepoFile.ReadRepoFile("Darling", "PerformanceMonitor.Darling.Service", "DarlingWorker.cs").Replace("\r\n", "\n");
+
+ Assert.Single(System.Text.RegularExpressions.Regex.Matches(source, @"QueryStoreBackgroundIndexes\.RunDelayedAsync\("));
+ Assert.Contains("QueryStoreBackgroundIndexes.All", source);
+ Assert.DoesNotContain("QueryStoreIntervalWideBrinIndex.RunDelayedAsync", source);
+
+ /* The loop is sequential and each failure is isolated: a failed index warns and the next one still runs. One
+ attempt per start: the start delay is the only wait, so nothing retries an index inside the run. */
+ var engine = RepoFile.ReadRepoFile("Darling", "PerformanceMonitor.Darling.Storage", "QueryStoreBackgroundIndexes.cs").Replace("\r\n", "\n");
+ Assert.Contains("foreach (var spec in specs)", engine);
+ Assert.Contains("catch (Exception ex) when (!cancellationToken.IsCancellationRequested)", engine);
+ Assert.Single(System.Text.RegularExpressions.Regex.Matches(engine, @"Task\.Delay\("));
+ Assert.DoesNotContain("while (", engine, StringComparison.Ordinal);
+ }
+}
diff --git a/Darling/Darling.Tests/QueryStoreIntervalWideBrinIndexLiveTests.cs b/Darling/Darling.Tests/QueryStoreIntervalWideBrinIndexLiveTests.cs
index 0c82074aae..d0895a2ac0 100644
--- a/Darling/Darling.Tests/QueryStoreIntervalWideBrinIndexLiveTests.cs
+++ b/Darling/Darling.Tests/QueryStoreIntervalWideBrinIndexLiveTests.cs
@@ -45,13 +45,13 @@ private static async Task OpenMigratedAsync(ScratchPostgres sc
return connection;
}
- private static async Task ScalarAsync(NpgsqlConnection connection, string sql, CancellationToken ct)
+ internal static async Task ScalarAsync(NpgsqlConnection connection, string sql, CancellationToken ct)
{
await using var command = new NpgsqlCommand(sql, connection);
return await command.ExecuteScalarAsync(ct);
}
- private static async Task ExecAsync(NpgsqlConnection connection, string sql, CancellationToken ct)
+ internal static async Task ExecAsync(NpgsqlConnection connection, string sql, CancellationToken ct)
{
await using var command = new NpgsqlCommand(sql, connection) { CommandTimeout = 300 };
await command.ExecuteNonQueryAsync(ct);
@@ -79,7 +79,7 @@ FROM generate_series(1, {rows}) AS g
"collection_time = collection_time + interval '1 minute', last_execution_time = last_execution_time + interval '1 minute', "
+ "execution_count = execution_count + 1, avg_duration_us = avg_duration_us + 1";
- private static async Task<(long Updated, long Hot)> UpdateAndReadHotAsync(
+ internal static async Task<(long Updated, long Hot)> UpdateAndReadHotAsync(
NpgsqlConnection connection, string setList, string where, CancellationToken ct)
{
/* The pg_stat_xact_* counters are the session's still-pending counts, so an earlier measurement on this
@@ -165,7 +165,7 @@ public async Task TheUpsertsCollectionTimeRewrite_StaysHotWithTheBrin_AndNotWith
}
/// The column names the product's upsert sets in DO UPDATE SET, parsed from its own SQL.
- private static HashSet UpsertSetColumns()
+ internal static HashSet UpsertSetColumns()
{
var sql = QueryStoreIntervalWide.UpsertSql;
var start = sql.IndexOf("DO UPDATE SET", StringComparison.Ordinal);
@@ -230,7 +230,16 @@ public async Task NoNonBrinIndex_CoversAColumnTheUpsertSets_SoTheUpsertStaysHotF
await using var scratch = await ScratchPostgres.CreateAsync(baseCs!, ct);
await using var connection = await OpenMigratedAsync(scratch, ct);
- await EnsureAsync(connection, ct);
+
+ /* Every background index the service builds, not the BRIN alone: the census reads the catalog after the product's
+ own list has been ensured, so the next member of QueryStoreBackgroundIndexes.All is scanned without an edit here. */
+ await QueryStoreBackgroundIndexesLiveTests.EnsureAllAsync(connection, ct);
+ foreach (var spec in QueryStoreBackgroundIndexes.All)
+ {
+ Assert.True(
+ (bool)(await ScalarAsync(connection, $"SELECT indisvalid FROM pg_index WHERE indexrelid = '{spec.IndexName}'::regclass", ct))!,
+ $"{spec.IndexName} must be built and valid before the census reads the catalog");
+ }
/* Data-independent: the catalog, not a seeded update. The scan must see the identity index and the
first_execution_time index, or it reads nothing. */
diff --git a/Darling/Darling.Tests/QueryStoreIntervalWideBrinIndexTests.cs b/Darling/Darling.Tests/QueryStoreIntervalWideBrinIndexTests.cs
index f62c0ce070..b82b5bed24 100644
--- a/Darling/Darling.Tests/QueryStoreIntervalWideBrinIndexTests.cs
+++ b/Darling/Darling.Tests/QueryStoreIntervalWideBrinIndexTests.cs
@@ -66,7 +66,8 @@ public void TheBuildSql_IsConcurrent_Idempotent_AndABrinOnCollectionTime()
[Fact]
public void TheEnsure_ReadsValidityFirst_AndGuardsHypertablesThroughAViewCheck()
{
- var source = RepoFile.ReadRepoFile("Darling", "PerformanceMonitor.Darling.Storage", "QueryStoreIntervalWideBrinIndex.cs");
+ /* The machinery moved to QueryStoreBackgroundIndexes (#4952) so the two new btrees share it; the pins moved with it. */
+ var source = RepoFile.ReadRepoFile("Darling", "PerformanceMonitor.Darling.Storage", "QueryStoreBackgroundIndexes.cs");
/* The validity read is what tells an INVALID leftover from a good index; IF NOT EXISTS alone keeps both. */
Assert.Contains("i.indisvalid", source);
@@ -75,7 +76,7 @@ public void TheEnsure_ReadsValidityFirst_AndGuardsHypertablesThroughAViewCheck()
/* The hypertable catalog view is queried only after to_regclass says it exists (no TimescaleDB, no view). */
Assert.Contains("to_regclass('timescaledb_information.hypertables')", source);
Assert.Contains("if (hasHypertableView)", source);
- Assert.Contains("BrinAction.SkipHypertable", source);
+ Assert.Contains("IndexAction.SkipHypertable", source);
Assert.Contains("LogWarning", source);
}
@@ -85,14 +86,14 @@ public void TheWorker_SchedulesTheEnsureAfterMigrations_WithoutAwaitingItOnTheSt
var source = RepoFile.ReadRepoFile("Darling", "PerformanceMonitor.Darling.Service", "DarlingWorker.cs").Replace("\r\n", "\n");
var migrate = source.IndexOf("PgMigrations.MigrateAsync(migrateConnection", System.StringComparison.Ordinal);
- var launch = source.IndexOf("var intervalWideBrin = QueryStoreIntervalWideBrinIndex.RunDelayedAsync(", System.StringComparison.Ordinal);
- var drain = source.IndexOf("await intervalWideBrin;", System.StringComparison.Ordinal);
+ var launch = source.IndexOf("var queryStoreIndexes = QueryStoreBackgroundIndexes.RunDelayedAsync(", System.StringComparison.Ordinal);
+ var drain = source.IndexOf("await queryStoreIndexes;", System.StringComparison.Ordinal);
var loopStop = source.IndexOf("PerformanceMonitor Darling collection loop stopped", System.StringComparison.Ordinal);
Assert.True(migrate > 0 && launch > migrate, "the ensure must launch after migrations");
Assert.True(drain > launch && drain < loopStop, "the ensure is drained only at shutdown, after the collection loop");
- Assert.Single(Regex.Matches(source, @"await intervalWideBrin;"));
- Assert.Contains("QueryStoreIntervalWideBrinIndex.StartDelay", source);
+ Assert.Single(Regex.Matches(source, @"await queryStoreIndexes;"));
+ Assert.Contains("QueryStoreBackgroundIndexes.StartDelay", source);
}
[Fact]
@@ -103,7 +104,7 @@ public async Task RunDelayed_CancelledDuringTheDelay_LogsAtDebug_NotInformation(
using var cts = new CancellationTokenSource();
cts.CancelAfter(TimeSpan.FromMilliseconds(200));
- await QueryStoreIntervalWideBrinIndex.RunDelayedAsync(dataSource, logger, TimeSpan.FromMinutes(20), cts.Token);
+ await QueryStoreBackgroundIndexes.RunDelayedAsync(dataSource, logger, TimeSpan.FromMinutes(20), QueryStoreBackgroundIndexes.All, cts.Token);
var entry = Assert.Single(logger.Entries);
Assert.Equal(LogLevel.Debug, entry.Level);
@@ -116,7 +117,7 @@ public void RunDelayed_KeepsTheInformationLineOnlyForACancelAfterTheDelay()
/* A cancel that lands after the delay needs a connection held open mid-handshake; Npgsql then may report
the stop as its own exception, so that branch is pinned on the source: the flag is set right after the
delay, the Debug branch is filtered on it, and the Information branch is the unfiltered catch. */
- var source = RepoFile.ReadRepoFile("Darling", "PerformanceMonitor.Darling.Storage", "QueryStoreIntervalWideBrinIndex.cs")
+ var source = RepoFile.ReadRepoFile("Darling", "PerformanceMonitor.Darling.Storage", "QueryStoreBackgroundIndexes.cs")
.Replace("\r\n", "\n");
var delay = source.IndexOf("await Task.Delay(delay, cancellationToken)", System.StringComparison.Ordinal);
diff --git a/Darling/Darling.Tests/QueryStoreIntervalWideFirstExecFloorTests.cs b/Darling/Darling.Tests/QueryStoreIntervalWideFirstExecFloorTests.cs
index fc1b7a1520..c9d2f82924 100644
--- a/Darling/Darling.Tests/QueryStoreIntervalWideFirstExecFloorTests.cs
+++ b/Darling/Darling.Tests/QueryStoreIntervalWideFirstExecFloorTests.cs
@@ -17,12 +17,14 @@
namespace Darling.Tests;
///
-/// #4605: among the indexes of collect.query_store_interval_wide are the unique key (it leads with
-/// server_id and holds first_execution_time as a key column) and
-/// idx_query_store_interval_wide_first_exec. The three per-server reads of the table (the MCP Query Store top,
+/// #4605: among the btrees of collect.query_store_interval_wide are the unique key (it leads with
+/// server_id and holds first_execution_time as a key column),
+/// idx_query_store_interval_wide_first_exec, and, where the service has built it, the wide btree on
+/// (server_id, first_execution_time) (#4952), which the service builds in the background rather than a
+/// migration. The three per-server reads of the table (the MCP Query Store top,
/// the Queries grid and the Trends chart, all WHERE server_id = $1) filter collection_time (the Trends
-/// interval arm interval_start_time_utc), which neither serves, so each walked all of the server's rows. Each
-/// now also carries
+/// interval arm interval_start_time_utc), which none of them serves, so each walked all of the server's rows.
+/// Each now also carries
/// first_execution_time >= <window start> - .
/// The Custom Views route, for all servers or some, carries no floor; its compose text is pinned in
/// DarlingComposeTests.Compile_QueryStoreWideEligible_CarriesNoFirstExecutionTimeFloor. This class pins the
diff --git a/Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs b/Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs
index 2b6c848ef1..0ecfc46f5c 100644
--- a/Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs
+++ b/Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs
@@ -2311,12 +2311,14 @@ startup work below. */
its own connection, its own catch, drained with the other background startup work below. */
var planForceDetailScrub = RunPlanForceActionDetailScrubAsync(postgres, stoppingToken);
- /* #4605: the BRIN index on collect.query_store_interval_wide (collection_time), built CONCURRENTLY in the
- background QueryStoreIntervalWideBrinIndex.StartDelay after start so the full-heap read stays off the
- post-restart IO burst. Launched after migrations confirm the table exists, never awaited on the startup
- path, one attempt per start, and RunDelayedAsync never throws. Drained with the other background work. */
- var intervalWideBrin = QueryStoreIntervalWideBrinIndex.RunDelayedAsync(
- postgres, _logger, QueryStoreIntervalWideBrinIndex.StartDelay, stoppingToken);
+ /* #4605, #4952: the Query Store read indexes - the BRIN on collect.query_store_interval_wide (collection_time)
+ and the btree on its (server_id, first_execution_time) - built in the background
+ QueryStoreBackgroundIndexes.StartDelay after start so their heap reads stay off the post-restart IO burst,
+ one after another, each failure-isolated. Launched after migrations confirm the tables exist, never awaited
+ on the startup path, one attempt per start, and RunDelayedAsync never throws. Drained with the other
+ background work. */
+ var queryStoreIndexes = QueryStoreBackgroundIndexes.RunDelayedAsync(
+ postgres, _logger, QueryStoreBackgroundIndexes.StartDelay, QueryStoreBackgroundIndexes.All, stoppingToken);
/* #4957: one rollup-coverage probe in the background RollupCoverageWarmup.ServiceStartDelay after start, so
the first MCP or web call finds each rollup's floor already measured and does not wait ~10 s on the
@@ -3818,8 +3820,8 @@ it throws is observed rather than lost with the task. */
/* Expected on shutdown. */
}
- /* And the interval-wide BRIN index ensure (#4605), which absorbs its own failures. */
- await intervalWideBrin;
+ /* And the Query Store read index ensures (#4605, #4952), which absorb their own failures. */
+ await queryStoreIndexes;
/* And the rollup-coverage warm (#4957), which also absorbs its own failures. */
await rollupCoverageWarm;
diff --git a/Darling/PerformanceMonitor.Darling.Storage/QueryStoreBackgroundIndexes.cs b/Darling/PerformanceMonitor.Darling.Storage/QueryStoreBackgroundIndexes.cs
new file mode 100644
index 0000000000..1fb5d8a11c
--- /dev/null
+++ b/Darling/PerformanceMonitor.Darling.Storage/QueryStoreBackgroundIndexes.cs
@@ -0,0 +1,370 @@
+/*
+ * 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.Threading;
+using System.Threading.Tasks;
+using Microsoft.Extensions.Logging;
+using Npgsql;
+
+namespace PerformanceMonitor.Darling.Storage;
+
+///
+/// The Query Store read indexes that are built in the background after the service is up rather than by a
+/// migration rung (#4605's BRIN, #4952's btree), and the one piece of machinery they share: the state read,
+/// the build-or-skip decision, the invalid-leftover drop, the build and the delayed start.
+///
+/// Why not a migration rung. Migrations run in one transaction and block startup, and
+/// CREATE INDEX CONCURRENTLY cannot run inside a transaction (25001). A plain in-rung build would hold a lock
+/// that blocks the writer for the whole heap read. An applied rung also never changes, so each ensure is idempotent
+/// and runs on every start: a fresh store builds an empty table's index instantly and no table-creation change is
+/// needed.
+///
+/// One launch, one build at a time. waits
+/// (20 minutes) once, then ensures each index in in order, each on its own connection and each
+/// failure-isolated: a store's volume sits at its IOPS cap for about 15 minutes after an install or restart (cold
+/// cache, migrations, the retention purge, the continuous-aggregate refresh), and builds that each read a whole
+/// table must not stack on top of it, or on each other. One attempt is made per service start; a failure or an
+/// interrupted build is retried at the next start.
+///
+/// An interrupted build leaves an INVALID index behind (a CREATE INDEX CONCURRENTLY that is
+/// cancelled or fails mid-build). IF NOT EXISTS would silently keep it, so reads
+/// validity first and drops an INVALID leftover before building.
+///
+/// Plain tables only. Every index here is built CREATE INDEX CONCURRENTLY
+/// (), which blocks no writes. TimescaleDB refuses CONCURRENTLY on a
+/// hypertable ("hypertables do not support concurrent index creation"), so a spec whose table is a hypertable is
+/// skipped with a warning instead of failing or retrying. The per-chunk alternative
+/// (WITH (timescaledb.transaction_per_chunk)) is deliberately not offered: it takes a ShareLock on each chunk
+/// it builds, for that chunk's scan, and the collector's COPY into a chunk waits behind it under a 10 s deadline.
+/// The Query Store backfill writes backdated rows into older chunks as well as the newest, so no chunk of
+/// collect.query_store_stats, a hypertable, is safe to lock while the service runs, and no spec targets it.
+///
+public static class QueryStoreBackgroundIndexes
+{
+ /// How long after the service is up the background step waits before its one attempt.
+ public static readonly TimeSpan StartDelay = TimeSpan.FromMinutes(20);
+
+ ///
+ /// Command deadline for a build and a drop, in seconds. A build reads a whole heap (about 108 s at 25 GB for the
+ /// BRIN, several minutes at a full 9 days' retention). Two hours leaves headroom on a slow volume while still
+ /// bounding a build stuck behind a long transaction; shutdown cancels it sooner.
+ ///
+ public const int BuildTimeoutSeconds = 7200;
+
+ /// Deadline for the catalog reads, in seconds.
+ public const int CatalogReadTimeoutSeconds = 30;
+
+ /// Name of the btree that lets the per-server table read range-scan first_execution_time (#4952).
+ public const string WideServerFirstExecIndexName = "collect.ix_query_store_interval_wide_server_first_exec";
+
+ /// What the ensure does about one index.
+ public enum IndexAction
+ {
+ /// Build (or keep) the index on a plain table.
+ Build,
+
+ /// Skip: the server predates the index's version floor.
+ SkipServerVersion,
+
+ /// Skip: the table is a hypertable, which refuses CREATE INDEX CONCURRENTLY.
+ SkipHypertable,
+ }
+
+ /// The decision and, for a skip, the reason to log.
+ public readonly record struct IndexDecision(IndexAction Action, string Reason);
+
+ /// One background index: its names, the statements that build and drop it, and when it may not be built.
+ /// The index's schema-qualified name.
+ /// The table's schema-qualified name (schema.name); the hypertable probe splits it at the last dot.
+ /// The build for a plain table; CONCURRENTLY, idempotent. A hypertable is skipped instead.
+ /// The drop of an INVALID leftover on a plain table.
+ /// The first server_version_num that may build it; 0 for any.
+ /// Why a server below the floor must not build it, for the log.
+ public sealed record IndexSpec(
+ string IndexName,
+ string TableName,
+ string PlainCreateSql,
+ string PlainDropSql,
+ int MinimumServerVersionNum = 0,
+ string BelowMinimumReason = "");
+
+ ///
+ /// A btree on collect.query_store_interval_wide (server_id, first_execution_time) (#4952), built
+ /// CONCURRENTLY (the table is a plain heap).
+ ///
+ /// Why. The per-server table reads bound first_execution_time (#4861), but in the unique
+ /// key it is the seventh column, behind database_name, runtime_stats_interval_id, plan_id,
+ /// query_id and replica_role, so the bound cannot narrow the range: the scan reads every index entry
+ /// the server has and tests the bound on each (4.3 s and 32,787 index blocks of a 7.7 s read at 24 h; at 168 h the
+ /// planner switched to an index-order walk with random heap fetches, 28 s). The other index on the column
+ /// (idx_query_store_interval_wide_first_exec) does not lead with server_id. This one does, so the
+ /// bound becomes the range and the 168 h read can take a bitmap heap scan.
+ ///
+ /// HOT.first_execution_time is part of the upsert's identity and server_id is its
+ /// first key, so ON CONFLICT ... DO UPDATE rewrites neither and an update stays heap-only (the regression
+ /// class #4250 fixed); the live tests pin both the catalog fact and a rolled-back update.
+ ///
+ public static readonly IndexSpec WideServerFirstExec = new(
+ WideServerFirstExecIndexName,
+ "collect.query_store_interval_wide",
+ "CREATE INDEX CONCURRENTLY IF NOT EXISTS ix_query_store_interval_wide_server_first_exec "
+ + "ON collect.query_store_interval_wide (server_id, first_execution_time);",
+ "DROP INDEX CONCURRENTLY IF EXISTS collect.ix_query_store_interval_wide_server_first_exec;");
+
+ /// Every background index, in the order the one delayed task ensures them.
+ public static readonly IReadOnlyList All = new[]
+ {
+ QueryStoreIntervalWideBrinIndex.Spec,
+ WideServerFirstExec,
+ };
+
+ /* One read of everything the decision needs. The names are bound, never interpolated. */
+ internal const string StateSql = @"
+SELECT
+ current_setting('server_version_num')::int AS server_version_num,
+ to_regclass($1) IS NOT NULL AS table_exists,
+ to_regclass('timescaledb_information.hypertables') IS NOT NULL AS has_hypertable_view,
+ (SELECT i.indisvalid
+ FROM pg_index AS i
+ WHERE i.indexrelid = to_regclass($2)) AS index_valid;";
+
+ /* Reached only when StateSql reported the view exists, so a store without TimescaleDB never parses it. $1 is the
+ table's schema and $2 its name (SplitTableName), each compared as it is rather than through a concatenation, so
+ how a spec spells its TableName decides nothing beyond the schema and the name themselves. */
+ internal const string HypertableSql = @"
+SELECT EXISTS
+(
+ SELECT 1
+ FROM timescaledb_information.hypertables AS h
+ WHERE h.hypertable_schema = $1
+ AND h.hypertable_name = $2
+);";
+
+ ///
+ /// A schema-qualified table name split at its last dot into the schema and the name, the two values the hypertable
+ /// probe binds. A name without both parts is a defect in the spec, so it is rejected rather than guessed at.
+ ///
+ internal static (string Schema, string Name) SplitTableName(string tableName)
+ {
+ ArgumentNullException.ThrowIfNull(tableName);
+ var dot = tableName.LastIndexOf('.');
+ if (dot <= 0 || dot == tableName.Length - 1)
+ {
+ throw new ArgumentException($"A table name must be schema-qualified (schema.name); got '{tableName}'.", nameof(tableName));
+ }
+
+ return (tableName[..dot], tableName[(dot + 1)..]);
+ }
+
+ ///
+ /// The pure build-or-skip decision. The version check comes first: below the floor a build is wrong whatever the
+ /// table is. A hypertable is then skipped, because TimescaleDB refuses CONCURRENTLY on one.
+ ///
+ public static IndexDecision Decide(IndexSpec spec, int serverVersionNum, bool tableIsHypertable)
+ {
+ if (serverVersionNum < spec.MinimumServerVersionNum)
+ {
+ return new IndexDecision(
+ IndexAction.SkipServerVersion,
+ $"server_version_num {serverVersionNum} is below {spec.MinimumServerVersionNum}: {spec.BelowMinimumReason}");
+ }
+
+ if (tableIsHypertable)
+ {
+ return new IndexDecision(
+ IndexAction.SkipHypertable,
+ $"{spec.TableName} is a hypertable and TimescaleDB refuses CREATE INDEX CONCURRENTLY on a hypertable");
+ }
+
+ return new IndexDecision(IndexAction.Build, string.Empty);
+ }
+
+ ///
+ /// The background entry point: waits , then makes one attempt
+ /// per spec, in order, each on its own connection, and never throws. A failure of one index is a Warning and the
+ /// next index still runs; the next service start retries it. Cancellation (shutdown) ends it quietly, and the line
+ /// it logs names the index in progress (before the delay is over, every index the run would have built); an error
+ /// raised while shutting down is logged the same way, at Information.
+ ///
+ public static Task RunDelayedAsync(
+ NpgsqlDataSource postgres,
+ ILogger logger,
+ TimeSpan delay,
+ IReadOnlyList specs,
+ CancellationToken cancellationToken) =>
+ RunDelayedAsync(
+ logger,
+ delay,
+ specs,
+ async (spec, token) =>
+ {
+ await using var connection = await postgres.OpenConnectionAsync(token).ConfigureAwait(false);
+ await EnsureAsync(connection, spec, logger, token).ConfigureAwait(false);
+ },
+ cancellationToken);
+
+ ///
+ ///
+ /// with the one ensure attempt injected, so the in-order, failure-isolated loop runs without a store.
+ ///
+ internal static async Task RunDelayedAsync(
+ ILogger logger,
+ TimeSpan delay,
+ IReadOnlyList specs,
+ Func ensureOne,
+ CancellationToken cancellationToken)
+ {
+ var delayFinished = false;
+
+ /* The index the run is on, so a shutdown line can name it: the delay has no index, and the loop sets this before
+ each attempt. */
+ IndexSpec? inProgress = null;
+ try
+ {
+ await Task.Delay(delay, cancellationToken).ConfigureAwait(false);
+ delayFinished = true;
+ foreach (var spec in specs)
+ {
+ inProgress = spec;
+ try
+ {
+ await ensureOne(spec, cancellationToken).ConfigureAwait(false);
+ }
+ catch (Exception ex) when (!cancellationToken.IsCancellationRequested)
+ {
+ LogEnsureFailure(logger, spec.IndexName, ex);
+ }
+ }
+ }
+ catch (OperationCanceledException) when (!delayFinished)
+ {
+ logger.LogDebug(
+ "Query Store index ensure ({Indexes}) was cancelled before it started.",
+ IndexNames(specs));
+ }
+ catch (OperationCanceledException)
+ {
+ logger.LogInformation(
+ "Query Store index ensure ({Index}) was cancelled at shutdown; the next start retries, "
+ + "and drops any half-built leftover first.",
+ inProgress?.IndexName ?? IndexNames(specs));
+ }
+ catch (Exception ex)
+ {
+ /* An error raised while the service shuts down (a connection closed under the build, say) is part of the
+ shutdown, so it is Information like the line above, not a failure to retry-warn about. */
+ logger.LogInformation(
+ "Query Store index ensure ({Index}) stopped at shutdown: {ExceptionType}{SqlState}: {Message}; "
+ + "the next start retries, and drops any half-built leftover first.",
+ inProgress?.IndexName ?? IndexNames(specs), ex.GetType().Name, SqlStateSuffix(ex), ex.Message);
+ }
+ }
+
+ private static string IndexNames(IReadOnlyList specs) => string.Join(", ", specs.Select(spec => spec.IndexName));
+
+ /* ", SQLSTATE 53100" for a server error that carries a SQLSTATE (a full disk reads differently from a lock timeout),
+ nothing otherwise. A block body, because TsqlConventionGuardTests' member scan stops short of an expression-bodied
+ member that has a property pattern in it. */
+ private static string SqlStateSuffix(Exception ex)
+ {
+ if (ex is NpgsqlException npgsql && !string.IsNullOrEmpty(npgsql.SqlState))
+ {
+ return $", SQLSTATE {npgsql.SqlState}";
+ }
+
+ return string.Empty;
+ }
+
+ private static void LogEnsureFailure(ILogger logger, string indexName, Exception ex)
+ {
+ logger.LogWarning(
+ "Query Store index ensure ({Index}) failed and is retried at the next start: {ExceptionType}{SqlState}: {Message}",
+ indexName, ex.GetType().Name, SqlStateSuffix(ex), ex.Message);
+ }
+
+ ///
+ /// Makes sure one index exists and is valid, on , which must be open and outside
+ /// any transaction (CONCURRENTLY fails inside one).
+ ///
+ public static async Task EnsureAsync(
+ NpgsqlConnection connection, IndexSpec spec, ILogger logger, CancellationToken cancellationToken)
+ {
+ int serverVersionNum;
+ bool tableExists;
+ bool hasHypertableView;
+ bool? indexValid;
+
+ await using (var state = new NpgsqlCommand(StateSql, connection) { CommandTimeout = CatalogReadTimeoutSeconds })
+ {
+ state.Parameters.Add(new NpgsqlParameter { NpgsqlDbType = NpgsqlTypes.NpgsqlDbType.Text, Value = spec.TableName });
+ state.Parameters.Add(new NpgsqlParameter { NpgsqlDbType = NpgsqlTypes.NpgsqlDbType.Text, Value = spec.IndexName });
+ await using var reader = await state.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
+ await reader.ReadAsync(cancellationToken).ConfigureAwait(false);
+ serverVersionNum = reader.GetInt32(0);
+ tableExists = reader.GetBoolean(1);
+ hasHypertableView = reader.GetBoolean(2);
+ indexValid = reader.IsDBNull(3) ? null : reader.GetBoolean(3);
+ }
+
+ if (!tableExists)
+ {
+ logger.LogDebug("Query Store index ensure skipped: {Table} does not exist yet.", spec.TableName);
+ return;
+ }
+
+ var isHypertable = false;
+ if (hasHypertableView)
+ {
+ var (tableSchema, tableName) = SplitTableName(spec.TableName);
+ await using var hypertable = new NpgsqlCommand(HypertableSql, connection) { CommandTimeout = CatalogReadTimeoutSeconds };
+ hypertable.Parameters.Add(new NpgsqlParameter { NpgsqlDbType = NpgsqlTypes.NpgsqlDbType.Text, Value = tableSchema });
+ hypertable.Parameters.Add(new NpgsqlParameter { NpgsqlDbType = NpgsqlTypes.NpgsqlDbType.Text, Value = tableName });
+ isHypertable = (bool)(await hypertable.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false))!;
+ }
+
+ var decision = Decide(spec, serverVersionNum, isHypertable);
+ if (decision.Action == IndexAction.SkipServerVersion)
+ {
+ logger.LogInformation("Query Store index {Index} not built: {Reason}.", spec.IndexName, decision.Reason);
+ return;
+ }
+
+ if (decision.Action == IndexAction.SkipHypertable)
+ {
+ logger.LogWarning("Query Store index {Index} not built: {Reason}.", spec.IndexName, decision.Reason);
+ return;
+ }
+
+ if (indexValid == true)
+ {
+ logger.LogDebug("Query Store index {Index} already exists and is valid.", spec.IndexName);
+ return;
+ }
+
+ if (indexValid == false)
+ {
+ logger.LogWarning(
+ "Query Store index {Index} exists but is INVALID (an interrupted build); dropping it and rebuilding.",
+ spec.IndexName);
+ await using var drop = new NpgsqlCommand(spec.PlainDropSql, connection) { CommandTimeout = BuildTimeoutSeconds };
+ await drop.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
+ }
+
+ var started = System.Diagnostics.Stopwatch.StartNew();
+ await using (var build = new NpgsqlCommand(spec.PlainCreateSql, connection) { CommandTimeout = BuildTimeoutSeconds })
+ {
+ await build.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
+ }
+
+ logger.LogInformation(
+ "Query Store index {Index} built in {Seconds:F1}s (valid).", spec.IndexName, started.Elapsed.TotalSeconds);
+ }
+}
diff --git a/Darling/PerformanceMonitor.Darling.Storage/QueryStoreIntervalWide.cs b/Darling/PerformanceMonitor.Darling.Storage/QueryStoreIntervalWide.cs
index aede32d41d..1d924bc947 100644
--- a/Darling/PerformanceMonitor.Darling.Storage/QueryStoreIntervalWide.cs
+++ b/Darling/PerformanceMonitor.Darling.Storage/QueryStoreIntervalWide.cs
@@ -411,7 +411,11 @@ RETURNING 1
table is the only record left, and ResolveReadAsync extends the bound down to
ExactBelowFloorStart = max(window start, filled_since, table floor + one day) and no further: those are the
three bounds under which the table provably holds what raw held before its purge.
- There is no per-server probe on this table: its only secondary index leads with first_execution_time. */
+ There is no per-server probe on this table: of its two secondary btrees, idx_query_store_interval_wide_first_exec
+ leads with first_execution_time, and the wide btree on (server_id, first_execution_time) (#4952,
+ QueryStoreBackgroundIndexes.WideServerFirstExec) leads with server_id but is built in the background by the
+ service, not by a migration, so a store can lack it. The per-server reads' bound on first_execution_time is
+ served by that wide btree where it exists. */
///
/// This table's store-shape inputs for one server: its coverage row's filled_since and
@@ -483,9 +487,12 @@ FROM collect.query_store_interval_wide AS t
/// their own identity — keeps only its LATEST snapshot in the table; every caller that places these rows at
/// collection_time (both duration-trend arm 2's) needs every one of them, not the running maximum. A
/// field store carries zero such rows once raw retention (days) has aged the post-upgrade window out, so
- /// this scan is a bounded, indexed (server_id, collection_time) read on the common path and
- /// costs nothing extra there; it is what lets clause 6 answer TRUE only on the rare upgraded-recently store
- /// this table cannot yet serve correctly. Bounded to the SAME range the table read would use
+ /// the answer is "none" on the common path, and it is what lets clause 6 answer TRUE only on the rare
+ /// upgraded-recently store this table cannot yet serve correctly. The cost (#4952): with only the
+ /// (server_id, collection_time) index this EXISTS finds nothing, so it has to fetch every heap page
+ /// the server's window touches (855 k raw rows and 56 k blocks at 24 h on a large store) to say no. It is an
+ /// exact read, not a cached answer, because the collector can still store a NULL start on a catalog join miss.
+ /// Bounded to the SAME range the table read would use
/// ( through in ), not
/// the caller's raw windowStart, so a legacy row outside the served range cannot force a needless refusal.
///
@@ -534,12 +541,14 @@ AND s.interval_start_time_utc IS NULL
///
/// as a Postgres interval literal (rounded UP to whole minutes, so the SQL form
/// can never be shorter than the margin), for the first_execution_time >= <window start> -
- /// PurgeEdgeMarginSql floor the per-server reads of this table carry (#4605). Among the table's indexes are the
- /// unique key, which leads with server_id and holds first_execution_time as a key column, and
- /// idx_query_store_interval_wide_first_exec. A read that filters only by collection_time (or
- /// interval_start_time_utc) is served by neither, so a per-server read walked all of the server's rows;
- /// the floor filters the unique key's entries before the heap. The Custom Views route, for all servers or some,
- /// does not carry the floor (see ComposeCompiler.BuildFactRelation).
+ /// PurgeEdgeMarginSql floor the per-server reads of this table carry (#4605). Among the table's btrees are the
+ /// unique key, which leads with server_id and holds first_execution_time as a key column,
+ /// idx_query_store_interval_wide_first_exec, and, where the service has built it, the wide btree on
+ /// (server_id, first_execution_time) (#4952), which the service builds in the background rather than a
+ /// migration. A read that filters only by collection_time (or interval_start_time_utc) is served by
+ /// none of them, so a per-server read walked all of the server's rows; the floor filters the unique key's entries
+ /// before the heap and, where the wide btree exists, is the range that btree scans. The Custom Views route, for all
+ /// servers or some, does not carry the floor (see ComposeCompiler.BuildFactRelation).
/// Why no row is lost. The collector keeps only intervals with end_time > @cutoff_time,
/// and the cutoff is never more than before the row's
/// collection_time (WatermarkPolicy.ClampCatchup: C - MaxCatchup with no watermark).
diff --git a/Darling/PerformanceMonitor.Darling.Storage/QueryStoreIntervalWideBrinIndex.cs b/Darling/PerformanceMonitor.Darling.Storage/QueryStoreIntervalWideBrinIndex.cs
index f0461a021b..224dff27b5 100644
--- a/Darling/PerformanceMonitor.Darling.Storage/QueryStoreIntervalWideBrinIndex.cs
+++ b/Darling/PerformanceMonitor.Darling.Storage/QueryStoreIntervalWideBrinIndex.cs
@@ -6,7 +6,6 @@
* Licensed under the MIT License. See LICENSE file in the project root for full license information.
*/
-using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
@@ -60,13 +59,14 @@ namespace PerformanceMonitor.Darling.Storage;
/// range as the table grows. A range that is not summarized yet is always read by the scan, so results stay
/// correct while the summary catches up; the index is 856 kB on that store.
///
-/// The start delay. waits (20 minutes)
-/// first. After an install or restart a big store's volume sits at its IOPS cap for about 15 minutes (cold
-/// cache, migrations, the retention purge, the continuous-aggregate refresh), and a full-heap read on top
-/// slows all of it. One attempt is made per service start; a failure or an interrupted build is retried at
-/// the next start. An interrupted CONCURRENTLY build leaves an INVALID index behind, which
-/// IF NOT EXISTS would silently keep, so the ensure reads validity first and drops an INVALID
-/// leftover before building.
+/// The start delay, the validity read and the build. They are
+/// 's, shared with the btree #4952 adds: it waits
+/// (20 minutes) first, because after an install or restart a
+/// big store's volume sits at its IOPS cap for about 15 minutes (cold cache, migrations, the retention purge, the
+/// continuous-aggregate refresh) and a full-heap read on top slows all of it. One attempt is made per service
+/// start; a failure or an interrupted build is retried at the next start. An interrupted CONCURRENTLY build
+/// leaves an INVALID index behind, which IF NOT EXISTS would silently keep, so the ensure reads validity
+/// first and drops an INVALID leftover before building.
///
/// Reads take the index only at a low random_page_cost. At the default of 4 the planner
/// still chooses the sequential scan for a 12-hour window even with this index present; at about 1.1 or
@@ -78,19 +78,6 @@ public static class QueryStoreIntervalWideBrinIndex
/// The index's schema-qualified name.
public const string IndexName = "collect.ix_query_store_interval_wide_collection_time_brin";
- /// How long after the service is up the background step waits before its one attempt.
- public static readonly TimeSpan StartDelay = TimeSpan.FromMinutes(20);
-
- ///
- /// Command deadline for the build and the drop, in seconds. The build reads the whole heap: about 108 s at
- /// 25 GB, several minutes at a full 9 days' retention. Two hours leaves headroom on a slow volume while
- /// still bounding a build stuck behind a long transaction; shutdown cancels it sooner.
- ///
- public const int BuildTimeoutSeconds = 7200;
-
- /// Deadline for the catalog reads, in seconds.
- public const int CatalogReadTimeoutSeconds = 30;
-
/// First PostgreSQL version whose HOT logic tolerates changes to summarizing-index columns.
public const int MinimumServerVersionNum = 160000;
@@ -101,24 +88,17 @@ public static class QueryStoreIntervalWideBrinIndex
internal const string DropSql =
"DROP INDEX CONCURRENTLY IF EXISTS collect.ix_query_store_interval_wide_collection_time_brin;";
- internal const string StateSql = @"
-SELECT
- current_setting('server_version_num')::int AS server_version_num,
- to_regclass('collect.query_store_interval_wide') IS NOT NULL AS table_exists,
- to_regclass('timescaledb_information.hypertables') IS NOT NULL AS has_hypertable_view,
- (SELECT i.indisvalid
- FROM pg_index AS i
- WHERE i.indexrelid = to_regclass('collect.ix_query_store_interval_wide_collection_time_brin')) AS index_valid;";
-
- /* Reached only when StateSql reported the view exists, so a store without TimescaleDB never parses it. */
- internal const string HypertableSql = @"
-SELECT EXISTS
-(
- SELECT 1
- FROM timescaledb_information.hypertables AS h
- WHERE h.hypertable_schema = 'collect'
- AND h.hypertable_name = 'query_store_interval_wide'
-);";
+ ///
+ /// This index as builds it: CONCURRENTLY on the plain table, no
+ /// hypertable form (a hypertable is skipped with a warning), and not below PostgreSQL 16.
+ ///
+ public static readonly QueryStoreBackgroundIndexes.IndexSpec Spec = new(
+ IndexName,
+ "collect.query_store_interval_wide",
+ CreateSql,
+ DropSql,
+ MinimumServerVersionNum,
+ "a BRIN index on collection_time would make the upsert non-HOT below PG 16");
/// What the ensure does about the index.
public enum BrinAction
@@ -137,137 +117,26 @@ public enum BrinAction
public readonly record struct BrinDecision(BrinAction Action, string Reason);
///
- /// The pure build-or-skip decision. The version check comes first: below 160000 a BRIN on
- /// collection_time makes every upsert non-HOT, whatever the table is.
+ /// The pure build-or-skip decision, for this index. The version
+ /// check comes first: below 160000 a BRIN on collection_time makes every upsert non-HOT, whatever the table
+ /// is.
///
public static BrinDecision Decide(int serverVersionNum, bool tableIsHypertable)
{
- if (serverVersionNum < MinimumServerVersionNum)
- {
- return new BrinDecision(
- BrinAction.SkipServerVersionBelowSixteen,
- $"server_version_num {serverVersionNum} is below {MinimumServerVersionNum}: a BRIN index on "
- + "collection_time would make the upsert non-HOT below PG 16");
- }
-
- if (tableIsHypertable)
- {
- return new BrinDecision(
- BrinAction.SkipHypertable,
- "collect.query_store_interval_wide is a hypertable and TimescaleDB refuses "
- + "CREATE INDEX CONCURRENTLY on a hypertable");
- }
-
- return new BrinDecision(BrinAction.Build, string.Empty);
- }
-
- ///
- /// The background entry point: waits , makes one attempt
- /// on its own connection, and never throws. Cancellation (shutdown) ends it quietly; any other failure is
- /// a Warning and the next service start retries.
- ///
- public static async Task RunDelayedAsync(
- NpgsqlDataSource postgres, ILogger logger, TimeSpan delay, CancellationToken cancellationToken)
- {
- var delayFinished = false;
- try
- {
- await Task.Delay(delay, cancellationToken).ConfigureAwait(false);
- delayFinished = true;
- await using var connection = await postgres.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
- await EnsureAsync(connection, logger, cancellationToken).ConfigureAwait(false);
- }
- catch (OperationCanceledException) when (!delayFinished)
- {
- logger.LogDebug(
- "Query Store interval index ensure ({Index}) was cancelled before it started.",
- IndexName);
- }
- catch (OperationCanceledException)
- {
- logger.LogInformation(
- "Query Store interval index ensure ({Index}) was cancelled at shutdown; the next start retries, "
- + "and drops any half-built leftover first.",
- IndexName);
- }
- catch (Exception ex)
+ var decision = QueryStoreBackgroundIndexes.Decide(Spec, serverVersionNum, tableIsHypertable);
+ var action = decision.Action switch
{
- var sqlState = ex is NpgsqlException { SqlState: { Length: > 0 } state } ? $", SQLSTATE {state}" : string.Empty;
- logger.LogWarning(
- "Query Store interval index ensure ({Index}) failed and is retried at the next start: {ExceptionType}{SqlState}: {Message}",
- IndexName, ex.GetType().Name, sqlState, ex.Message);
- }
+ QueryStoreBackgroundIndexes.IndexAction.SkipServerVersion => BrinAction.SkipServerVersionBelowSixteen,
+ QueryStoreBackgroundIndexes.IndexAction.SkipHypertable => BrinAction.SkipHypertable,
+ _ => BrinAction.Build,
+ };
+ return new BrinDecision(action, decision.Reason);
}
///
/// Makes sure the BRIN index exists and is valid, on , which must be open and
/// outside any transaction (CONCURRENTLY fails with 25001 inside one).
///
- public static async Task EnsureAsync(NpgsqlConnection connection, ILogger logger, CancellationToken cancellationToken)
- {
- int serverVersionNum;
- bool tableExists;
- bool hasHypertableView;
- bool? indexValid;
-
- await using (var state = new NpgsqlCommand(StateSql, connection) { CommandTimeout = CatalogReadTimeoutSeconds })
- await using (var reader = await state.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false))
- {
- await reader.ReadAsync(cancellationToken).ConfigureAwait(false);
- serverVersionNum = reader.GetInt32(0);
- tableExists = reader.GetBoolean(1);
- hasHypertableView = reader.GetBoolean(2);
- indexValid = reader.IsDBNull(3) ? null : reader.GetBoolean(3);
- }
-
- if (!tableExists)
- {
- logger.LogDebug("Query Store interval index ensure skipped: collect.query_store_interval_wide does not exist yet.");
- return;
- }
-
- var isHypertable = false;
- if (hasHypertableView)
- {
- await using var hypertable = new NpgsqlCommand(HypertableSql, connection) { CommandTimeout = CatalogReadTimeoutSeconds };
- isHypertable = (bool)(await hypertable.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false))!;
- }
-
- var decision = Decide(serverVersionNum, isHypertable);
- if (decision.Action == BrinAction.SkipServerVersionBelowSixteen)
- {
- logger.LogInformation("Query Store interval index {Index} not built: {Reason}.", IndexName, decision.Reason);
- return;
- }
-
- if (decision.Action == BrinAction.SkipHypertable)
- {
- logger.LogWarning("Query Store interval index {Index} not built: {Reason}.", IndexName, decision.Reason);
- return;
- }
-
- if (indexValid == true)
- {
- logger.LogDebug("Query Store interval index {Index} already exists and is valid.", IndexName);
- return;
- }
-
- if (indexValid == false)
- {
- logger.LogWarning(
- "Query Store interval index {Index} exists but is INVALID (an interrupted CONCURRENTLY build); dropping it and rebuilding.",
- IndexName);
- await using var drop = new NpgsqlCommand(DropSql, connection) { CommandTimeout = BuildTimeoutSeconds };
- await drop.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
- }
-
- var started = System.Diagnostics.Stopwatch.StartNew();
- await using (var build = new NpgsqlCommand(CreateSql, connection) { CommandTimeout = BuildTimeoutSeconds })
- {
- await build.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
- }
-
- logger.LogInformation(
- "Query Store interval index {Index} built in {Seconds:F1}s (valid).", IndexName, started.Elapsed.TotalSeconds);
- }
+ public static Task EnsureAsync(NpgsqlConnection connection, ILogger logger, CancellationToken cancellationToken) =>
+ QueryStoreBackgroundIndexes.EnsureAsync(connection, Spec, logger, cancellationToken);
}
From 82967c02ea70a170a5de4583ec71b49ca98438f2 Mon Sep 17 00:00:00 2001
From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com>
Date: Fri, 2 Oct 2026 21:13:36 -0400
Subject: [PATCH 012/399] Custom Views panels, Active Queries and Current Waits
say where their data starts (#4953)
Some windows reach further back than the rows the store holds. These surfaces now say where the data starts, instead of drawing a shorter range as if it were the whole window:
- Custom Views panels in the web viewer, and the run_custom_view_panel MCP tool, which uses the same runner. The tool's description names the three causes of a notice: the tier's retention did not cover the window, the panel's own data starts after the window does, or the row cap cut the result.
- Active Queries and Current Waits, in the Darling viewer and in Lite. A range that reaches past the data shows the "Showing since" banner, a drill-down sets it for its own window, and a live or latest-snapshot read takes it down.
- The Queries tab banner and the top-query MCP tools (get_top_queries_by_cpu, get_top_procedures_by_cpu, get_query_store_top, and Darling's get_query_store_clutter). A server that was quiet for the first hours of a window no longer reads as cut short, because the floor is now the oldest row at or before the window's end.
The start is where the store's coverage begins: the retention edge, or the server's first collection. A stitched rollup read probes its successor from the stitch boundary up.
---
.../ComposeDataFloorLiveTests.cs | 680 ++++++++++++++++++
.../ComposeRollupDataFloorLiveTests.cs | 226 ++++++
.../Darling.Tests/ComposeSourceRouterTests.cs | 98 +++
Darling/Darling.Tests/DarlingComposeTests.cs | 63 +-
.../Darling.Tests/DarlingMcpDataToolsTests.cs | 2 +-
Darling/Darling.Tests/DataWindowFloorTests.cs | 366 ++++++++++
.../Darling.Tests/LiveStoreSessionTimeZone.cs | 46 ++
.../LiveStoreSessionTimeZoneTests.cs | 41 ++
.../McpToolGuideHeads.CustomView.cs | 36 +-
.../DarlingMcpCustomViewTools.txt | 2 +-
.../Darling.Tests/QueryStoreTopWindowTests.cs | 19 +-
.../RawWindowFloorQuietStartTests.cs | 226 ++++++
.../RawWindowFloorViewerPortTests.cs | 2 +-
Darling/Darling.Tests/ScratchPostgres.cs | 4 +
.../ViewerDataStartBannerTests.cs | 338 +++++++++
.../web-compose-notice-harness.mjs | 119 +++
.../Compose/ComposeSourceRouter.cs | 25 +-
.../Compose/ComposeStoreAvailability.cs | 111 ++-
.../DarlingWebEndpoints.cs | 23 +-
.../Mcp/DarlingMcpCustomViewTools.cs | 14 +-
.../Mcp/DarlingMcpDataTools.cs | 4 +-
.../Mcp/DarlingMcpQueryStoreClutterTools.cs | 11 +-
.../ReadLatencyRecorder.cs | 4 +-
.../wwwroot/js/compose.js | 6 +-
.../DataWindowFloor.cs | 401 +++++++++++
.../RawWindowFloor.cs | 54 +-
.../ViewerDataService.BlockingTrends.cs | 13 +
.../ViewerDataService.QuerySnapshots.cs | 14 +
.../ViewerServerTab.ActiveQueries.cs | 25 +-
.../ViewerServerTab.Blocking.cs | 8 +-
.../ViewerServerTab.Queries.cs | 62 +-
.../ViewerServerTab.xaml | 22 +-
.../DataStartBannerProbeFailureTests.cs | 126 ++++
Lite.Tests/DataStartBannerTests.cs | 405 +++++++++++
Lite.Tests/DisplayZoneTextFrameTests.cs | 58 +-
Lite.Tests/QueryWindowTruncationTests.cs | 198 ++++-
Lite/Controls/ServerTab.DrillDown.cs | 11 +-
Lite/Controls/ServerTab.Refresh.cs | 80 ++-
Lite/Controls/ServerTab.Slicers.cs | 2 +
Lite/Controls/ServerTab.xaml | 33 +-
Lite/Controls/ServerTab.xaml.cs | 5 +
Lite/Mcp/McpQueryTools.cs | 24 +-
.../LocalDataService.QueryWindowFloor.cs | 165 ++++-
43 files changed, 4014 insertions(+), 158 deletions(-)
create mode 100644 Darling/Darling.Tests/ComposeDataFloorLiveTests.cs
create mode 100644 Darling/Darling.Tests/ComposeRollupDataFloorLiveTests.cs
create mode 100644 Darling/Darling.Tests/DataWindowFloorTests.cs
create mode 100644 Darling/Darling.Tests/LiveStoreSessionTimeZone.cs
create mode 100644 Darling/Darling.Tests/LiveStoreSessionTimeZoneTests.cs
create mode 100644 Darling/Darling.Tests/RawWindowFloorQuietStartTests.cs
create mode 100644 Darling/Darling.Tests/ViewerDataStartBannerTests.cs
create mode 100644 Darling/Darling.Tests/web-compose-notice-harness.mjs
create mode 100644 Darling/PerformanceMonitor.Darling.Storage/DataWindowFloor.cs
create mode 100644 Lite.Tests/DataStartBannerProbeFailureTests.cs
create mode 100644 Lite.Tests/DataStartBannerTests.cs
diff --git a/Darling/Darling.Tests/ComposeDataFloorLiveTests.cs b/Darling/Darling.Tests/ComposeDataFloorLiveTests.cs
new file mode 100644
index 0000000000..0854528390
--- /dev/null
+++ b/Darling/Darling.Tests/ComposeDataFloorLiveTests.cs
@@ -0,0 +1,680 @@
+/*
+ * 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.ComponentModel;
+using System.Diagnostics;
+using System.Globalization;
+using System.IO;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+using System.Threading;
+using System.Threading.Tasks;
+using Npgsql;
+using PerformanceMonitor.Darling.Service;
+using PerformanceMonitor.Darling.Storage;
+using Xunit;
+using static Darling.Tests.RepoFile;
+
+namespace Darling.Tests;
+
+///
+/// A Custom Views panel whose data starts later than its window says so, and names the window it really covers.
+/// A 30-day panel over a table that holds 7 days used to draw 7 days with nothing above the chart. The start comes
+/// from the rows the store holds for the panel's servers, so a quiet stretch at the start of the window, or a server
+/// with an older row before it, does not raise the notice.
+///
+/* #1776 own-store: deliberately NOT [Collection("live-postgres")]. Every test here reaches DARLING_TEST_PG only to CREATE
+ and DROP its own database through ScratchPostgres, then works entirely inside it, so a fleet-wide panel sees only
+ the rows these tests planted. */
+public sealed class ComposeDataFloorLiveTests
+{
+ private const int RecentServerId = -497101;
+ private const string RecentServerName = "data-floor-recent";
+ private const int QuietServerId = -497102;
+ private const string QuietServerName = "data-floor-quiet-start";
+
+ private const int IdleServerId = -497103;
+ private const string IdleServerName = "data-floor-idle-overnight";
+ private const int SparseServerId = -497104;
+ private const string SparseServerName = "data-floor-first-wait-late";
+ private const int NewServerId = -497105;
+ private const string NewServerName = "data-floor-added-two-days-ago";
+ private const int BaselineServerId = -497106;
+ private const string BaselineServerName = "data-floor-baseline-floored-old-server";
+ private const int OldServerId = -497107;
+ private const string OldServerName = "data-floor-first-collected-ninety-days-ago";
+
+ private const string WaitDurationPanel =
+ "{\"source\":\"waiting_tasks\",\"measure\":\"waiting_task_duration_ms\",\"aggregate\":\"max\",\"timeBucket\":\"hour\",\"viz\":\"line\"}";
+
+ private const string FileReadsPanel =
+ "{\"source\":\"file_io_stats\",\"measure\":\"file_reads\",\"aggregate\":\"sum\",\"timeBucket\":\"hour\",\"viz\":\"line\"}";
+
+ [Fact]
+ public async Task AThirtyDayPanel_OverSevenDaysOfRows_SaysWhereTheDataStarts_AgainstDevPostgres()
+ {
+ var ct = TestContext.Current.CancellationToken;
+ await using var store = await SeededStore.CreateAsync(ct);
+
+ var outcome = await RunAsync(store.DataSource, RecentServerName, hours: 720, ct);
+
+ var notice = NoticeOf(outcome);
+ Assert.NotNull(notice);
+ Assert.StartsWith("partial window:", notice, StringComparison.Ordinal);
+ Assert.Contains(Minute(store.RecentFirstRow) + " UTC", notice, StringComparison.Ordinal);
+ Assert.DoesNotContain("—", notice, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public async Task AWindowTheRowsCover_GetsNoNotice_AgainstDevPostgres()
+ {
+ var ct = TestContext.Current.CancellationToken;
+ await using var store = await SeededStore.CreateAsync(ct);
+
+ var outcome = await RunAsync(store.DataSource, RecentServerName, hours: 72, ct);
+
+ Assert.Null(NoticeOf(outcome));
+ }
+
+ ///
+ /// The quiet server held one row 8 days back, then nothing until 6 days back. A 7-day window's own oldest row
+ /// is 6 days back, a day after the window starts, but the store held this server's data before the window
+ /// began, so nothing is missing from the start of the window: no notice.
+ ///
+ [Fact]
+ public async Task AQuietStartInsideTheWindow_GetsNoNotice_AgainstDevPostgres()
+ {
+ var ct = TestContext.Current.CancellationToken;
+ await using var store = await SeededStore.CreateAsync(ct);
+
+ var outcome = await RunAsync(store.DataSource, QuietServerName, hours: 168, ct);
+
+ Assert.Null(NoticeOf(outcome));
+ }
+
+ ///
+ /// A server monitored for months, idle overnight, whose older rows the purge dropped: the 7-day window's first
+ /// waiting task comes 5 hours after its start and no older row exists. The store covered the whole window, so
+ /// there is no notice.
+ ///
+ [Fact]
+ public async Task AnIdleStart_WithNoOlderRow_OnAServerMonitoredForMonths_GetsNoNotice_AgainstDevPostgres()
+ {
+ var ct = TestContext.Current.CancellationToken;
+ await using var store = await SeededStore.CreateAsync(ct);
+
+ Assert.Null(NoticeOf(await RunAsync(store.DataSource, IdleServerName, hours: 168, ct)));
+ }
+
+ ///
+ /// A server collected for 30 days whose first waiting task ever came 3 days ago: a 7-day window is covered,
+ /// so there is no notice.
+ ///
+ [Fact]
+ public async Task AFirstWaitThreeDaysAgo_OnAServerCollectedForThirtyDays_GetsNoNotice_AgainstDevPostgres()
+ {
+ var ct = TestContext.Current.CancellationToken;
+ await using var store = await SeededStore.CreateAsync(ct);
+
+ Assert.Null(NoticeOf(await RunAsync(store.DataSource, SparseServerName, hours: 168, ct)));
+ }
+
+ ///
+ /// A server added 2 days ago, whose first waiting task came a day later: a 7-day window starts before the
+ /// server's coverage, and the notice names where coverage starts (its first collection), not its first row.
+ ///
+ [Fact]
+ public async Task AServerAddedTwoDaysAgo_SaysSinceItsFirstCollection_NotItsFirstRow_AgainstDevPostgres()
+ {
+ var ct = TestContext.Current.CancellationToken;
+ await using var store = await SeededStore.CreateAsync(ct);
+
+ var notice = NoticeOf(await RunAsync(store.DataSource, NewServerName, hours: 168, ct));
+ Assert.NotNull(notice);
+ Assert.Contains("data starts at " + Minute(store.NewServerAdded) + " UTC", notice, StringComparison.Ordinal);
+ }
+
+ ///
+ /// A fleet panel whose only older history belongs to servers that stopped 10 days ago: their old rows do not
+ /// cover a 7-day window, so the panel says where the server it draws starts (added 2 days ago).
+ ///
+ [Fact]
+ public async Task AFleetPanel_StoppedServersOldRows_DoNotHideALateStart_AgainstDevPostgres()
+ {
+ var ct = TestContext.Current.CancellationToken;
+ await using var store = await SeededStore.CreateStoppedFleetAsync(ct);
+
+ var notice = NoticeOf(await RunAsync(store.DataSource, server: null, hours: 168, ct));
+ Assert.NotNull(notice);
+ Assert.Contains("data starts at " + Minute(store.NewServerAdded) + " UTC", notice, StringComparison.Ordinal);
+ }
+
+ ///
+ /// file_io_stats is one of the collectors whose purge is floored at the baseline window, so the schedule gives it
+ /// no retention edge, and a Custom Views panel over it has no tier notice to say where its data starts (that one
+ /// covers a rollup route on a TimescaleDB store). A server registered 100 days ago whose oldest row sits 10 days
+ /// back, the older rows long purged, still reads "data starts at" that row for a 30-day window: its coverage is
+ /// the oldest row the table holds for it, not its first collection, which would put the start 100 days back and
+ /// show no notice at all. A window those rows cover shows none.
+ ///
+ [Fact]
+ public async Task ABaselineFlooredTable_ForAServerOlderThanTheWindow_StillSaysWhereItsOldestRowIs_AgainstDevPostgres()
+ {
+ var ct = TestContext.Current.CancellationToken;
+ await using var store = await SeededStore.CreateBaselineFlooredAsync(ct);
+
+ var notice = NoticeOf(await RunAsync(store.DataSource, FileReadsPanel, BaselineServerName, hours: 720, ct));
+ Assert.NotNull(notice);
+ Assert.Contains("data starts at " + Minute(store.BaselineFirstRow) + " UTC", notice, StringComparison.Ordinal);
+
+ Assert.Null(NoticeOf(await RunAsync(store.DataSource, FileReadsPanel, BaselineServerName, hours: 168, ct)));
+ }
+
+ /// A fleet-wide panel starts where its oldest server's data starts: the quiet server's row 8 days
+ /// back covers a 7-day window, though the recent server's rows start a week back.
+ [Fact]
+ public async Task AFleetPanel_StartsAtItsOldestServer_AgainstDevPostgres()
+ {
+ var ct = TestContext.Current.CancellationToken;
+ await using var store = await SeededStore.CreateAsync(ct);
+
+ Assert.Null(NoticeOf(await RunAsync(store.DataSource, server: null, hours: 168, ct)));
+
+ var month = NoticeOf(await RunAsync(store.DataSource, server: null, hours: 720, ct));
+ Assert.NotNull(month);
+ Assert.Contains(Minute(store.QuietFirstRow) + " UTC", month, StringComparison.Ordinal);
+ }
+
+ ///
+ /// The page shows it: the run's own answer, drawn by the shipped compose.js under Node
+ /// (web-compose-notice-harness.mjs), puts the notice above the chart. Node is skipped when it is not installed,
+ /// the way does.
+ ///
+ [Fact]
+ public async Task TheRenderedPanel_ShowsWhereTheDataStarts_AgainstDevPostgres()
+ {
+ var ct = TestContext.Current.CancellationToken;
+ await using var store = await SeededStore.CreateAsync(ct);
+
+ var outcome = await RunAsync(store.DataSource, RecentServerName, hours: 720, ct);
+ Assert.True(outcome.Error is null, $"compose run failed: {outcome.Error}");
+
+ var scenario = new JsonObject
+ {
+ ["panel"] = JsonNode.Parse(WaitDurationPanel),
+ ["scope"] = new JsonObject { ["server"] = RecentServerName, ["hours"] = 720 },
+ ["answer"] = outcome.Payload!.DeepClone(),
+ };
+
+ if (!TryRender(scenario, out var drawn))
+ {
+ Assert.Skip("Node is not installed, so the shipped page script cannot be run.");
+ return;
+ }
+
+ Assert.Empty(drawn.GetProperty("errors").EnumerateArray());
+ var shown = Assert.Single(drawn.GetProperty("notices").EnumerateArray()).GetString();
+ Assert.StartsWith("partial window:", shown, StringComparison.Ordinal);
+ Assert.Contains(Minute(store.RecentFirstRow) + " UTC", shown, StringComparison.Ordinal);
+ }
+
+ ///
+ /// The desktop viewer's form of the probe, for one server: Active Queries and Current Waits compare it with the
+ /// tab's range. Over a 30-day range the quiet server's start is its row 8 days back, not its first row inside a
+ /// 7-day range, and a server the registry does not know has no start.
+ ///
+ [Fact]
+ public async Task TheViewerProbe_ForOneServer_AnswersWhereItsCoverageStarts_AgainstDevPostgres()
+ {
+ var ct = TestContext.Current.CancellationToken;
+ await using var store = await SeededStore.CreateAsync(ct);
+ var waitingTasks = DataWindowFloor.Source.ForCollectorTable("waiting_tasks");
+ var end = DateTime.UtcNow.AddMinutes(1);
+
+ Assert.Equal(store.QuietFirstRow, await DataWindowFloor.GetForServerAsync(store.DataSource, waitingTasks, QuietServerId, end.AddDays(-30), end, 30, ct));
+ Assert.Equal(store.RecentFirstRow, await DataWindowFloor.GetForServerAsync(store.DataSource, waitingTasks, RecentServerId, end.AddDays(-30), end, 30, ct));
+ Assert.Null(await DataWindowFloor.GetForServerAsync(store.DataSource, waitingTasks, -497199, end.AddDays(-30), end, 30, ct));
+ }
+
+ ///
+ /// The purge keeps waiting_tasks for 7 days, but the run log keeps 60, so a server first collected 90 days ago
+ /// still counts in a window 45 to 40 days back (its logged runs are there) while its coverage starts at the
+ /// purge edge, a week back, after the window ended. A pinned cell sends exactly this window. The panel shows no
+ /// notice, rather than a start that comes after the window's own end.
+ ///
+ [Fact]
+ public async Task AWindowWhollyBeforeTheRetentionEdge_WithLoggedRuns_GetsNoNotice_AgainstDevPostgres()
+ {
+ var ct = TestContext.Current.CancellationToken;
+ await using var store = await SeededStore.CreateOldServerAsync(ct);
+
+ var outcome = await RunInWindowAsync(store.DataSource, OldServerName, store.SeededAt.AddDays(-45), store.SeededAt.AddDays(-40), ct);
+
+ Assert.Null(NoticeOf(outcome));
+ }
+
+ ///
+ /// The same server, a window 10 to 5 days back: its rows start 7 days back, inside the window, so the panel is
+ /// cut at the retention edge and says so, and the window it covers ends where the window does, not at now.
+ ///
+ [Fact]
+ public async Task AWindowStraddlingTheRetentionEdge_NamesTheEdge_AndCoversToTheWindowsEnd_AgainstDevPostgres()
+ {
+ var ct = TestContext.Current.CancellationToken;
+ await using var store = await SeededStore.CreateOldServerAsync(ct);
+ var windowStart = store.SeededAt.AddDays(-10);
+ var windowEnd = store.SeededAt.AddDays(-5);
+
+ var notice = NoticeOf(await RunInWindowAsync(store.DataSource, OldServerName, windowStart, windowEnd, ct));
+
+ Assert.NotNull(notice);
+ Assert.Contains("data starts at " + Minute(store.OldServerFirstRow) + " UTC", notice, StringComparison.Ordinal);
+ Assert.Contains("after the window's start at " + Minute(windowStart) + " UTC", notice, StringComparison.Ordinal);
+ Assert.Contains("covers " + Minute(store.OldServerFirstRow) + " to " + Minute(windowEnd) + " UTC", notice, StringComparison.Ordinal);
+ }
+
+ ///
+ /// The desktop viewer's probe for one server, and the fleet form the panels use, over the window wholly before
+ /// the retention edge: there is no coverage to report, so the Queries tab shows no "Showing since" banner that
+ /// names a time after its own range. A window that straddles the edge still answers it.
+ ///
+ [Fact]
+ public async Task TheProbes_AnswerNothing_ForAWindowWhollyBeforeTheRetentionEdge_AgainstDevPostgres()
+ {
+ var ct = TestContext.Current.CancellationToken;
+ await using var store = await SeededStore.CreateOldServerAsync(ct);
+ var waitingTasks = DataWindowFloor.Source.ForCollectorTable("waiting_tasks");
+ var windowStart = store.SeededAt.AddDays(-45);
+ var windowEnd = store.SeededAt.AddDays(-40);
+
+ Assert.Null(await DataWindowFloor.GetForServerAsync(store.DataSource, waitingTasks, OldServerId, windowStart, windowEnd, 30, ct));
+ Assert.Null(await DataWindowFloor.GetAsync(store.DataSource, [waitingTasks], null, windowStart, windowEnd, 30, ct));
+
+ Assert.Equal(
+ store.OldServerFirstRow,
+ await DataWindowFloor.GetForServerAsync(store.DataSource, waitingTasks, OldServerId, store.SeededAt.AddDays(-10), store.SeededAt.AddDays(-5), 30, ct));
+ }
+
+ private static string Minute(DateTime utc) => utc.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture);
+
+ private static string? NoticeOf(DarlingWebEndpoints.ComposeRunOutcome outcome)
+ {
+ Assert.True(outcome.Error is null, $"compose run failed: {outcome.Error}");
+ return outcome.Payload!["notice"]?.GetValue();
+ }
+
+ private static Task RunAsync(NpgsqlDataSource dataSource, string? server, int hours, CancellationToken ct) =>
+ RunAsync(dataSource, WaitDurationPanel, server, hours, ct);
+
+ private static Task RunAsync(
+ NpgsqlDataSource dataSource, string panel, string? server, int hours, CancellationToken ct)
+ {
+ var body = new JsonObject
+ {
+ ["panel"] = JsonNode.Parse(panel),
+ ["hours"] = hours,
+ };
+ if (server is not null)
+ {
+ body["server"] = server;
+ }
+
+ return DarlingWebEndpoints.RunComposedPanelAsync(dataSource, body, ct);
+ }
+
+ /* A pinned Custom Views cell's run: the absolute window pair, as ISO-8601 UTC text, in place of the relative hours. */
+ private static Task RunInWindowAsync(
+ NpgsqlDataSource dataSource, string server, DateTime windowStartUtc, DateTime windowEndUtc, CancellationToken ct)
+ {
+ var body = new JsonObject
+ {
+ ["panel"] = JsonNode.Parse(WaitDurationPanel),
+ ["server"] = server,
+ ["windowStart"] = windowStartUtc.ToString("o", CultureInfo.InvariantCulture),
+ ["windowEnd"] = windowEndUtc.ToString("o", CultureInfo.InvariantCulture),
+ };
+
+ return DarlingWebEndpoints.RunComposedPanelAsync(dataSource, body, ct);
+ }
+
+ private static bool TryRender(JsonObject scenario, out JsonElement result)
+ {
+ result = default;
+ var scenarioPath = Path.Combine(Path.GetTempPath(), "compose-notice-" + Guid.NewGuid().ToString("N") + ".json");
+ File.WriteAllText(scenarioPath, scenario.ToJsonString());
+ try
+ {
+ var psi = new ProcessStartInfo("node") { RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false };
+ psi.ArgumentList.Add(PathTo("Darling", "Darling.Tests", "web-compose-notice-harness.mjs"));
+ psi.ArgumentList.Add(PathTo("Darling", "PerformanceMonitor.Darling.Service", "wwwroot", "js"));
+ psi.ArgumentList.Add(scenarioPath);
+
+ Process proc;
+ try
+ {
+ proc = Process.Start(psi)!;
+ }
+ catch (Win32Exception)
+ {
+ return false;
+ }
+
+ using (proc)
+ {
+ var error = proc.StandardError.ReadToEndAsync();
+ var output = proc.StandardOutput.ReadToEnd().Trim();
+ if (!proc.WaitForExit(20000))
+ {
+ proc.Kill(entireProcessTree: true);
+ Assert.Fail("the compose notice harness did not finish in 20 s");
+ }
+
+ Assert.True(proc.ExitCode == 0, "the compose notice harness failed: " + error.Result);
+ using var doc = JsonDocument.Parse(output);
+ result = doc.RootElement.Clone();
+ return true;
+ }
+ }
+ finally
+ {
+ File.Delete(scenarioPath);
+ }
+ }
+
+ ///
+ /// A scratch store holding two servers' waiting_tasks rows, written every 30 minutes up to now: the recent
+ /// server's start 7 days back, and the quiet server holds one row 8 days back, then rows from 6 days back.
+ ///
+ private sealed class SeededStore : IAsyncDisposable
+ {
+ private readonly ScratchPostgres _scratch;
+
+ private SeededStore(
+ ScratchPostgres scratch, NpgsqlDataSource dataSource, DateTime recentFirstRow, DateTime quietFirstRow, DateTime newServerAdded,
+ DateTime baselineFirstRow = default, DateTime seededAt = default, DateTime oldServerFirstRow = default)
+ {
+ _scratch = scratch;
+ DataSource = dataSource;
+ RecentFirstRow = recentFirstRow;
+ QuietFirstRow = quietFirstRow;
+ NewServerAdded = newServerAdded;
+ BaselineFirstRow = baselineFirstRow;
+ SeededAt = seededAt;
+ OldServerFirstRow = oldServerFirstRow;
+ }
+
+ /// The minute the store was seeded at: the "now" the old server's windows are measured back from
+ /// ().
+ public DateTime SeededAt { get; }
+
+ /// The oldest waiting_tasks row of the server first collected 90 days ago: the purge edge, 7 days
+ /// before .
+ public DateTime OldServerFirstRow { get; }
+
+ /// When the server added 2 days ago was first collected: its registry row and first logged run.
+ public DateTime NewServerAdded { get; }
+
+ /// The oldest file_io_stats row of the server registered 100 days ago ().
+ public DateTime BaselineFirstRow { get; }
+
+ public NpgsqlDataSource DataSource { get; }
+
+ public DateTime RecentFirstRow { get; }
+
+ public DateTime QuietFirstRow { get; }
+
+ public static async Task CreateAsync(CancellationToken ct)
+ {
+ var baseConnectionString = Environment.GetEnvironmentVariable("DARLING_TEST_PG");
+ Assert.SkipWhen(string.IsNullOrEmpty(baseConnectionString),
+ "Set DARLING_TEST_PG to a Postgres connection string to run the live panel data-start tests.");
+
+ var scratch = await ScratchPostgres.CreateAsync(baseConnectionString!, ct);
+ try
+ {
+ await using (var connection = new NpgsqlConnection(scratch.ConnectionString))
+ {
+ await connection.OpenAsync(ct);
+ await PgMigrations.MigrateAsync(connection, ct);
+ await DarlingMcpTestData.RegisterServerAsync(connection, RecentServerId, RecentServerName, ct);
+ await DarlingMcpTestData.RegisterServerAsync(connection, QuietServerId, QuietServerName, ct);
+
+ var now = DateTime.UtcNow;
+ var end = new DateTime(now.Year, now.Month, now.Day, now.Hour, now.Minute, 0, DateTimeKind.Utc);
+ var recentFirst = end.AddDays(-7);
+ var quietFirst = end.AddDays(-8);
+
+ await InsertEveryHalfHourAsync(connection, RecentServerId, RecentServerName, recentFirst, end, ct);
+ await InsertEveryHalfHourAsync(connection, QuietServerId, QuietServerName, quietFirst, quietFirst, ct);
+ await InsertEveryHalfHourAsync(connection, QuietServerId, QuietServerName, end.AddDays(-6), end, ct);
+ await AddedAtAsync(connection, RecentServerId, recentFirst, ct);
+ await AddedAtAsync(connection, QuietServerId, quietFirst, ct);
+
+ /* Monitored for months and idle overnight: the purge left no row before the 7-day window, and
+ the window's first waiting task came 5 hours in. */
+ await DarlingMcpTestData.RegisterServerAsync(connection, IdleServerId, IdleServerName, ct);
+ await AddedAtAsync(connection, IdleServerId, end.AddDays(-120), ct);
+ await LogRunsAsync(connection, IdleServerId, IdleServerName, end.AddDays(-8), end, ct);
+ await InsertEveryHalfHourAsync(connection, IdleServerId, IdleServerName, end.AddDays(-7).AddHours(5), end, ct);
+
+ /* Collected for 30 days, and its first waiting task ever came 3 days ago. */
+ await DarlingMcpTestData.RegisterServerAsync(connection, SparseServerId, SparseServerName, ct);
+ await AddedAtAsync(connection, SparseServerId, end.AddDays(-30), ct);
+ await LogRunsAsync(connection, SparseServerId, SparseServerName, end.AddDays(-30), end, ct);
+ await InsertEveryHalfHourAsync(connection, SparseServerId, SparseServerName, end.AddDays(-3), end, ct);
+
+ var newServerAdded = await AddNewServerAsync(connection, end, ct);
+
+ return new SeededStore(scratch, NpgsqlDataSource.Create(scratch.ConnectionString), recentFirst, quietFirst, newServerAdded);
+ }
+ }
+ catch
+ {
+ await scratch.DisposeAsync();
+ throw;
+ }
+ }
+
+ ///
+ /// A store holding only two servers that stopped 10 days ago, with rows and runs before that, and the server
+ /// added 2 days ago: the fleet's start is the new server's.
+ ///
+ public static async Task CreateStoppedFleetAsync(CancellationToken ct)
+ {
+ var baseConnectionString = Environment.GetEnvironmentVariable("DARLING_TEST_PG");
+ Assert.SkipWhen(string.IsNullOrEmpty(baseConnectionString),
+ "Set DARLING_TEST_PG to a Postgres connection string to run the live panel data-start tests.");
+
+ var scratch = await ScratchPostgres.CreateAsync(baseConnectionString!, ct);
+ try
+ {
+ await using var connection = new NpgsqlConnection(scratch.ConnectionString);
+ await connection.OpenAsync(ct);
+ await PgMigrations.MigrateAsync(connection, ct);
+
+ var now = DateTime.UtcNow;
+ var end = new DateTime(now.Year, now.Month, now.Day, now.Hour, now.Minute, 0, DateTimeKind.Utc);
+ foreach (var (id, name) in new[] { (RecentServerId, "data-floor-stopped-a"), (QuietServerId, "data-floor-stopped-b") })
+ {
+ await DarlingMcpTestData.RegisterServerAsync(connection, id, name, ct);
+ await AddedAtAsync(connection, id, end.AddDays(-40), ct);
+ await LogRunsAsync(connection, id, name, end.AddDays(-40), end.AddDays(-10), ct);
+ await InsertEveryHalfHourAsync(connection, id, name, end.AddDays(-40), end.AddDays(-10), ct);
+ }
+
+ var newServerAdded = await AddNewServerAsync(connection, end, ct);
+ return new SeededStore(scratch, NpgsqlDataSource.Create(scratch.ConnectionString), end.AddDays(-40), end.AddDays(-40), newServerAdded);
+ }
+ catch
+ {
+ await scratch.DisposeAsync();
+ throw;
+ }
+ }
+
+ ///
+ /// A store holding one server first collected 90 days ago: its collector's runs are in the run log for the last
+ /// 60 days (the log's own horizon, longer than the table's), and its waiting_tasks rows run from 7 days back
+ /// (the purge edge) to now, one every 30 minutes. A window 45 to 40 days back therefore holds logged runs and
+ /// no rows, wholly before the table's coverage.
+ ///
+ public static async Task CreateOldServerAsync(CancellationToken ct)
+ {
+ var baseConnectionString = Environment.GetEnvironmentVariable("DARLING_TEST_PG");
+ Assert.SkipWhen(string.IsNullOrEmpty(baseConnectionString),
+ "Set DARLING_TEST_PG to a Postgres connection string to run the live panel data-start tests.");
+
+ var scratch = await ScratchPostgres.CreateAsync(baseConnectionString!, ct);
+ try
+ {
+ await using var connection = new NpgsqlConnection(scratch.ConnectionString);
+ await connection.OpenAsync(ct);
+ await PgMigrations.MigrateAsync(connection, ct);
+
+ var now = DateTime.UtcNow;
+ var end = new DateTime(now.Year, now.Month, now.Day, now.Hour, now.Minute, 0, DateTimeKind.Utc);
+ var firstRow = end.AddDays(-7);
+ await DarlingMcpTestData.RegisterServerAsync(connection, OldServerId, OldServerName, ct);
+ await AddedAtAsync(connection, OldServerId, end.AddDays(-90), ct);
+ await LogRunsAsync(connection, OldServerId, OldServerName, end.AddDays(-60), end, ct);
+ await InsertEveryHalfHourAsync(connection, OldServerId, OldServerName, firstRow, end, ct);
+
+ return new SeededStore(
+ scratch, NpgsqlDataSource.Create(scratch.ConnectionString), firstRow, firstRow, firstRow,
+ seededAt: end, oldServerFirstRow: firstRow);
+ }
+ catch
+ {
+ await scratch.DisposeAsync();
+ throw;
+ }
+ }
+
+ ///
+ /// A store holding one server registered 100 days ago, whose file_io_stats rows run from 10 days back to now
+ /// (the collector writes one every 30 minutes; the purge has dropped everything older).
+ ///
+ public static async Task CreateBaselineFlooredAsync(CancellationToken ct)
+ {
+ var baseConnectionString = Environment.GetEnvironmentVariable("DARLING_TEST_PG");
+ Assert.SkipWhen(string.IsNullOrEmpty(baseConnectionString),
+ "Set DARLING_TEST_PG to a Postgres connection string to run the live panel data-start tests.");
+
+ var scratch = await ScratchPostgres.CreateAsync(baseConnectionString!, ct);
+ try
+ {
+ await using var connection = new NpgsqlConnection(scratch.ConnectionString);
+ await connection.OpenAsync(ct);
+ await PgMigrations.MigrateAsync(connection, ct);
+
+ var now = DateTime.UtcNow;
+ var end = new DateTime(now.Year, now.Month, now.Day, now.Hour, now.Minute, 0, DateTimeKind.Utc);
+ var firstRow = end.AddDays(-10);
+ await DarlingMcpTestData.RegisterServerAsync(connection, BaselineServerId, BaselineServerName, ct);
+ await AddedAtAsync(connection, BaselineServerId, end.AddDays(-100), ct);
+ await LogRunsAsync(connection, BaselineServerId, BaselineServerName, "file_io_stats", end.AddDays(-100), end, ct);
+
+ await using var insert = new NpgsqlCommand(@"
+INSERT INTO collect.file_io_stats
+ (collection_id, collection_time, server_id, server_name, database_name, file_name, num_of_reads, delta_reads, sample_interval_seconds)
+SELECT row_number() OVER (), t, $1, $2, 'FloorDb', 'FloorFile', 1000, 10, 1800
+FROM generate_series($3::timestamp, $4::timestamp, interval '30 minutes') AS t", connection);
+ insert.Parameters.AddWithValue(BaselineServerId);
+ insert.Parameters.AddWithValue(BaselineServerName);
+ insert.Parameters.AddWithValue(DateTime.SpecifyKind(firstRow, DateTimeKind.Unspecified));
+ insert.Parameters.AddWithValue(DateTime.SpecifyKind(end, DateTimeKind.Unspecified));
+ await insert.ExecuteNonQueryAsync(ct);
+
+ return new SeededStore(scratch, NpgsqlDataSource.Create(scratch.ConnectionString), firstRow, firstRow, firstRow, firstRow);
+ }
+ catch
+ {
+ await scratch.DisposeAsync();
+ throw;
+ }
+ }
+
+ /* The server added 2 days ago: registered and first collected then, its first waiting task a day later. */
+ private static async Task AddNewServerAsync(NpgsqlConnection connection, DateTime end, CancellationToken ct)
+ {
+ var added = end.AddDays(-2);
+ await DarlingMcpTestData.RegisterServerAsync(connection, NewServerId, NewServerName, ct);
+ await AddedAtAsync(connection, NewServerId, added, ct);
+ await LogRunsAsync(connection, NewServerId, NewServerName, added, end, ct);
+ await InsertEveryHalfHourAsync(connection, NewServerId, NewServerName, end.AddDays(-1), end, ct);
+ return added;
+ }
+
+ /* The registry's created_date: the server's first successful connect, which the service writes once. */
+ private static async Task AddedAtAsync(NpgsqlConnection connection, int serverId, DateTime addedUtc, CancellationToken ct)
+ {
+ await using var update = new NpgsqlCommand("UPDATE collect.servers SET created_date = $2 WHERE server_id = $1", connection);
+ update.Parameters.AddWithValue(serverId);
+ update.Parameters.AddWithValue(DateTime.SpecifyKind(addedUtc, DateTimeKind.Unspecified));
+ await update.ExecuteNonQueryAsync(ct);
+ }
+
+ /* The waiting_tasks collector's runs in collection_log, every 30 minutes, whether or not anything waited. */
+ private static Task LogRunsAsync(
+ NpgsqlConnection connection, int serverId, string serverName, DateTime firstUtc, DateTime lastUtc, CancellationToken ct) =>
+ LogRunsAsync(connection, serverId, serverName, "waiting_tasks", firstUtc, lastUtc, ct);
+
+ private static async Task LogRunsAsync(
+ NpgsqlConnection connection, int serverId, string serverName, string collector, DateTime firstUtc, DateTime lastUtc, CancellationToken ct)
+ {
+ await using var insert = new NpgsqlCommand(@"
+INSERT INTO collect.collection_log
+ (log_id, server_id, server_name, collector_name, collection_time, duration_ms, status, rows_collected)
+SELECT
+ row_number() OVER (),
+ $1,
+ $2,
+ $5,
+ t,
+ 12,
+ 'SUCCESS',
+ 0
+FROM generate_series($3::timestamp, $4::timestamp, interval '30 minutes') AS t", connection);
+ insert.Parameters.AddWithValue(serverId);
+ insert.Parameters.AddWithValue(serverName);
+ insert.Parameters.AddWithValue(DateTime.SpecifyKind(firstUtc, DateTimeKind.Unspecified));
+ insert.Parameters.AddWithValue(DateTime.SpecifyKind(lastUtc, DateTimeKind.Unspecified));
+ insert.Parameters.AddWithValue(collector);
+ await insert.ExecuteNonQueryAsync(ct);
+ }
+
+ private static async Task InsertEveryHalfHourAsync(
+ NpgsqlConnection connection, int serverId, string serverName, DateTime firstUtc, DateTime lastUtc, CancellationToken ct)
+ {
+ await using var insert = new NpgsqlCommand(@"
+INSERT INTO collect.waiting_tasks
+ (collection_id, collection_time, server_id, server_name, wait_type, wait_duration_ms, database_name)
+SELECT
+ row_number() OVER (),
+ t,
+ $1,
+ $2,
+ 'LCK_M_X',
+ 250,
+ 'FloorDb'
+FROM generate_series($3::timestamp, $4::timestamp, interval '30 minutes') AS t", connection);
+ insert.Parameters.AddWithValue(serverId);
+ insert.Parameters.AddWithValue(serverName);
+ insert.Parameters.AddWithValue(DateTime.SpecifyKind(firstUtc, DateTimeKind.Unspecified));
+ insert.Parameters.AddWithValue(DateTime.SpecifyKind(lastUtc, DateTimeKind.Unspecified));
+ await insert.ExecuteNonQueryAsync(ct);
+ }
+
+ public async ValueTask DisposeAsync()
+ {
+ await DataSource.DisposeAsync();
+ await _scratch.DisposeAsync();
+ }
+ }
+}
diff --git a/Darling/Darling.Tests/ComposeRollupDataFloorLiveTests.cs b/Darling/Darling.Tests/ComposeRollupDataFloorLiveTests.cs
new file mode 100644
index 0000000000..20dba71d07
--- /dev/null
+++ b/Darling/Darling.Tests/ComposeRollupDataFloorLiveTests.cs
@@ -0,0 +1,226 @@
+/*
+ * 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.Text.Json.Nodes;
+using System.Threading;
+using System.Threading.Tasks;
+using Npgsql;
+using PerformanceMonitor.Darling.Service;
+using PerformanceMonitor.Darling.Storage;
+using Xunit;
+
+namespace Darling.Tests;
+
+///
+/// A panel read from a rollup takes its start from the rollup it read, not from the raw table. Server B's rows
+/// were rolled up from 10 days back, and raw now keeps the last 3 days, as its retention leaves it. Server A's
+/// rollup reaches 20 days back, so the store as a whole covers a 14-day window and routes it to the hourly
+/// rollup. B's own hourly rows start 10 days back, so B's panel says so and names that start. The raw table's
+/// start, 3 days back, would be the wrong answer.
+///
+/* #1776 own-store: deliberately NOT [Collection("live-postgres")]. Every test here reaches DARLING_TEST_PG only
+ to CREATE and DROP its own database through ScratchPostgres, then works entirely inside it. */
+public sealed class ComposeRollupDataFloorLiveTests
+{
+ private const int OldServerId = -497201;
+ private const string OldServerName = "rollup-floor-old";
+ private const int NewServerId = -497202;
+ private const string NewServerName = "rollup-floor-new";
+ private const int StitchedDeepServerId = -497203;
+ private const string StitchedDeepServerName = "rollup-floor-stitched-deep";
+ private const int StitchedScopedServerId = -497204;
+ private const string StitchedScopedServerName = "rollup-floor-stitched-scoped";
+
+ [Fact]
+ public async Task AnHourlyRoutedPanel_TakesItsStartFromTheHourlyRollup_AgainstDevPostgres()
+ {
+ var baseConnectionString = Environment.GetEnvironmentVariable("DARLING_TEST_PG");
+ Assert.SkipWhen(string.IsNullOrEmpty(baseConnectionString),
+ "Set DARLING_TEST_PG to a Postgres connection string (with TimescaleDB installed) to run the rollup data-start test.");
+ var ct = TestContext.Current.CancellationToken;
+
+ await using var scratch = await ScratchPostgres.CreateAsync(baseConnectionString!, ct);
+ await using var connection = new NpgsqlConnection(scratch.ConnectionString);
+ await connection.OpenAsync(ct);
+ await PgMigrations.MigrateAsync(connection, ct);
+
+ var timescaleEnabled = await TimescaleSupport.TryEnableAsync(connection, null, ct);
+ Assert.SkipWhen(!timescaleEnabled, "The rollup data-start test needs TimescaleDB: the panel reads a continuous aggregate.");
+ await TimescaleSupport.ConvertToHypertablesAsync(connection, null, ct);
+ await DarlingMcpTestData.RegisterServerAsync(connection, OldServerId, OldServerName, ct);
+ await DarlingMcpTestData.RegisterServerAsync(connection, NewServerId, NewServerName, ct);
+
+ var today = DateTime.SpecifyKind(DateTime.UtcNow.Date, DateTimeKind.Unspecified);
+ await InsertDailyAsync(connection, OldServerId, OldServerName, today.AddDays(-20), today.AddDays(-1), ct);
+ await InsertDailyAsync(connection, NewServerId, NewServerName, today.AddDays(-10), today.AddDays(-1), ct);
+
+ await TimescaleSupport.EnsureContinuousAggregatesAsync(connection, null, ct);
+ foreach (var view in new[] { TimescaleSupport.QueryStatsHourlyView, TimescaleSupport.QueryStatsIntervalHourlyView })
+ {
+ await using var refresh = new NpgsqlCommand($"CALL refresh_continuous_aggregate('collect.{view}'::regclass, $1::timestamp, $2::timestamp)", connection);
+ refresh.Parameters.AddWithValue(today.AddDays(-21));
+ refresh.Parameters.AddWithValue(today.AddDays(1));
+ await refresh.ExecuteNonQueryAsync(ct);
+ }
+
+ /* Raw keeps the last 3 days, as its retention would leave it; the rollups keep everything they built. */
+ await using (var trim = new NpgsqlCommand("DELETE FROM collect.query_stats WHERE collection_time < $1", connection))
+ {
+ trim.Parameters.AddWithValue(today.AddDays(-3));
+ await trim.ExecuteNonQueryAsync(ct);
+ }
+
+ await using var dataSource = NpgsqlDataSource.Create(scratch.ConnectionString);
+
+ var newServer = await RunAsync(dataSource, NewServerName, ct);
+ Assert.True(newServer.Error is null, $"compose run failed: {newServer.Error}");
+ var sql = (string)newServer.Payload!["sql"]!;
+ Assert.Contains("_hourly", sql, StringComparison.Ordinal);
+
+ var notice = newServer.Payload["notice"]?.GetValue();
+ Assert.NotNull(notice);
+ Assert.StartsWith("partial window:", notice, StringComparison.Ordinal);
+ Assert.Contains(Minute(today.AddDays(-10).AddHours(10)) + " UTC", notice, StringComparison.Ordinal);
+ Assert.DoesNotContain(today.AddDays(-3).ToString("yyyy-MM-dd", CultureInfo.InvariantCulture), notice, StringComparison.Ordinal);
+
+ /* The old server's hourly rows cover the whole window: no notice. */
+ var oldServer = await RunAsync(dataSource, OldServerName, ct);
+ Assert.True(oldServer.Error is null, $"compose run failed: {oldServer.Error}");
+ Assert.Contains("_hourly", (string)oldServer.Payload!["sql"]!, StringComparison.Ordinal);
+ Assert.Null(oldServer.Payload["notice"]);
+ }
+
+ ///
+ /// A stitched read takes the successor rollup only from the stitch boundary up, so for a server the superseded
+ /// rollup holds nothing for, the panel's data starts at the boundary, whatever older buckets the successor holds
+ /// for it. The deep server's superseded hourly reaches back 20 days and the successor hourly was built from 8 days
+ /// back, so the first run measures the boundary at 8 days back and caches it. The scoped server's rows then arrive
+ /// after the superseded hourly was refreshed, and a wide refresh materializes the successor back past the
+ /// boundary: it holds buckets for the scoped server older than the window's start, none of which the stitched
+ /// read uses. The notice names the boundary, not the first of those buckets.
+ ///
+ [Fact]
+ public async Task AStitchedHourlyPanel_StartsAtTheStitchBoundary_WhenTheSuccessorWasMaterializedBackPastIt_AgainstDevPostgres()
+ {
+ var baseConnectionString = Environment.GetEnvironmentVariable("DARLING_TEST_PG");
+ Assert.SkipWhen(string.IsNullOrEmpty(baseConnectionString),
+ "Set DARLING_TEST_PG to a Postgres connection string (with TimescaleDB installed) to run the stitched rollup data-start test.");
+ var ct = TestContext.Current.CancellationToken;
+
+ await using var scratch = await ScratchPostgres.CreateAsync(baseConnectionString!, ct);
+ await using var connection = new NpgsqlConnection(scratch.ConnectionString);
+ await connection.OpenAsync(ct);
+ await PgMigrations.MigrateAsync(connection, ct);
+
+ var timescaleEnabled = await TimescaleSupport.TryEnableAsync(connection, null, ct);
+ Assert.SkipWhen(!timescaleEnabled, "The stitched rollup data-start test needs TimescaleDB: the panel reads continuous aggregates.");
+ await TimescaleSupport.ConvertToHypertablesAsync(connection, null, ct);
+
+ /* The scratch database's own scheduler stops before the ensure sweep creates its refresh policies: a policy
+ run would refresh the superseded hourly over the scoped server's rows, which this test keeps out of it. */
+ await using (var stop = new NpgsqlCommand("SELECT _timescaledb_functions.stop_background_workers()", connection))
+ {
+ await stop.ExecuteNonQueryAsync(ct);
+ }
+
+ await DarlingMcpTestData.RegisterServerAsync(connection, StitchedDeepServerId, StitchedDeepServerName, ct);
+ await DarlingMcpTestData.RegisterServerAsync(connection, StitchedScopedServerId, StitchedScopedServerName, ct);
+
+ var today = DateTime.SpecifyKind(DateTime.UtcNow.Date, DateTimeKind.Unspecified);
+ await InsertDailyAsync(connection, StitchedDeepServerId, StitchedDeepServerName, today.AddDays(-20), today.AddDays(-1), ct);
+ await TimescaleSupport.EnsureContinuousAggregatesAsync(connection, null, ct);
+
+ await RefreshAsync(connection, TimescaleSupport.QueryStatsHourlyView, today.AddDays(-21), today.AddDays(1), ct);
+ await RefreshAsync(connection, TimescaleSupport.QueryStatsIntervalHourlyView, today.AddDays(-8), today.AddDays(1), ct);
+ var boundary = today.AddDays(-8).AddHours(10);
+
+ await using var dataSource = NpgsqlDataSource.Create(scratch.ConnectionString);
+
+ /* The first run measures the store's coverage and caches it per data source: the boundary is the successor's
+ first bucket, 8 days back. The deep server's superseded hourly covers the whole window, so it has no notice. */
+ var deep = await RunAsync(dataSource, StitchedDeepServerName, ct);
+ Assert.True(deep.Error is null, $"compose run failed: {deep.Error}");
+ Assert.Contains("UNION ALL", (string)deep.Payload!["sql"]!, StringComparison.Ordinal);
+ Assert.Null(deep.Payload["notice"]);
+
+ await InsertDailyAsync(connection, StitchedScopedServerId, StitchedScopedServerName, today.AddDays(-19), today.AddDays(-1), ct);
+ await RefreshAsync(connection, TimescaleSupport.QueryStatsIntervalHourlyView, today.AddDays(-21), today.AddDays(1), ct);
+
+ Assert.Equal(0L, await CountAsync(connection, TimescaleSupport.QueryStatsHourlyView, StitchedScopedServerId, ct));
+ var olderBucket = today.AddDays(-19).AddHours(10);
+ Assert.Equal(olderBucket, await FirstBucketAsync(connection, TimescaleSupport.QueryStatsIntervalHourlyView, StitchedScopedServerId, ct));
+
+ var scoped = await RunAsync(dataSource, StitchedScopedServerName, ct);
+ Assert.True(scoped.Error is null, $"compose run failed: {scoped.Error}");
+ var sql = (string)scoped.Payload!["sql"]!;
+ Assert.Contains("UNION ALL", sql, StringComparison.Ordinal);
+ Assert.Contains("TIMESTAMP '" + boundary.ToString("yyyy-MM-dd HH:mm:ss.ffffff", CultureInfo.InvariantCulture) + "'", sql, StringComparison.Ordinal);
+
+ var notice = scoped.Payload["notice"]?.GetValue();
+ Assert.NotNull(notice);
+ Assert.StartsWith("partial window:", notice, StringComparison.Ordinal);
+ Assert.Contains(Minute(boundary) + " UTC", notice, StringComparison.Ordinal);
+ Assert.DoesNotContain(Minute(olderBucket), notice, StringComparison.Ordinal);
+ }
+
+ private static string Minute(DateTime utc) => utc.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture);
+
+ private static async Task RefreshAsync(NpgsqlConnection connection, string view, DateTime from, DateTime to, CancellationToken ct)
+ {
+ await using var refresh = new NpgsqlCommand($"CALL refresh_continuous_aggregate('collect.{view}'::regclass, $1::timestamp, $2::timestamp)", connection);
+ refresh.Parameters.AddWithValue(from);
+ refresh.Parameters.AddWithValue(to);
+ await refresh.ExecuteNonQueryAsync(ct);
+ }
+
+ private static async Task CountAsync(NpgsqlConnection connection, string view, int serverId, CancellationToken ct)
+ {
+ await using var count = new NpgsqlCommand($"SELECT count(*) FROM collect.{view} WHERE server_id = $1", connection);
+ count.Parameters.AddWithValue(serverId);
+ return (long)(await count.ExecuteScalarAsync(ct))!;
+ }
+
+ private static async Task FirstBucketAsync(NpgsqlConnection connection, string view, int serverId, CancellationToken ct)
+ {
+ await using var first = new NpgsqlCommand($"SELECT min(bucket) FROM collect.{view} WHERE server_id = $1", connection);
+ first.Parameters.AddWithValue(serverId);
+ return (DateTime)(await first.ExecuteScalarAsync(ct))!;
+ }
+
+ private static Task RunAsync(NpgsqlDataSource dataSource, string server, CancellationToken ct)
+ {
+ var body = new JsonObject
+ {
+ ["panel"] = JsonNode.Parse(
+ "{\"source\":\"query_stats\",\"measure\":\"query_worker_us\",\"aggregate\":\"sum\",\"timeBucket\":\"day\",\"viz\":\"line\"}"),
+ ["server"] = server,
+ ["hours"] = 14 * 24,
+ };
+
+ return DarlingWebEndpoints.RunComposedPanelAsync(dataSource, body, ct);
+ }
+
+ /// One query_stats row a day at 10:05, from to .
+ private static async Task InsertDailyAsync(
+ NpgsqlConnection connection, int serverId, string serverName, DateTime firstDay, DateTime lastDay, CancellationToken ct)
+ {
+ await using var insert = new NpgsqlCommand(@"
+INSERT INTO collect.query_stats
+ (collection_id, collection_time, server_id, server_name, database_name, query_hash, sql_handle,
+ delta_worker_time, delta_elapsed_time, delta_execution_count, sample_interval_seconds)
+SELECT row_number() OVER (), d + interval '10 hours 5 minutes', $1, $2, 'FloorDb', 'HASHFLOOR', '0xFLOOR', 1000, 1000, 10, 300
+FROM generate_series($3::timestamp, $4::timestamp, interval '1 day') AS d", connection);
+ insert.Parameters.AddWithValue(serverId);
+ insert.Parameters.AddWithValue(serverName);
+ insert.Parameters.AddWithValue(firstDay);
+ insert.Parameters.AddWithValue(lastDay);
+ await insert.ExecuteNonQueryAsync(ct);
+ }
+}
diff --git a/Darling/Darling.Tests/ComposeSourceRouterTests.cs b/Darling/Darling.Tests/ComposeSourceRouterTests.cs
index ff83096cf4..3464083285 100644
--- a/Darling/Darling.Tests/ComposeSourceRouterTests.cs
+++ b/Darling/Darling.Tests/ComposeSourceRouterTests.cs
@@ -446,4 +446,102 @@ public void DailyAgeWindow_DailyMissing_FallsToHourly()
Assert.Equal(ComposeSourceTier.Hourly, route.Tier);
Assert.Equal("query_stats_hourly", route.CaggRelation);
}
+
+ /* ─────────────── the FROM clause and stitch boundary a route carries ─────────────── */
+
+ private static RollupCoverage QueryStatsHourlyStore(DateTime successorFloor) => new(
+ new Dictionary(StringComparer.Ordinal)
+ {
+ [TimescaleSupport.QueryStatsHourlyView] = Now.AddDays(-80),
+ [TimescaleSupport.QueryStatsIntervalHourlyView] = successorFloor,
+ [TimescaleSupport.QueryStatsDailyView] = Now.AddDays(-110),
+ },
+ new Dictionary(StringComparer.Ordinal) { ["query_stats"] = Now.AddDays(-4) },
+ RollupAvailability.All);
+
+ /// The route carries the instant its stitched clause splits at, as a value, so the data-start probe
+ /// reads the successor over the range the panel does. A route that reads one relation carries none.
+ [Fact]
+ public void AStitchedHourlyRoute_CarriesTheBoundaryItSplitsAt_AndAnUnstitchedOneCarriesNone()
+ {
+ var windowStart = Now.AddDays(-10);
+
+ var stitched = ComposeSourceRouter.Resolve(Plan("query_worker_us"), Now, windowStart, RollupAvailability.All, QueryStatsHourlyStore(Now.AddDays(-1)));
+ Assert.Equal(ComposeSourceTier.Hourly, stitched.Tier);
+ Assert.Equal(Now.AddDays(-1), stitched.StitchBoundaryUtc);
+ Assert.Contains("UNION ALL", stitched.CaggFromClause, StringComparison.Ordinal);
+
+ var covered = ComposeSourceRouter.Resolve(Plan("query_worker_us"), Now, windowStart, RollupAvailability.All, QueryStatsHourlyStore(Now.AddDays(-80)));
+ Assert.Equal(ComposeSourceTier.Hourly, covered.Tier);
+ Assert.Null(covered.StitchBoundaryUtc);
+ Assert.DoesNotContain("UNION ALL", covered.CaggFromClause, StringComparison.Ordinal);
+ }
+
+ /// The daily boundary is the first whole day after the successor hourly's first bucket, so the successor
+ /// daily can hold the day before it, which the stitched read leaves to the superseded daily.
+ [Fact]
+ public void AStitchedDailyRoute_CarriesTheBoundaryItSplitsAt()
+ {
+ var coverage = new RollupCoverage(
+ new Dictionary(StringComparer.Ordinal)
+ {
+ [TimescaleSupport.QueryStatsHourlyView] = Now.AddDays(-400),
+ [TimescaleSupport.QueryStatsDailyView] = Now.AddDays(-400),
+ [TimescaleSupport.QueryStatsIntervalHourlyView] = Now.Date.AddDays(-100).AddHours(5),
+ [TimescaleSupport.QueryStatsIntervalDailyView] = Now.Date.AddDays(-110),
+ },
+ new Dictionary(StringComparer.Ordinal) { ["query_stats"] = Now.AddDays(-4) },
+ RollupAvailability.All);
+
+ var route = ComposeSourceRouter.Resolve(Plan("query_worker_us"), Now, Now.AddDays(-150), RollupAvailability.All, coverage);
+
+ Assert.Equal(ComposeSourceTier.Daily, route.Tier);
+ Assert.Equal(Now.Date.AddDays(-99), route.StitchBoundaryUtc);
+ Assert.Contains("UNION ALL", route.CaggFromClause, StringComparison.Ordinal);
+ }
+
+ /// The day-grain daily has no successor, so its clause names the one relation. The data-start probe reads
+ /// the relations a panel read off the clause, so it must carry one like every other rollup route.
+ [Fact]
+ public void TheDayGrainDailyRoute_CarriesItsFromClause_SoTheDataStartProbeReadsIt()
+ {
+ var route = ComposeSourceRouter.Resolve(
+ Plan("qs_executions"), Now, Now.AddDays(-100), RollupAvailability.All, QueryStoreLadder(dayGrainDays: 120));
+
+ Assert.Equal(TimescaleSupport.QueryStoreStatsDayGrainDailyView, route.CaggRelation);
+ Assert.Equal($"collect.{TimescaleSupport.QueryStoreStatsDayGrainDailyView} AS f", route.CaggFromClause);
+ Assert.Null(route.StitchBoundaryUtc);
+
+ var source = Assert.Single(ComposeStoreAvailability.DataStartSources("query_store_stats", route));
+ Assert.Equal(TimescaleSupport.QueryStoreStatsDayGrainDailyView, source.Relation);
+ Assert.Null(source.LowerBoundUtc);
+ }
+
+ [Fact]
+ public void EveryRollupRoute_CarriesTheFromClauseItReads()
+ {
+ var coverages = new[]
+ {
+ RollupCoverage.Unknown,
+ QueryStoreLadder(dayGrainDays: 120),
+ QueryStatsHourlyStore(Now.AddDays(-1)),
+ QueryStatsHourlyStore(Now.AddDays(-80)),
+ };
+
+ foreach (var plan in new[] { Plan("query_worker_us"), Plan("qs_executions") })
+ {
+ foreach (var coverage in coverages)
+ {
+ foreach (var age in new[] { 10, 40, 100, 150, 300 })
+ {
+ var route = ComposeSourceRouter.Resolve(plan, Now, Now.AddDays(-age), RollupAvailability.All, coverage);
+
+ if (route.IsCagg)
+ {
+ Assert.False(string.IsNullOrEmpty(route.CaggFromClause), $"a {age}-day window routed to {route.CaggRelation} with no FROM clause");
+ }
+ }
+ }
+ }
+ }
}
diff --git a/Darling/Darling.Tests/DarlingComposeTests.cs b/Darling/Darling.Tests/DarlingComposeTests.cs
index 6000361176..0f3893af1d 100644
--- a/Darling/Darling.Tests/DarlingComposeTests.cs
+++ b/Darling/Darling.Tests/DarlingComposeTests.cs
@@ -3485,11 +3485,19 @@ public async Task RunComposedPanel_RejectsBadAbsoluteWindows(string windowJson,
[Collection("live-postgres")]
public sealed class DarlingComposeLivePostgresTests
{
+ private const int ServerId = -973405;
+ private const string ServerName = "compose-old-window-no-rows";
+
///
/// The live #1665 repro, end-to-end through the ONE shared runner: a >3-day window against a plain
/// PostgreSQL store (the darling-pg CI service container — no TimescaleDB, so no rollups exist). Before
/// the availability gate this compiled collect.query_stats_hourly and failed 42P01 at run time;
- /// now it routes raw, runs clean, and carries NO notice (raw is complete on this store shape).
+ /// now it routes raw, runs clean, and carries NO notice. The panel is scoped to a server that holds no rows:
+ /// a fleet-wide panel would start at the oldest row any other test left in this shared store, and a store
+ /// whose rows start after the window's start now says so. That server is REGISTERED (a sentinel registry
+ /// row, removed afterwards), because the data-start probe reads through collect.servers: a name the
+ /// registry does not hold asks the probe nothing, and the no-notice assertion would pass whatever the
+ /// store held (#4953).
///
[Fact]
public async Task RunComposedPanel_OldWindow_AgainstPlainPostgres_RunsCleanOnRaw()
@@ -3500,30 +3508,43 @@ public async Task RunComposedPanel_OldWindow_AgainstPlainPostgres_RunsCleanOnRaw
var ct = TestContext.Current.CancellationToken;
- await using (var connection = new NpgsqlConnection(connectionString))
+ var bodySucceeded = false;
+ try
{
- await connection.OpenAsync(ct);
- await PgMigrations.MigrateAsync(connection, ct);
- }
-
- await using var postgres = NpgsqlDataSource.Create(connectionString!);
+ await using (var connection = new NpgsqlConnection(connectionString))
+ {
+ await connection.OpenAsync(ct);
+ await PgMigrations.MigrateAsync(connection, ct);
+ await DarlingMcpTestData.RegisterServerAsync(connection, ServerId, ServerName, ct);
+ }
- var body = new JsonObject
- {
- ["panel"] = JsonNode.Parse(
- "{\"source\":\"query_stats\",\"measure\":\"query_worker_us\",\"aggregate\":\"sum\",\"timeBucket\":\"day\",\"viz\":\"line\"}"),
- ["hours"] = 240, /* 10 days — far past the 3-day raw route horizon */
- };
+ await using var postgres = NpgsqlDataSource.Create(connectionString!);
- var outcome = await DarlingWebEndpoints.RunComposedPanelAsync(postgres, body, ct);
+ var body = new JsonObject
+ {
+ ["panel"] = JsonNode.Parse(
+ "{\"source\":\"query_stats\",\"measure\":\"query_worker_us\",\"aggregate\":\"sum\",\"timeBucket\":\"day\",\"viz\":\"line\"}"),
+ ["hours"] = 240, /* 10 days — far past the 3-day raw route horizon */
+ ["server"] = ServerName,
+ };
+
+ var outcome = await DarlingWebEndpoints.RunComposedPanelAsync(postgres, body, ct);
+
+ Assert.True(outcome.Error is null, $"compose run failed: {outcome.Error}");
+ Assert.NotNull(outcome.Payload);
+ var sql = (string)outcome.Payload!["sql"]!;
+ Assert.Contains("collect.query_stats", sql, StringComparison.Ordinal);
+ Assert.DoesNotContain("_hourly", sql, StringComparison.Ordinal);
+ Assert.DoesNotContain("_daily", sql, StringComparison.Ordinal);
+ Assert.Null(outcome.Payload["notice"]);
- Assert.True(outcome.Error is null, $"compose run failed: {outcome.Error}");
- Assert.NotNull(outcome.Payload);
- var sql = (string)outcome.Payload!["sql"]!;
- Assert.Contains("collect.query_stats", sql, StringComparison.Ordinal);
- Assert.DoesNotContain("_hourly", sql, StringComparison.Ordinal);
- Assert.DoesNotContain("_daily", sql, StringComparison.Ordinal);
- Assert.Null(outcome.Payload["notice"]);
+ bodySucceeded = true;
+ }
+ finally
+ {
+ await LiveStoreCleanup.RunAsync(connectionString!, bodySucceeded, async (cleanup, cleanupCt) =>
+ await DarlingMcpTestData.ExecAsync(cleanup, cleanupCt, "DELETE FROM servers WHERE server_id = $1", ServerId));
+ }
}
}
diff --git a/Darling/Darling.Tests/DarlingMcpDataToolsTests.cs b/Darling/Darling.Tests/DarlingMcpDataToolsTests.cs
index ee076d7259..0bf257fd2e 100644
--- a/Darling/Darling.Tests/DarlingMcpDataToolsTests.cs
+++ b/Darling/Darling.Tests/DarlingMcpDataToolsTests.cs
@@ -719,7 +719,7 @@ public void QueryStoreSql_AppliesModuleFilterAfterDedupAndBeforeRankingLimit()
public void QueryStoreWindowFloor_RemainsACheapUnfilteredRetentionProbe()
{
var sql = DarlingDataReader.QueryStoreWindowFloorSql;
- Assert.Contains("SELECT MIN(collection_time)", sql, StringComparison.Ordinal);
+ Assert.Contains("LIMIT 1", sql, StringComparison.Ordinal);
Assert.DoesNotContain("ROW_NUMBER", sql, StringComparison.Ordinal);
Assert.DoesNotContain("module_name", sql, StringComparison.Ordinal);
Assert.DoesNotContain("database_name", sql, StringComparison.Ordinal);
diff --git a/Darling/Darling.Tests/DataWindowFloorTests.cs b/Darling/Darling.Tests/DataWindowFloorTests.cs
new file mode 100644
index 0000000000..1e2ec2796d
--- /dev/null
+++ b/Darling/Darling.Tests/DataWindowFloorTests.cs
@@ -0,0 +1,366 @@
+/*
+ * 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 PerformanceMonitor.Darling.Service;
+using PerformanceMonitor.Darling.Storage;
+using Xunit;
+
+namespace Darling.Tests;
+
+///
+/// The data-start probe's SQL, the relations it accepts, which relations a compiled panel probes, and the notice's
+/// words. The live classes (, )
+/// run the probe against PostgreSQL; these pin what it is allowed to read and how.
+///
+public sealed class DataWindowFloorTests
+{
+ [Fact]
+ public void ThePanelProbe_AsksEachServerThroughItsOwnIndex_NeverFiltersTheFactTableByName()
+ {
+ var sql = DataWindowFloor.FloorSql([DataWindowFloor.Source.ForCollectorTable("waiting_tasks")], DataWindowFloor.Scope.ServerNames);
+
+ Assert.Contains("FROM collect.servers AS s", sql, StringComparison.Ordinal);
+ Assert.Contains("LEFT JOIN LATERAL", sql, StringComparison.Ordinal);
+ Assert.DoesNotContain("CROSS JOIN LATERAL", sql, StringComparison.Ordinal);
+ Assert.Contains("WHERE f.server_id = s.server_id", sql, StringComparison.Ordinal);
+ Assert.Contains("AND s.server_name = ANY($4)", sql, StringComparison.Ordinal);
+ Assert.DoesNotContain("f.server_name", sql, StringComparison.Ordinal);
+ Assert.Contains("ORDER BY f.collection_time", sql, StringComparison.Ordinal);
+ Assert.Contains("LIMIT 1", sql, StringComparison.Ordinal);
+ }
+
+ /// The scope is the fourth parameter (the window's end, its start and the probe's clock come first), and
+ /// a fleet probe carries none.
+ [Fact]
+ public void TheScope_IsTheFourthParameter_AndAFleetProbeHasNone()
+ {
+ var waitingTasks = new[] { DataWindowFloor.Source.ForCollectorTable("waiting_tasks") };
+
+ Assert.Contains("AND s.server_id = $4", DataWindowFloor.FloorSql(waitingTasks, DataWindowFloor.Scope.ServerId), StringComparison.Ordinal);
+ Assert.Contains("AND s.server_name = ANY($4)", DataWindowFloor.FloorSql(waitingTasks, DataWindowFloor.Scope.ServerNames), StringComparison.Ordinal);
+ Assert.DoesNotContain("$4", DataWindowFloor.FloorSql(waitingTasks, DataWindowFloor.Scope.Fleet), StringComparison.Ordinal);
+ }
+
+ /// The read inside the window has both bounds: it asks whether the server holds a row in the window and
+ /// where its first one is, so the earliest instant a row can move coverage to is the window's start. Before this,
+ /// the probe took the oldest row at or before the window's end, a row a purge may already have dropped.
+ [Fact]
+ public void TheWindowRead_IsBoundedOnBothSides_ForEverySource()
+ {
+ var raw = DataWindowFloor.FloorSql([DataWindowFloor.Source.ForCollectorTable("waiting_tasks")], DataWindowFloor.Scope.Fleet);
+ Assert.Contains("AND f.collection_time >= $2", raw, StringComparison.Ordinal);
+ Assert.Contains("AND f.collection_time <= $1", raw, StringComparison.Ordinal);
+
+ Assert.True(DataWindowFloor.Source.TryForRollup(TimescaleSupport.QueryStatsHourlyView, out var hourly));
+ var rollup = DataWindowFloor.FloorSql([hourly], DataWindowFloor.Scope.ServerId);
+ Assert.Contains("AND f.bucket >= $2", rollup, StringComparison.Ordinal);
+ Assert.Contains("AND f.bucket < $1", rollup, StringComparison.Ordinal);
+ Assert.Contains("AND s.server_id = $4", rollup, StringComparison.Ordinal);
+ }
+
+ ///
+ /// A collector table the schedule's retention governs (waiting_tasks and query_snapshots keep 7 days) takes its
+ /// edge from the purge cutoff, so the probe reads nothing older than the window: no row of the table is read
+ /// without the window's lower bound, and the first collection (the registry's created_date) is the other bound.
+ /// A quiet start, or a server whose first row came late, cannot raise a notice the store does not owe.
+ ///
+ [Theory]
+ [InlineData("waiting_tasks", 7)]
+ [InlineData("query_snapshots", 7)]
+ public void AScheduleGovernedTable_ReadsItsEdgeFromTheScheduleNotFromAWalk(string table, int defaultDays)
+ {
+ var source = DataWindowFloor.Source.ForCollectorTable(table);
+ Assert.Equal(defaultDays, source.RetentionDefaultDays);
+
+ var sql = DataWindowFloor.FloorSql([source], DataWindowFloor.Scope.Fleet);
+
+ Assert.Contains("GREATEST($3 - make_interval(days => COALESCE((SELECT o.retention_days FROM config.config_collector_schedules AS o", sql, StringComparison.Ordinal);
+ Assert.Contains($"lower(o.collector_name) = '{table}' AND o.retention_days >= 1 LIMIT 1), {defaultDays})), s.created_date)", sql, StringComparison.Ordinal);
+ Assert.DoesNotContain(" AS h", sql, StringComparison.Ordinal);
+ }
+
+ ///
+ /// A source the schedule gives no edge (the raw relations the gated purge owns, the collectors whose purge is
+ /// floored at the baseline window, and every rollup) takes its edge from the oldest row the server holds at or
+ /// before the window's end, the rule before the probe read coverage. These tables are dense, so the oldest
+ /// row is where coverage starts; reading coverage from the first collection alone would give an old server no
+ /// notice however long before anything the table keeps the window starts. The walk is unbounded below and
+ /// sits in the select list, so it runs only for a server that counts.
+ ///
+ [Theory]
+ [InlineData("cpu_utilization_stats", "collection_time")]
+ [InlineData("file_io_stats", "collection_time")]
+ [InlineData("pg_wait_stats", "collection_time")]
+ [InlineData("query_stats", "collection_time")]
+ [InlineData("procedure_stats", "collection_time")]
+ [InlineData("query_store_stats", "collection_time")]
+ public void ASourceTheScheduleGivesNoEdge_WalksToTheOldestRowAtOrBeforeTheEnd(string table, string timeColumn)
+ {
+ Assert.True(DataWindowFloor.Source.TryForCollectorTable(table, out var source));
+ Assert.Null(source.RetentionDefaultDays);
+
+ var sql = DataWindowFloor.FloorSql([source], DataWindowFloor.Scope.Fleet);
+
+ var walk = $"COALESCE((SELECT h.{timeColumn} FROM collect.{table} AS h WHERE h.server_id = s.server_id AND h.{timeColumn} <= $1 ORDER BY h.{timeColumn} LIMIT 1), s.created_date)";
+ Assert.Contains(walk, sql, StringComparison.Ordinal);
+ Assert.DoesNotContain("config.config_collector_schedules", sql, StringComparison.Ordinal);
+ Assert.DoesNotContain("make_interval", sql, StringComparison.Ordinal);
+ Assert.DoesNotContain("$3", sql, StringComparison.Ordinal);
+
+ /* In the select list, ahead of FROM: the planner runs it on the rows the WHERE keeps, never on a server
+ that holds nothing in the window. */
+ Assert.True(sql.IndexOf(walk, StringComparison.Ordinal) < sql.IndexOf("FROM collect.servers AS s", StringComparison.Ordinal));
+ Assert.True(sql.IndexOf(walk, StringComparison.Ordinal) < sql.IndexOf("WHERE (w.t IS NOT NULL", StringComparison.Ordinal));
+ }
+
+ [Fact]
+ public void ARollup_WalksToItsOldestBucketBeforeTheEnd_AndTheEndIsExclusive()
+ {
+ Assert.True(DataWindowFloor.Source.TryForRollup(TimescaleSupport.QueryStatsHourlyView, out var hourly));
+ Assert.Null(hourly.RetentionDefaultDays);
+
+ var sql = DataWindowFloor.FloorSql([hourly], DataWindowFloor.Scope.Fleet);
+
+ Assert.Contains(
+ $"COALESCE((SELECT h.bucket FROM collect.{TimescaleSupport.QueryStatsHourlyView} AS h WHERE h.server_id = s.server_id AND h.bucket < $1 ORDER BY h.bucket LIMIT 1), s.created_date)",
+ sql, StringComparison.Ordinal);
+ }
+
+ /// A server counts only with a row, or a logged run of the collector, inside the window, so a server that
+ /// stopped before it (its registry row and history stay) cannot hide a newer server's late start on a fleet
+ /// panel. A rollup has no collector to log a run, so it counts by its rows alone.
+ [Fact]
+ public void AServerCounts_OnlyWithARowOrALoggedRunInTheWindow()
+ {
+ var collector = DataWindowFloor.FloorSql([DataWindowFloor.Source.ForCollectorTable("waiting_tasks")], DataWindowFloor.Scope.Fleet);
+ Assert.Contains("WHERE (w.t IS NOT NULL", collector, StringComparison.Ordinal);
+ Assert.Contains(
+ "OR EXISTS (SELECT 1 FROM collect.collection_log AS c WHERE c.server_id = s.server_id AND c.collector_name = 'waiting_tasks' AND c.collection_time >= $2 AND c.collection_time <= $1))",
+ collector, StringComparison.Ordinal);
+
+ Assert.True(DataWindowFloor.Source.TryForRollup(TimescaleSupport.QueryStatsHourlyView, out var hourly));
+ var rollup = DataWindowFloor.FloorSql([hourly], DataWindowFloor.Scope.Fleet);
+ Assert.Contains("WHERE (w.t IS NOT NULL)", rollup, StringComparison.Ordinal);
+ Assert.DoesNotContain("collection_log", rollup, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void TwoSources_AreProbedSideBySide_AndTheOldestWins()
+ {
+ Assert.True(DataWindowFloor.Source.TryForRollup(TimescaleSupport.QueryStatsHourlyView, out var legacy));
+ Assert.True(DataWindowFloor.Source.TryForRollup(TimescaleSupport.QueryStatsIntervalHourlyView, out var successor));
+
+ var sql = DataWindowFloor.FloorSql([legacy, successor], DataWindowFloor.Scope.Fleet);
+
+ Assert.StartsWith("SELECT MIN(u.t)", sql, StringComparison.Ordinal);
+ Assert.Contains("FROM collect." + TimescaleSupport.QueryStatsHourlyView + " AS f", sql, StringComparison.Ordinal);
+ Assert.Contains("FROM collect." + TimescaleSupport.QueryStatsIntervalHourlyView + " AS f", sql, StringComparison.Ordinal);
+ Assert.Contains("UNION ALL", sql, StringComparison.Ordinal);
+ }
+
+ [Theory]
+ [InlineData("waiting_tasks", true)]
+ [InlineData("query_snapshots", true)]
+ [InlineData("wait_stats", true)]
+ [InlineData("index_object_stats", false)]
+ [InlineData("server_config", false)]
+ [InlineData("database_config", false)]
+ [InlineData("query_stats; DROP TABLE x", false)]
+ [InlineData("not_a_table", false)]
+ public void CollectorTables_AreProbedOnlyWhenTheirIndexLeadsWithServerAndTime(string table, bool accepted)
+ {
+ Assert.Equal(accepted, DataWindowFloor.Source.TryForCollectorTable(table, out _));
+ }
+
+ [Theory]
+ [InlineData("query_stats_hourly", true)]
+ [InlineData("query_store_stats_corrected_daily", true)]
+ [InlineData("wait_stats", false)]
+ [InlineData("query_stats_hourly; DROP TABLE x", false)]
+ [InlineData("", false)]
+ public void Rollups_AreProbedOnlyWhenTheRegistryKnowsThem(string view, bool accepted)
+ {
+ Assert.Equal(accepted, DataWindowFloor.Source.TryForRollup(view, out _));
+ }
+
+ /// Every rollup a Custom Views panel can be routed to is one the probe accepts, so no rollup-routed
+ /// panel goes without a start.
+ [Fact]
+ public void EveryRollupAPanelCanRead_IsOneTheProbeAccepts()
+ {
+ foreach (var table in new[] { "query_stats", "procedure_stats", "query_store_stats" })
+ {
+ var info = ComposeCaggCatalog.For(table)!;
+ var names = new[] { info.HourlyView, info.DailyView, info.LegacyHourlyView, info.LegacyDailyView, info.DayGrainDailyView }
+ .Where(n => n is not null)
+ .Select(n => n!)
+ .ToList();
+ names.AddRange(names.Select(TimescaleSupport.SuccessorOf).Where(n => n is not null).Select(n => n!).ToList());
+ names.AddRange(TimescaleSupport.SupersededDailyRollups
+ .Where(p => names.Contains(p.LegacyDaily))
+ .Select(p => p.SuccessorDaily)
+ .ToList());
+
+ foreach (var name in names.Distinct())
+ {
+ Assert.True(DataWindowFloor.Source.TryForRollup(name, out _), name + " is a rollup a panel can read, but the probe refuses it");
+ }
+ }
+ }
+
+ [Fact]
+ public void ARawRoute_ProbesItsSourceTable()
+ {
+ var sources = ComposeStoreAvailability.DataStartSources("waiting_tasks", ComposeRoute.Raw);
+
+ Assert.Equal("waiting_tasks", Assert.Single(sources).Relation);
+ }
+
+ [Fact]
+ public void ARawRoute_OverATableTheProbeCannotRead_ProbesNothing()
+ {
+ Assert.Empty(ComposeStoreAvailability.DataStartSources("index_object_stats", ComposeRoute.Raw));
+ }
+
+ [Fact]
+ public void ARollupRoute_ProbesTheRollupItRead()
+ {
+ var route = new ComposeRoute(
+ ComposeSourceTier.Hourly, TimescaleSupport.QueryStatsHourlyView, "collect." + TimescaleSupport.QueryStatsHourlyView + " AS f");
+
+ var source = Assert.Single(ComposeStoreAvailability.DataStartSources("query_stats", route));
+
+ Assert.Equal(TimescaleSupport.QueryStatsHourlyView, source.Relation);
+ Assert.Equal("bucket", source.TimeColumn);
+ Assert.True(source.EndExclusive);
+ }
+
+ /// A route that stitches a superseded rollup to its successor reads both, so both are probed.
+ [Fact]
+ public void AStitchedRollupRoute_ProbesBothHalves()
+ {
+ var from = "(SELECT bucket, server_id FROM collect." + TimescaleSupport.QueryStatsHourlyView
+ + " WHERE bucket < TIMESTAMP '2026-09-01 00:00:00.000000' UNION ALL SELECT bucket, server_id FROM collect."
+ + TimescaleSupport.QueryStatsIntervalHourlyView + " WHERE bucket >= TIMESTAMP '2026-09-01 00:00:00.000000') AS f";
+ var route = new ComposeRoute(ComposeSourceTier.Hourly, TimescaleSupport.QueryStatsIntervalHourlyView, from);
+
+ var relations = ComposeStoreAvailability.DataStartSources("query_stats", route).Select(s => s.Relation).ToArray();
+
+ Assert.Equal(new[] { TimescaleSupport.QueryStatsHourlyView, TimescaleSupport.QueryStatsIntervalHourlyView }, relations);
+ }
+
+ [Fact]
+ public void TheNotice_NamesTheStartAndTheWindowItCovers_InUtc()
+ {
+ var notice = ComposeStoreAvailability.BuildDataStartNotice(
+ new DateTime(2026, 9, 25, 14, 2, 0, DateTimeKind.Utc),
+ new DateTime(2026, 9, 2, 14, 0, 0, DateTimeKind.Utc),
+ new DateTime(2026, 10, 2, 14, 0, 0, DateTimeKind.Utc));
+
+ Assert.Equal(
+ "partial window: this panel's data starts at 2026-09-25 14:02 UTC, after the window's start at 2026-09-02 14:00 UTC. "
+ + "The panel covers 2026-09-25 14:02 to 2026-10-02 14:00 UTC.",
+ notice);
+ }
+
+ /// A relation the panel reads only from an instant up (the successor half of a stitched read) starts both
+ /// its window read and its walk there, with the instant a bind parameter numbered after the scope's, never a
+ /// literal. A relation read whole keeps the text it had.
+ [Fact]
+ public void ALowerBound_StartsTheWindowReadAndTheWalk_AsABindParameter()
+ {
+ var bound = new DateTime(2026, 9, 1, 0, 0, 0, DateTimeKind.Unspecified);
+ Assert.True(DataWindowFloor.Source.TryForRollup(TimescaleSupport.QueryStatsHourlyView, out var legacy));
+ Assert.True(DataWindowFloor.Source.TryForRollup(TimescaleSupport.QueryStatsIntervalHourlyView, out var successor, bound));
+ Assert.Null(legacy.LowerBoundUtc);
+ Assert.Equal(bound, successor.LowerBoundUtc);
+
+ var fleet = DataWindowFloor.FloorSql([legacy, successor], DataWindowFloor.Scope.Fleet);
+
+ Assert.Contains("AND f.bucket >= $4\n", fleet, StringComparison.Ordinal);
+ Assert.Contains(
+ $"FROM collect.{TimescaleSupport.QueryStatsIntervalHourlyView} AS h WHERE h.server_id = s.server_id AND h.bucket < $1 AND h.bucket >= $4 ORDER BY h.bucket LIMIT 1",
+ fleet, StringComparison.Ordinal);
+ Assert.Contains(
+ $"FROM collect.{TimescaleSupport.QueryStatsHourlyView} AS h WHERE h.server_id = s.server_id AND h.bucket < $1 ORDER BY h.bucket LIMIT 1",
+ fleet, StringComparison.Ordinal);
+ Assert.DoesNotContain("2026-09-01", fleet, StringComparison.Ordinal);
+ Assert.DoesNotContain("$5", fleet, StringComparison.Ordinal);
+
+ var scoped = DataWindowFloor.FloorSql([legacy, successor], DataWindowFloor.Scope.ServerNames);
+ Assert.Contains("AND f.bucket >= $5\n", scoped, StringComparison.Ordinal);
+ Assert.Contains("AND h.bucket >= $5 ORDER BY", scoped, StringComparison.Ordinal);
+ Assert.DoesNotContain("$6", scoped, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void SeveralLowerBounds_AreNumberedAfterTheScope_InSourceOrder()
+ {
+ var bound = new DateTime(2026, 9, 1, 0, 0, 0, DateTimeKind.Unspecified);
+ Assert.True(DataWindowFloor.Source.TryForRollup(TimescaleSupport.QueryStatsIntervalHourlyView, out var first, bound));
+ Assert.True(DataWindowFloor.Source.TryForRollup(TimescaleSupport.QueryStatsDbIntervalHourlyView, out var second, bound));
+
+ var sql = DataWindowFloor.FloorSql([first, second], DataWindowFloor.Scope.ServerId);
+
+ Assert.Contains("AND s.server_id = $4", sql, StringComparison.Ordinal);
+ Assert.True(sql.IndexOf("f.bucket >= $5", StringComparison.Ordinal) < sql.IndexOf("f.bucket >= $6", StringComparison.Ordinal));
+ Assert.Contains("h.bucket >= $5 ORDER BY", sql, StringComparison.Ordinal);
+ Assert.Contains("h.bucket >= $6 ORDER BY", sql, StringComparison.Ordinal);
+ }
+
+ /// A stitched route reads the superseded half below the boundary and the successor from it up, so the
+ /// probe reads the successor from the boundary up and the superseded half whole, on both tiers, whichever order
+ /// the clause lists them in: the successor is the one the registry names, and the boundary is the route's value.
+ [Fact]
+ public void AStitchedRoute_ProbesTheSuccessorFromTheBoundaryUp_AndTheSupersededHalfWhole()
+ {
+ var boundary = new DateTime(2026, 9, 1, 0, 0, 0, DateTimeKind.Unspecified);
+ var pairs = new[]
+ {
+ (Legacy: TimescaleSupport.QueryStatsHourlyView, Successor: TimescaleSupport.QueryStatsIntervalHourlyView, Tier: ComposeSourceTier.Hourly),
+ (Legacy: TimescaleSupport.QueryStatsDailyView, Successor: TimescaleSupport.QueryStatsIntervalDailyView, Tier: ComposeSourceTier.Daily),
+ };
+
+ foreach (var pair in pairs)
+ {
+ foreach (var order in new[] { new[] { pair.Legacy, pair.Successor }, new[] { pair.Successor, pair.Legacy } })
+ {
+ var from = "(SELECT bucket FROM collect." + order[0] + " WHERE bucket < TIMESTAMP '2026-09-01 00:00:00.000000' UNION ALL SELECT bucket FROM collect."
+ + order[1] + " WHERE bucket >= TIMESTAMP '2026-09-01 00:00:00.000000') AS f";
+ var route = new ComposeRoute(pair.Tier, pair.Legacy, from, boundary);
+
+ var sources = ComposeStoreAvailability.DataStartSources("query_stats", route).ToDictionary(s => s.Relation, StringComparer.Ordinal);
+
+ Assert.Equal(2, sources.Count);
+ Assert.Null(sources[pair.Legacy].LowerBoundUtc);
+ Assert.Equal(boundary, sources[pair.Successor].LowerBoundUtc);
+ }
+ }
+ }
+
+ [Fact]
+ public void AnUnstitchedRollupRoute_ProbesItsRollupWhole()
+ {
+ var route = new ComposeRoute(
+ ComposeSourceTier.Hourly, TimescaleSupport.QueryStatsIntervalHourlyView, "collect." + TimescaleSupport.QueryStatsIntervalHourlyView + " AS f");
+
+ Assert.Null(Assert.Single(ComposeStoreAvailability.DataStartSources("query_stats", route)).LowerBoundUtc);
+ }
+
+ /// The router sets the FROM clause on every rollup route it builds, so a rollup route without one is a
+ /// router defect: the probe refuses it rather than guess which relation the panel read.
+ [Fact]
+ public void ARollupRouteWithoutAFromClause_IsRefused()
+ {
+ var route = new ComposeRoute(ComposeSourceTier.Hourly, TimescaleSupport.QueryStatsIntervalHourlyView);
+
+ Assert.Throws(() => ComposeStoreAvailability.DataStartSources("query_stats", route));
+ }
+}
diff --git a/Darling/Darling.Tests/LiveStoreSessionTimeZone.cs b/Darling/Darling.Tests/LiveStoreSessionTimeZone.cs
new file mode 100644
index 0000000000..71223f8690
--- /dev/null
+++ b/Darling/Darling.Tests/LiveStoreSessionTimeZone.cs
@@ -0,0 +1,46 @@
+/*
+ * 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.Runtime.CompilerServices;
+using PerformanceMonitor.Darling.Storage;
+
+namespace Darling.Tests;
+
+///
+/// Pins every live test's store session to UTC, the way the product pins its own store connections
+/// (). The store's timestamp columns hold naive UTC, and
+/// many reads compare them with the session's clock, so a test store whose server runs in another time zone failed
+/// tests that plant rows relative to UTC now, with no product bug behind them. More than four hundred classes read
+/// DARLING_TEST_PG directly, so the variable is pinned once, when this assembly loads, before any of them reads it.
+/// Pinned by .
+///
+/* #1776 own-store: this class never connects to any store, shared or its own. It rewrites the variable's text once,
+ when the assembly loads and before any live class reads it, so it cannot race the shared store. */
+internal static class LiveStoreSessionTimeZone
+{
+ [ModuleInitializer]
+ internal static void PinTestStoreToUtc()
+ {
+ var connectionString = Environment.GetEnvironmentVariable("DARLING_TEST_PG");
+ if (string.IsNullOrEmpty(connectionString))
+ {
+ return;
+ }
+
+ try
+ {
+ Environment.SetEnvironmentVariable("DARLING_TEST_PG", DarlingStoreConnection.PinSessionTimeZoneUtc(connectionString));
+ }
+ catch (ArgumentException)
+ {
+ /* A malformed string stays as it was, so each live test reports it the way it always has, rather than
+ this initializer failing every test in the assembly at load. */
+ }
+ }
+}
diff --git a/Darling/Darling.Tests/LiveStoreSessionTimeZoneTests.cs b/Darling/Darling.Tests/LiveStoreSessionTimeZoneTests.cs
new file mode 100644
index 0000000000..3dc25e6f33
--- /dev/null
+++ b/Darling/Darling.Tests/LiveStoreSessionTimeZoneTests.cs
@@ -0,0 +1,41 @@
+/*
+ * 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.Threading.Tasks;
+using Npgsql;
+using Xunit;
+
+namespace Darling.Tests;
+
+///
+/// Every live test reaches the store through DARLING_TEST_PG, and many plant rows relative to UTC now and read them
+/// back through SQL that compares naive UTC timestamps with the session's clock. The product pins every store session
+/// to UTC (DarlingStoreConnection.PinSessionTimeZoneUtc), so a store whose server runs in another time zone
+/// still reads correctly. The test process pins the variable the same way when it loads
+/// (). Without that, a test store in another zone failed the Azure master-scope
+/// fleet and daily-summary tests with no product bug.
+///
+[Collection("live-postgres")]
+public sealed class LiveStoreSessionTimeZoneTests
+{
+ [Fact]
+ public async Task ALiveTestSession_RunsInUtc_AgainstDevPostgres()
+ {
+ var connectionString = Environment.GetEnvironmentVariable("DARLING_TEST_PG");
+ Assert.SkipWhen(string.IsNullOrEmpty(connectionString),
+ "Set DARLING_TEST_PG to a Postgres connection string to run the live session time zone test.");
+
+ var ct = TestContext.Current.CancellationToken;
+ await using var connection = new NpgsqlConnection(connectionString);
+ await connection.OpenAsync(ct);
+ await using var command = new NpgsqlCommand("SHOW timezone", connection);
+
+ Assert.Equal("UTC", (string?)await command.ExecuteScalarAsync(ct));
+ }
+}
diff --git a/Darling/Darling.Tests/McpToolGuideHeads.CustomView.cs b/Darling/Darling.Tests/McpToolGuideHeads.CustomView.cs
index 90f3830a08..dd5d14db4f 100644
--- a/Darling/Darling.Tests/McpToolGuideHeads.CustomView.cs
+++ b/Darling/Darling.Tests/McpToolGuideHeads.CustomView.cs
@@ -22,7 +22,7 @@ namespace Darling.Tests;
public sealed class McpToolGuideHeadsCustomViewTests
{
private const int ValidateCustomViewServedCap = 561;
- private const int RunCustomViewPanelServedCap = 612;
+ private const int RunCustomViewPanelServedCap = 609;
/// Guardrail facts the head alone must carry for each tool: what a caller could otherwise misread
/// about the answer (dry-run vs write, exhaustiveness, strictness, status shape, and what an absent
@@ -35,7 +35,7 @@ private static readonly (string Tool, string Fact)[] HeadFacts =
("validate_custom_view", "A notebook cell is FLAT (carries the panel's own keys directly); only run_custom_view_panel's spec nests under 'panel'."),
("run_custom_view_panel", "with no 'status' field on success"),
("run_custom_view_panel", "Failures return {status, message}: \"invalid\" for a bad spec or panel, or a failed or timed-out query; \"error\" for an internal fault."),
- ("run_custom_view_panel", "notice means retention covered only part of the window, or the row cap truncated the result; absent means neither happened"),
+ ("run_custom_view_panel", "notice is a partial-window caveat: the tier's retention could not cover the window, or the panel's own data starts after the window does, or the row cap truncated the result; absent means the window was served whole."),
("run_custom_view_panel", "Window ends now: 'hours' (default 24)"),
("run_custom_view_panel", "Only 'panel' is required."),
];
@@ -48,6 +48,21 @@ private static readonly (string Tool, string Fact)[] TailCarriesFacts =
("validate_custom_view", "This is the exact authority create_custom_view / update_custom_view run before saving"),
("run_custom_view_panel", "This is the SAME compile-and-run the web composer's live preview uses"),
("run_custom_view_panel", "the panel is validated, compiled to catalog-only bound SQL, and executed against the collected store under a statement_timeout"),
+ // The window and server sentences the head gave up so the three-cause notice contract fits its budget.
+ ("run_custom_view_panel", "omit 'server' (or use \"All\") for the whole fleet"),
+ ("run_custom_view_panel", "the span is capped at 90 days, and old windows are served from the retention rollups automatically"),
+ ];
+
+ /// What notice means, said the same way in the head and in the tail: a partial-window caveat
+ /// with three causes, and its absence means the window was served whole (#4953). The old wording named two
+ /// causes (retention, the row cap), so a caller read a panel whose own data started late as a complete one.
+ private static readonly string[] NoticeContract =
+ [
+ "notice is a partial-window caveat",
+ "the tier's retention could not cover the window",
+ "the panel's own data starts after the window does",
+ "the row cap truncated the result",
+ "absent means the window was served whole",
];
[Fact]
@@ -74,6 +89,23 @@ public void BothHeads_StayUnderTheirCap_AndCarryTheirGuardrailFacts()
tool => Assert.All(McpToolGuideTests.Served(tool).ParameterDescriptionLengths, p => Assert.True(p.Length <= 200)));
}
+ /// The contract of notice is in BOTH places a caller reads it: the served head names all three
+ /// causes and what an absent notice means, and the tail says the same.
+ [Fact]
+ public void TheNoticeContract_NamesAllThreeCauses_InTheHeadAndInTheTail()
+ {
+ var run = McpToolGuideTests.Served("run_custom_view_panel");
+ Assert.NotNull(run.Tail);
+
+ foreach (var fact in NoticeContract)
+ {
+ Assert.Contains(fact, run.Served, StringComparison.Ordinal);
+ Assert.Contains(fact, run.Tail!, StringComparison.Ordinal);
+ }
+
+ Assert.DoesNotContain("neither happened", run.Served, StringComparison.Ordinal);
+ }
+
/// Nothing the old descriptions said was lost: every fact the head had no room for is still in the
/// tail, verbatim, so one get_tool_guide call recovers it.
[Fact]
diff --git a/Darling/Darling.Tests/McpToolsListBudget/DarlingMcpCustomViewTools.txt b/Darling/Darling.Tests/McpToolsListBudget/DarlingMcpCustomViewTools.txt
index d483ec7f35..6981178cfa 100644
--- a/Darling/Darling.Tests/McpToolsListBudget/DarlingMcpCustomViewTools.txt
+++ b/Darling/Darling.Tests/McpToolsListBudget/DarlingMcpCustomViewTools.txt
@@ -16,7 +16,7 @@ param get_custom_view.view_id 61
tool list_custom_views 313
-tool run_custom_view_panel 612
+tool run_custom_view_panel 609
param run_custom_view_panel.spec 103
tool update_custom_view 785
diff --git a/Darling/Darling.Tests/QueryStoreTopWindowTests.cs b/Darling/Darling.Tests/QueryStoreTopWindowTests.cs
index 8c287ea33e..a2986adb2e 100644
--- a/Darling/Darling.Tests/QueryStoreTopWindowTests.cs
+++ b/Darling/Darling.Tests/QueryStoreTopWindowTests.cs
@@ -39,19 +39,22 @@ public class QueryStoreTopWindowTests
"Darling", "PerformanceMonitor.Darling.Service", "Mcp", "DarlingMcpDataTools.cs"));
///
- /// The floor probe is bounded on BOTH sides of the window. Bounding the partitioning column is what lets
- /// TimescaleDB exclude chunks; an unbounded MIN would read the retention window to answer a question
- /// about it.
+ /// The floor probe walks the table's (server_id, collection_time) index in time order and stops at the
+ /// first row, so it reads one row, not the retention window, while being bounded only by the window's end. A
+ /// floor bounded below by the window's start called a quiet start a cut. The start bounds only the existence
+ /// check that keeps an empty window null.
///
[Fact]
- public void TheWindowFloorProbe_IsBoundedOnBothSides()
+ public void TheWindowFloorProbe_WalksTheIndexFromTheOldestRow_AndChecksTheWindowApart()
{
var sql = DarlingDataReader.QueryStoreWindowFloorSql;
- Assert.Contains("MIN(collection_time)", sql, StringComparison.Ordinal);
- Assert.Contains("FROM query_store_stats", sql, StringComparison.Ordinal);
- Assert.Contains("collection_time >=", sql, StringComparison.Ordinal);
- Assert.Contains("collection_time <=", sql, StringComparison.Ordinal);
+ Assert.Contains("FROM collect.query_store_stats AS f", sql, StringComparison.Ordinal);
+ Assert.Contains("AND f.collection_time <= $3", sql, StringComparison.Ordinal);
+ Assert.DoesNotContain("f.collection_time >=", sql, StringComparison.Ordinal);
+ Assert.Contains("ORDER BY f.collection_time", sql, StringComparison.Ordinal);
+ Assert.Contains("LIMIT 1", sql, StringComparison.Ordinal);
+ Assert.Contains("AND w.collection_time >= $2", sql, StringComparison.Ordinal);
}
///
diff --git a/Darling/Darling.Tests/RawWindowFloorQuietStartTests.cs b/Darling/Darling.Tests/RawWindowFloorQuietStartTests.cs
new file mode 100644
index 0000000000..9585441269
--- /dev/null
+++ b/Darling/Darling.Tests/RawWindowFloorQuietStartTests.cs
@@ -0,0 +1,226 @@
+/*
+ * 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.Threading;
+using System.Threading.Tasks;
+using Npgsql;
+using PerformanceMonitor.Darling.Storage;
+using Xunit;
+using static Darling.Tests.RepoFile;
+
+namespace Darling.Tests;
+
+///
+/// The Queries tab's "Showing since" banner and the MCP tools' window_truncated read the raw window floor.
+/// A floor found inside the window called a quiet start a cut: a server with older rows, quiet for the first day of
+/// the window, was reported as holding less than the window. The floor is now the oldest row at or before the
+/// window's end, the same way the Custom Views panels find where their data starts, and it is still null when the
+/// window itself holds nothing, which the MCP tools report as an empty window.
+///
+/* #1776 own-store: deliberately NOT [Collection("live-postgres")]. Every test here reaches DARLING_TEST_PG only to
+ CREATE and DROP its own database through ScratchPostgres, then works entirely inside it. */
+public sealed class RawWindowFloorQuietStartLiveTests
+{
+ private const int QuietServerId = -497201;
+ private const string QuietServerName = "raw-floor-quiet-start";
+ private const int EndedServerId = -497202;
+ private const string EndedServerName = "raw-floor-ended";
+ private const int RecentServerId = -497203;
+ private const string RecentServerName = "raw-floor-recent";
+
+ [Fact]
+ public async Task AQuietStartInsideTheWindow_IsNotACut_AgainstDevPostgres()
+ {
+ var ct = TestContext.Current.CancellationToken;
+ await using var store = await SeededStore.CreateAsync(ct);
+ var start = store.End.AddDays(-7);
+
+ var floor = await RawWindowFloor.GetAsync(store.DataSource, RawWindowFloor.Table.QueryStats, QuietServerId, start, store.End, cancellationToken: ct);
+
+ Assert.Equal(store.End.AddDays(-8), floor);
+ Assert.False(RawWindowFloor.IsTruncated(floor, start));
+ Assert.Equal(start, RawWindowFloor.EffectiveStart(floor, start));
+ }
+
+ [Fact]
+ public async Task AServerWhoseRowsEndBeforeTheWindow_ReadsAsNothing_AgainstDevPostgres()
+ {
+ var ct = TestContext.Current.CancellationToken;
+ await using var store = await SeededStore.CreateAsync(ct);
+ var start = store.End.AddDays(-7);
+
+ var floor = await RawWindowFloor.GetAsync(store.DataSource, RawWindowFloor.Table.QueryStats, EndedServerId, start, store.End, cancellationToken: ct);
+
+ Assert.Null(floor);
+ }
+
+ [Fact]
+ public async Task ARecentServer_IsCutAtItsFirstRow_AgainstDevPostgres()
+ {
+ var ct = TestContext.Current.CancellationToken;
+ await using var store = await SeededStore.CreateAsync(ct);
+ var start = store.End.AddDays(-7);
+
+ var floor = await RawWindowFloor.GetAsync(store.DataSource, RawWindowFloor.Table.QueryStats, RecentServerId, start, store.End, cancellationToken: ct);
+
+ Assert.Equal(store.End.AddDays(-3), floor);
+ Assert.True(RawWindowFloor.IsTruncated(floor, start));
+ Assert.Equal(store.End.AddDays(-3), RawWindowFloor.EffectiveStart(floor, start));
+ }
+
+ private sealed class SeededStore : IAsyncDisposable
+ {
+ private readonly ScratchPostgres _scratch;
+
+ private SeededStore(ScratchPostgres scratch, NpgsqlDataSource dataSource, DateTime end)
+ {
+ _scratch = scratch;
+ DataSource = dataSource;
+ End = end;
+ }
+
+ public NpgsqlDataSource DataSource { get; }
+
+ public DateTime End { get; }
+
+ public static async Task CreateAsync(CancellationToken ct)
+ {
+ var baseConnectionString = Environment.GetEnvironmentVariable("DARLING_TEST_PG");
+ Assert.SkipWhen(string.IsNullOrEmpty(baseConnectionString),
+ "Set DARLING_TEST_PG to a Postgres connection string to run the live raw window floor tests.");
+
+ var scratch = await ScratchPostgres.CreateAsync(baseConnectionString!, ct);
+ try
+ {
+ await using var connection = new NpgsqlConnection(scratch.ConnectionString);
+ await connection.OpenAsync(ct);
+ await PgMigrations.MigrateAsync(connection, ct);
+ await DarlingMcpTestData.RegisterServerAsync(connection, QuietServerId, QuietServerName, ct);
+ await DarlingMcpTestData.RegisterServerAsync(connection, EndedServerId, EndedServerName, ct);
+ await DarlingMcpTestData.RegisterServerAsync(connection, RecentServerId, RecentServerName, ct);
+
+ var now = DateTime.UtcNow;
+ var end = new DateTime(now.Year, now.Month, now.Day, now.Hour, now.Minute, 0, DateTimeKind.Utc);
+
+ /* One old row, then nothing until a day into a 7-day window. */
+ await InsertEveryHalfHourAsync(connection, QuietServerId, QuietServerName, end.AddDays(-8), end.AddDays(-8), ct);
+ await InsertEveryHalfHourAsync(connection, QuietServerId, QuietServerName, end.AddDays(-6), end, ct);
+ /* Rows that all end before a 7-day window starts. */
+ await InsertEveryHalfHourAsync(connection, EndedServerId, EndedServerName, end.AddDays(-12), end.AddDays(-9), ct);
+ /* A server added 3 days ago. */
+ await InsertEveryHalfHourAsync(connection, RecentServerId, RecentServerName, end.AddDays(-3), end, ct);
+
+ return new SeededStore(scratch, NpgsqlDataSource.Create(scratch.ConnectionString), end);
+ }
+ catch
+ {
+ await scratch.DisposeAsync();
+ throw;
+ }
+ }
+
+ private static async Task InsertEveryHalfHourAsync(
+ NpgsqlConnection connection, int serverId, string serverName, DateTime firstUtc, DateTime lastUtc, CancellationToken ct)
+ {
+ await using var insert = new NpgsqlCommand(@"
+INSERT INTO collect.query_stats
+ (collection_id, collection_time, server_id, server_name, database_name, query_hash, sql_handle,
+ delta_worker_time, delta_elapsed_time, delta_execution_count, sample_interval_seconds)
+SELECT
+ row_number() OVER (),
+ t,
+ $1,
+ $2,
+ 'FloorDb',
+ 'HASH1',
+ '0x01',
+ 1000,
+ 1000,
+ 10,
+ 300
+FROM generate_series($3::timestamp, $4::timestamp, interval '30 minutes') AS t", connection);
+ insert.Parameters.AddWithValue(serverId);
+ insert.Parameters.AddWithValue(serverName);
+ insert.Parameters.AddWithValue(DateTime.SpecifyKind(firstUtc, DateTimeKind.Unspecified));
+ insert.Parameters.AddWithValue(DateTime.SpecifyKind(lastUtc, DateTimeKind.Unspecified));
+ await insert.ExecuteNonQueryAsync(ct);
+ }
+
+ public async ValueTask DisposeAsync()
+ {
+ await DataSource.DisposeAsync();
+ await _scratch.DisposeAsync();
+ }
+ }
+}
+
+///
+/// The served window never starts before the one asked for. A floor older than the window's start means the rows
+/// reach back past it, so the window was served whole.
+///
+public sealed class RawWindowFloorEffectiveStartTests
+{
+ private static readonly DateTime s_start = new(2026, 9, 25, 12, 0, 0, DateTimeKind.Utc);
+
+ [Fact]
+ public void AFloorBeforeTheStart_ServesTheWholeWindow() =>
+ Assert.Equal(s_start, RawWindowFloor.EffectiveStart(s_start.AddDays(-1), s_start));
+
+ [Fact]
+ public void AFloorAfterTheStart_IsWhereTheWindowWasServedFrom() =>
+ Assert.Equal(s_start.AddHours(2), RawWindowFloor.EffectiveStart(s_start.AddHours(2), s_start));
+
+ [Fact]
+ public void NoFloor_ReportsTheRequestedStart() =>
+ Assert.Equal(s_start, RawWindowFloor.EffectiveStart(null, s_start));
+
+ /// The floor walks each table's index in time order, so the walk stops at the first row it meets.
+ [Theory]
+ [InlineData("query_stats")]
+ [InlineData("procedure_stats")]
+ [InlineData("query_store_stats")]
+ public void EveryRawFloorTable_HasTheIndexTheWalkUses(string table) =>
+ Assert.True(DataWindowFloor.Source.TryForCollectorTable(table, out _));
+
+ ///
+ /// Every MCP tool takes its served start from and its truncation
+ /// verdict from , so none reports a start before the window it was asked
+ /// about, and none keeps a tolerance of its own. The clutter tool once restated the 90 minutes as a private
+ /// constant and compared the floor with requestedStart + by hand, a copy that went stale when the floor
+ /// stopped being the first row inside the window. The lines below are the ways a tool computes either by hand.
+ ///
+ [Fact]
+ public void NoMcpTool_ComputesTheServedStartByHand()
+ {
+ var byHand = new[]
+ {
+ "floor ?? requestedStart",
+ "WindowFloorTolerance",
+ "FromMinutes(90)",
+ "requestedStart +",
+ "requestedStart.Add(",
+ };
+
+ var found = new List();
+ foreach (var file in new[] { "DarlingMcpDataTools.cs", "DarlingMcpQueryStoreClutterTools.cs" })
+ {
+ var text = ReadRepoFile("Darling", "PerformanceMonitor.Darling.Service", "Mcp", file);
+ foreach (var line in byHand)
+ {
+ if (text.Contains(line, StringComparison.Ordinal))
+ {
+ found.Add(file + " has `" + line + "`");
+ }
+ }
+ }
+
+ Assert.True(found.Count == 0, "an MCP tool computes the served start or the truncation verdict by hand: " + string.Join("; ", found));
+ }
+}
diff --git a/Darling/Darling.Tests/RawWindowFloorViewerPortTests.cs b/Darling/Darling.Tests/RawWindowFloorViewerPortTests.cs
index 6382d17b02..ceef335740 100644
--- a/Darling/Darling.Tests/RawWindowFloorViewerPortTests.cs
+++ b/Darling/Darling.Tests/RawWindowFloorViewerPortTests.cs
@@ -171,7 +171,7 @@ public void QueryStoreGrid_BindsThePlansReadStart_AndPassesThePlanToTheBanner()
var tab = ViewerFile("ViewerServerTab.Queries.cs");
Assert.Contains("GetQueryStoreTopQueriesWithReachAsync(", tab, StringComparison.Ordinal);
- Assert.Contains("UpdateTruncationBanner(QueryStoreTruncationBanner, await floorTask, startUtc, widePlan: widePlan)", tab, StringComparison.Ordinal);
+ Assert.Contains("UpdateTruncationBanner(QueryStoreTruncationBanner, await DataStartOrNullAsync(floorTask, \"Query Store\"), startUtc, widePlan: widePlan)", tab, StringComparison.Ordinal);
Assert.Contains("QueryStoreIntervalWide.BannerReason(widePlan.Value.StartBound)", tab, StringComparison.Ordinal);
Assert.DoesNotContain("interval table keeps 9 days", tab, StringComparison.Ordinal);
Assert.Contains(" · slicer since ", tab, StringComparison.Ordinal);
diff --git a/Darling/Darling.Tests/ScratchPostgres.cs b/Darling/Darling.Tests/ScratchPostgres.cs
index e65d5aa72d..6b243a0922 100644
--- a/Darling/Darling.Tests/ScratchPostgres.cs
+++ b/Darling/Darling.Tests/ScratchPostgres.cs
@@ -55,6 +55,10 @@ public static async Task CreateAsync(string baseConnectionStrin
Pooling = false,
};
+ /* The base string can already carry the session pin (LiveStoreSessionTimeZone adds it to DARLING_TEST_PG),
+ so the unpinned string drops it. */
+ builder.Remove("Timezone");
+
/* Pinned to UTC the way every product store connection is, so a cluster whose default zone is behind UTC
cannot skew timestamp round trips (a chunk's range_end read back through a ::timestamp cast). */
return new ScratchPostgres(
diff --git a/Darling/Darling.Tests/ViewerDataStartBannerTests.cs b/Darling/Darling.Tests/ViewerDataStartBannerTests.cs
new file mode 100644
index 0000000000..4ea4f53a41
--- /dev/null
+++ b/Darling/Darling.Tests/ViewerDataStartBannerTests.cs
@@ -0,0 +1,338 @@
+/*
+ * 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.Globalization;
+using System.Text.RegularExpressions;
+using System.Threading;
+using System.Threading.Tasks;
+using System.Windows;
+using System.Windows.Controls;
+using PerformanceMonitor.Darling.Storage;
+using PerformanceMonitor.Darling.Viewer;
+using PerformanceMonitor.Ui;
+using Xunit;
+using static Darling.Tests.RepoFile;
+
+namespace Darling.Tests;
+
+///
+/// The desktop viewer's server tab says where the data starts on the two surfaces that read the 7-day tables over
+/// the tab's range: Active Queries (query_snapshots) and Blocking's Current Waits (waiting_tasks). A 30-day custom
+/// range used to draw 7 days on both with nothing to say so. Each asks the shared probe
+/// () where the server's coverage starts for the
+/// range (the later of its first collection and the table's retention edge, moved earlier by a row in the range,
+/// so a quiet start is not a cut) and shows the Queries tab's "Showing since" banner when that is after the
+/// range's start. The Queries tab's three raw-table grids (Top Queries, Top Procedures, Query Store) ask their own
+/// window-floor probe the same way, and every one of these probes is awaited through the same catch, so a probe that
+/// throws costs its banner and nothing after it.
+///
+/* The banner's time text reads the process-wide display mode, which the culture pin sets (restored in Dispose); one
+ shared collection serializes it with every other class that flips the viewer's time statics. */
+[Collection("viewer-time-statics")]
+public sealed class ViewerDataStartBannerTests : IDisposable
+{
+ private readonly TimeDisplayMode _savedMode = ViewerTimeHelper.CurrentDisplayMode;
+
+ public void Dispose() => ViewerTimeHelper.CurrentDisplayMode = _savedMode;
+
+ private static string ViewerFile(string file) => ReadRepoFile("Darling", "PerformanceMonitor.Darling.Viewer", file);
+
+ private static int Count(string source, string text) => Regex.Matches(source, Regex.Escape(text)).Count;
+
+ [Fact]
+ public void ActiveQueries_ShowsWhereTheSnapshotsStart_OnEveryReadOfTheGrid()
+ {
+ var tab = ViewerFile("ViewerServerTab.ActiveQueries.cs");
+
+ /* The range load, the deep-link load, the slicer re-read and the window drill (the heatmap, the charts and the
+ Overview drills) each draw the grid, so each sets the banner. The window drill is the fourth (3 -> 4, #4953): it
+ loads its own narrow window and used to leave the banner of the last range read standing. */
+ Assert.Equal(4, Count(tab, "_dataService.GetQuerySnapshotsDataStartAsync("));
+ Assert.Equal(4, Count(tab, "UpdateTruncationBanner(QuerySnapshotsTruncationBanner, "));
+ Assert.Contains("x:Name=\"QuerySnapshotsTruncationBanner\"", ViewerFile("ViewerServerTab.xaml"), StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void CurrentWaits_ShowsWhereTheWaitingTasksStart()
+ {
+ var tab = ViewerFile("ViewerServerTab.Blocking.cs");
+
+ Assert.Contains("_dataService.GetWaitingTasksDataStartAsync(", tab, StringComparison.Ordinal);
+ Assert.Contains("UpdateTruncationBanner(CurrentWaitsTruncationBanner, ", tab, StringComparison.Ordinal);
+ Assert.Contains("x:Name=\"CurrentWaitsTruncationBanner\"", ViewerFile("ViewerServerTab.xaml"), StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void BothReads_GoThroughTheSharedProbe()
+ {
+ var snapshots = ViewerFile("ViewerDataService.QuerySnapshots.cs");
+ Assert.Contains("DataWindowFloor.GetForServerAsync(", snapshots, StringComparison.Ordinal);
+ Assert.Contains("DataWindowFloor.Source.ForCollectorTable(\"query_snapshots\")", snapshots, StringComparison.Ordinal);
+
+ var waits = ViewerFile("ViewerDataService.BlockingTrends.cs");
+ Assert.Contains("DataWindowFloor.GetForServerAsync(", waits, StringComparison.Ordinal);
+ Assert.Contains("DataWindowFloor.Source.ForCollectorTable(\"waiting_tasks\")", waits, StringComparison.Ordinal);
+ }
+
+ private static int Matches(string source, string pattern) => Regex.Matches(source, pattern).Count;
+
+ /* Each read asks its probe about the window it draws and compares the answer with that window's start: the toolbar's
+ range on a load, the deep-link's own narrow window (pending.FromUtc, not the toolbar's start the method also
+ receives), the slicer's selection on a drag. */
+ [Fact]
+ public void ActiveQueries_RangeLoad_AsksAboutTheToolbarWindow_AndComparesWithItsStart()
+ {
+ var tab = ViewerFile("ViewerServerTab.ActiveQueries.cs");
+
+ Assert.Equal(1, Matches(tab, @"var dataStartTask = _dataService\.GetQuerySnapshotsDataStartAsync\(_server\.ServerId,\s*startUtc,\s*endUtc\);"));
+ Assert.Equal(1, Matches(tab,
+ @"UpdateTruncationBanner\(QuerySnapshotsTruncationBanner,\s*await DataStartOrNullAsync\(dataStartTask,\s*""Active Queries""\),\s*startUtc\);\s*await LoadActiveQueriesSlicerAsync\(startUtc,\s*endUtc\);"));
+ }
+
+ [Fact]
+ public void ActiveQueries_DeepLink_AsksAboutThePendingWindow_AndComparesWithItsStart()
+ {
+ var tab = ViewerFile("ViewerServerTab.ActiveQueries.cs");
+
+ Assert.Equal(1, Matches(tab, @"var pendingDataStartTask = _dataService\.GetQuerySnapshotsDataStartAsync\(_server\.ServerId,\s*pending\.FromUtc,\s*pending\.ToUtc\);"));
+ Assert.Equal(1, Matches(tab,
+ @"UpdateTruncationBanner\(QuerySnapshotsTruncationBanner,\s*await DataStartOrNullAsync\(pendingDataStartTask,\s*""Active Queries""\),\s*pending\.FromUtc\);"));
+ }
+
+ [Fact]
+ public void ActiveQueries_SlicerDrag_AsksAboutTheSelection_AndComparesWithItsStart()
+ {
+ var tab = ViewerFile("ViewerServerTab.ActiveQueries.cs");
+
+ Assert.Equal(1, Matches(tab, @"var dataStartTask = _dataService\.GetQuerySnapshotsDataStartAsync\(_server\.ServerId,\s*e\.StartUtc,\s*e\.EndUtc\);"));
+ Assert.Equal(1, Matches(tab,
+ @"UpdateTruncationBanner\(QuerySnapshotsTruncationBanner,\s*await DataStartOrNullAsync\(dataStartTask,\s*""Active Queries""\),\s*e\.StartUtc\);"));
+ }
+
+ /* A window drill (the heatmap, a resource or trend chart, an Overview drill, or the deep link that reuses it) loads the
+ grid for its own narrow window, so it asks the probe about THAT window (fromUtc/toUtc, not the toolbar's range) and
+ compares the answer with its start, after the rows are bound and before the slicer loads, as the deep-link branch
+ of the range load does. Without it the banner of the last range read stays up: it can name a cut the drill window
+ does not have, or miss one it does. */
+ [Fact]
+ public void ActiveQueries_WindowDrill_AsksAboutTheDrillWindow_AndComparesWithItsStart()
+ {
+ var tab = ViewerFile("ViewerServerTab.ActiveQueries.cs");
+ var drill = Regex.Match(tab, @"private async Task NavigateToActiveQueriesForWindowAsync\(.*?\r?\n \}\r?\n", RegexOptions.Singleline);
+ Assert.True(drill.Success, "NavigateToActiveQueriesForWindowAsync was not found in ViewerServerTab.ActiveQueries.cs");
+ var body = drill.Value;
+
+ Assert.Equal(1, Matches(body,
+ @"var dataStartTask = _dataService\.GetQuerySnapshotsDataStartAsync\(_server\.ServerId,\s*fromUtc,\s*toUtc\);\s*var \(totalCount, snapshots\) = await _dataService\.GetLatestQuerySnapshotsAsync\(_server\.ServerId,\s*fromUtc,\s*toUtc,"));
+ Assert.Equal(1, Matches(body,
+ @"_querySnapshotsFilterMgr!\.UpdateData\(snapshots\);[\s\S]*?UpdateTruncationBanner\(QuerySnapshotsTruncationBanner,\s*await DataStartOrNullAsync\(dataStartTask,\s*""Active Queries""\),\s*fromUtc\);\s*await LoadActiveQueriesSlicerAsync\(fromUtc\.AddHours\(-1\),\s*toUtc\.AddHours\(1\)\);"));
+ }
+
+ /* "Latest Snapshot" swaps the grid for the newest stored batch, so the "Showing since" banner the last range read
+ raised no longer describes it. The handler collapses the banner and clears its text as the batch is bound, and not
+ before, so a read that fails, which leaves the range rows in the grid, keeps the banner that still describes them.
+ It is a direct hide and not a fifth UpdateTruncationBanner call: that call asks a probe about a range, and this
+ read has none (the count pin above stays at four). The next range read raises the banner again through those
+ four calls. */
+ [Fact]
+ public void LatestSnapshot_HidesTheActiveQueriesBanner_AsTheBatchIsBound_AndNotWhenTheReadFails()
+ {
+ var tab = ViewerFile("ViewerServerTab.ActiveQueries.cs");
+ var handler = Regex.Match(tab, @"private async void LatestSnapshot_Click\(.*?\r?\n \}\r?\n", RegexOptions.Singleline);
+ Assert.True(handler.Success, "LatestSnapshot_Click was not found in ViewerServerTab.ActiveQueries.cs");
+ var body = handler.Value;
+
+ var bound = body.IndexOf("_querySnapshotsFilterMgr!.UpdateData(rows);", StringComparison.Ordinal);
+ var failed = body.IndexOf("catch (Exception ex)", StringComparison.Ordinal);
+ Assert.True(bound >= 0, "LatestSnapshot_Click no longer binds the batch through _querySnapshotsFilterMgr.UpdateData(rows)");
+ Assert.True(failed > bound, "LatestSnapshot_Click no longer has its failure arm after the rows are bound");
+
+ var collapses = Regex.Matches(body, @"QuerySnapshotsTruncationBanner\.Visibility\s*=\s*Visibility\.Collapsed;");
+ var clears = Regex.Matches(body, @"QuerySnapshotsTruncationBanner\.Text\s*=\s*(?:""""|string\.Empty);");
+ Assert.True(collapses.Count == 1 && clears.Count == 1,
+ "LatestSnapshot_Click must collapse the Active Queries banner and clear its text, once each (found " + collapses.Count + " and "
+ + clears.Count + "): the newest batch replaces the range rows the banner described");
+ Assert.True(collapses[0].Index > bound && collapses[0].Index < failed && clears[0].Index > bound && clears[0].Index < failed,
+ "the banner must come down after the batch is bound and before the failure arm, so a failed read keeps it");
+ }
+
+ [Fact]
+ public void CurrentWaits_AsksAboutTheToolbarWindow_AndComparesWithItsStart()
+ {
+ var tab = ViewerFile("ViewerServerTab.Blocking.cs");
+
+ Assert.Equal(1, Matches(tab, @"var dataStartTask = _dataService\.GetWaitingTasksDataStartAsync\(_server\.ServerId,\s*startUtc,\s*endUtc\);"));
+ Assert.Equal(1, Matches(tab,
+ @"UpdateTruncationBanner\(CurrentWaitsTruncationBanner,\s*await DataStartOrNullAsync\(dataStartTask,\s*""Current Waits""\),\s*startUtc\);"));
+ }
+
+ /* The Queries tab's three raw-table grids (Top Queries, Top Procedures, Query Store) start their window-floor probe
+ beside the grid read, ask it about the window the grid draws, and compare the answer with that window's start: the
+ toolbar's range on a load (startUtc), the slicer's selection on a drag (e.StartUtc). Both await the probe through the
+ same catch as the two surfaces above, so a probe that throws costs the grid its banner and not the slicer and the
+ comparison loads that follow it. The tail is what the call passes after the start: the hourly-tier suffix on Top
+ Queries and Top Procedures, the interval-table plan on a Query Store load, nothing on a Query Store slicer drag. */
+ private const string HourlyTail = @",\s*tier == ""hourly"" \? HourlyTierSuffix : null";
+ private const string WidePlanTail = @",\s*widePlan: widePlan";
+
+ [Theory]
+ [InlineData("GetQueryStatsWindowFloorAsync", "QueryStats", "Query Stats", HourlyTail)]
+ [InlineData("GetProcedureStatsWindowFloorAsync", "ProcStats", "Procedure Stats", HourlyTail)]
+ [InlineData("GetQueryStoreWindowFloorAsync", "QueryStore", "Query Store", WidePlanTail)]
+ public void QueriesTab_RangeLoad_AsksAboutTheToolbarWindow_AndComparesWithItsStart(string probe, string grid, string surface, string tail)
+ {
+ var tab = ViewerFile("ViewerServerTab.Queries.cs");
+
+ Assert.Equal(1, Matches(tab, $@"var floorTask = _dataService\.{probe}\(_server\.ServerId,\s*startUtc,\s*endUtc\);"));
+ Assert.Equal(1, Matches(tab,
+ $@"UpdateTruncationBanner\({grid}TruncationBanner,\s*await DataStartOrNullAsync\(floorTask,\s*""{surface}""\),\s*startUtc{tail}\);\s*await Load{grid}SlicerAsync\(startUtc,\s*endUtc\);\s*await Refresh{grid}ComparisonAsync\(startUtc,\s*endUtc\);"));
+ }
+
+ [Theory]
+ [InlineData("GetQueryStatsWindowFloorAsync", "QueryStats", "Query Stats", HourlyTail)]
+ [InlineData("GetProcedureStatsWindowFloorAsync", "ProcStats", "Procedure Stats", HourlyTail)]
+ [InlineData("GetQueryStoreWindowFloorAsync", "QueryStore", "Query Store", "")]
+ public void QueriesTab_SlicerDrag_AsksAboutTheSelection_AndComparesWithItsStart(string probe, string grid, string surface, string tail)
+ {
+ var tab = ViewerFile("ViewerServerTab.Queries.cs");
+
+ Assert.Equal(1, Matches(tab, $@"var floorTask = _dataService\.{probe}\(_server\.ServerId,\s*e\.StartUtc,\s*e\.EndUtc\);"));
+ Assert.Equal(1, Matches(tab,
+ $@"UpdateTruncationBanner\({grid}TruncationBanner,\s*await DataStartOrNullAsync\(floorTask,\s*""{surface}""\),\s*e\.StartUtc{tail}\);\s*await Refresh{grid}ComparisonAsync\(e\.StartUtc,\s*e\.EndUtc\);"));
+ }
+
+ /* No read awaits its probe bare (a probe that throws would unwind the load past the slicer, or the rest of the tab),
+ and the default log is the viewer's own. On the Queries tab every banner call goes through the catch, so a grid read
+ added later without it fails here instead of shipping bare. */
+ [Fact]
+ public void NoLoadAwaitsItsProbeBare_AndTheDefaultLogIsTheViewersOwn()
+ {
+ var queries = ViewerFile("ViewerServerTab.Queries.cs");
+
+ foreach (var source in new[] { ViewerFile("ViewerServerTab.ActiveQueries.cs"), ViewerFile("ViewerServerTab.Blocking.cs"), queries })
+ {
+ Assert.DoesNotContain("await dataStartTask", source, StringComparison.Ordinal);
+ Assert.DoesNotContain("await pendingDataStartTask", source, StringComparison.Ordinal);
+ Assert.DoesNotContain("await floorTask", source, StringComparison.Ordinal);
+ }
+
+ /* Six banner calls: a range load and a slicer drag on each of the three grids. */
+ const string bannerCall = @"UpdateTruncationBanner\(\w+TruncationBanner,";
+ Assert.Equal(6, Matches(queries, bannerCall));
+ Assert.Equal(6, Matches(queries, bannerCall + @"\s*await DataStartOrNullAsync\(floorTask,"));
+
+ Assert.Contains("(warn ?? ViewerLogger.Warn)(", queries, StringComparison.Ordinal);
+ }
+
+ private static readonly DateTime RangeStart = new(2026, 9, 1, 0, 0, 0, DateTimeKind.Utc);
+
+ /* A probe that throws costs the surface its banner and nothing else: the helper answers null instead of throwing (the
+ load goes on to the slicer), the banner drawn from that answer is hidden (seeded visible, so a no-op cannot pass),
+ and the failure is logged with the surface and the cause. */
+ [Fact]
+ public void AProbeThatThrows_HidesTheBanner_IsLogged_AndLetsTheLoadGoOn()
+ {
+ OnStaThread(() =>
+ {
+ var banner = new TextBlock { Visibility = Visibility.Visible, Text = "Showing since 2026-09-04 00:00" };
+ var logged = new List<(string Source, string Message)>();
+ var probe = Task.FromException(new InvalidOperationException("the store went away"));
+
+ var floor = ViewerServerTab.DataStartOrNullAsync(probe, "Active Queries", (source, message) => logged.Add((source, message)))
+ .GetAwaiter().GetResult();
+ ViewerServerTab.UpdateTruncationBanner(banner, floor, RangeStart);
+
+ Assert.Null(floor);
+ Assert.Equal(Visibility.Collapsed, banner.Visibility);
+ var line = Assert.Single(logged);
+ Assert.Equal("ViewerServerTab", line.Source);
+ Assert.Contains("Active Queries", line.Message, StringComparison.Ordinal);
+ Assert.Contains("the store went away", line.Message, StringComparison.Ordinal);
+ });
+ }
+
+ [Fact]
+ public void AProbeThatAnswers_PassesTheAnswerOn_AndLogsNothing()
+ {
+ OnStaThread(() =>
+ {
+ var banner = new TextBlock();
+ var logged = new List<(string Source, string Message)>();
+ var answer = RangeStart.AddDays(3);
+
+ var floor = ViewerServerTab.DataStartOrNullAsync(Task.FromResult(answer), "Current Waits", (source, message) => logged.Add((source, message)))
+ .GetAwaiter().GetResult();
+ ViewerServerTab.UpdateTruncationBanner(banner, floor, RangeStart);
+
+ Assert.Equal(answer, floor);
+ Assert.Empty(logged);
+ Assert.Equal(Visibility.Visible, banner.Visibility);
+ });
+ }
+
+ /* The banner names its instants on the invariant culture whatever the machine's: under a Thai default (Buddhist
+ calendar, 2026 prints as 2569) or a Finnish one (a "." time separator) it still reads Gregorian "yyyy-MM-dd HH:mm",
+ as Lite's banner and the web's notice do. Every branch of the shared banner is read. */
+ [Theory]
+ [InlineData("th-TH")]
+ [InlineData("fi-FI")]
+ public void TheBanner_NamesItsInstants_OnTheInvariantCulture(string cultureName)
+ {
+ var savedMode = ViewerTimeHelper.CurrentDisplayMode;
+ try
+ {
+ ViewerTimeHelper.CurrentDisplayMode = TimeDisplayMode.UTC;
+ OnStaThread(() =>
+ {
+ CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo(cultureName);
+ /* The premise: this culture's own formatting does not give the invariant text, so the pin is not vacuous. */
+ Assert.NotEqual("2026-09-04 00:00", RangeStart.AddDays(3).ToString("yyyy-MM-dd HH:mm"));
+
+ var banner = new TextBlock();
+
+ ViewerServerTab.UpdateTruncationBanner(banner, RangeStart.AddDays(3), RangeStart);
+ Assert.Equal("Showing since 2026-09-04 00:00", banner.Text);
+
+ ViewerServerTab.UpdateTruncationBanner(banner, null, RangeStart, " (hourly)");
+ Assert.Equal("Showing 2026-09-01 00:00 (hourly)", banner.Text);
+
+ var wideStart = RangeStart.AddDays(1);
+ var plan = new QueryStoreIntervalWide.WideReadPlan(
+ true, RangeStart.AddDays(4), wideStart, wideStart, QueryStoreIntervalWide.WideStartBound.FilledSince);
+ ViewerServerTab.UpdateTruncationBanner(banner, RangeStart.AddDays(4), RangeStart, widePlan: plan);
+ Assert.StartsWith("Showing since 2026-09-02 00:00", banner.Text, StringComparison.Ordinal);
+ Assert.Contains(" · slicer since 2026-09-05 00:00", banner.Text, StringComparison.Ordinal);
+ });
+ }
+ finally
+ {
+ ViewerTimeHelper.CurrentDisplayMode = savedMode;
+ }
+ }
+
+ /* WPF objects require STA; same shape as RawWindowFloorViewerPortTests. */
+ private static void OnStaThread(Action body)
+ {
+ Exception? error = null;
+ var thread = new Thread(() =>
+ {
+ try { body(); }
+ catch (Exception ex) { error = ex; }
+ });
+ thread.SetApartmentState(ApartmentState.STA);
+ thread.Start();
+ thread.Join();
+
+ if (error is not null)
+ {
+ throw error;
+ }
+ }
+}
diff --git a/Darling/Darling.Tests/web-compose-notice-harness.mjs b/Darling/Darling.Tests/web-compose-notice-harness.mjs
new file mode 100644
index 0000000000..551183a595
--- /dev/null
+++ b/Darling/Darling.Tests/web-compose-notice-harness.mjs
@@ -0,0 +1,119 @@
+/* Runs the web viewer's composed-panel body (wwwroot/js/compose.js and util.js) against a scripted
+ /api/compose/run answer and prints the strips it drew as one line of JSON. ComposeDataFloorLiveTests starts it as
+ node web-compose-notice-harness.mjs
+ The scenario file holds {panel, scope, answer}: the panel spec and scope the page would send, and the body the run
+ endpoint returned for them. The modules are copied into a scratch folder beside stand-ins for charts.js (the SVG
+ renderer, which needs a real browser), panels.js and views-api.js (reached only by clicks and annotation names),
+ then imported. `fetch` and the DOM are stand-ins: a node tree of plain objects, and a fetch that answers the run
+ with the scenario's body. Everything else is the shipped code. */
+import fs from "node:fs";
+import os from "node:os";
+import path from "node:path";
+import { pathToFileURL } from "node:url";
+
+const [jsDir, scenarioPath] = process.argv.slice(-2);
+const scenario = JSON.parse(fs.readFileSync(scenarioPath, "utf8"));
+
+class FakeNode {
+ constructor(tag, text) {
+ this.tag = tag;
+ this.children = [];
+ this.attrs = {};
+ this.dataset = {};
+ this.style = {};
+ this.className = "";
+ this.text = text == null ? null : String(text);
+ this.classList = { add() {}, remove() {}, toggle() {}, contains: () => false };
+ }
+ get firstChild() {
+ return this.children[0] || null;
+ }
+ appendChild(child) {
+ this.children.push(child);
+ return child;
+ }
+ removeChild(child) {
+ const i = this.children.indexOf(child);
+ if (i >= 0) this.children.splice(i, 1);
+ return child;
+ }
+ setAttribute(name, value) {
+ this.attrs[name] = String(value);
+ }
+ getAttribute(name) {
+ return name in this.attrs ? this.attrs[name] : null;
+ }
+ addEventListener() {}
+ set textContent(value) {
+ this.children = [];
+ this.text = String(value);
+ }
+ get textContent() {
+ return (this.text || "") + this.children.map((c) => c.textContent).join("");
+ }
+}
+
+globalThis.Node = FakeNode;
+globalThis.document = {
+ createElement: (tag) => new FakeNode(tag),
+ createElementNS: (ns, tag) => new FakeNode(tag),
+ createTextNode: (text) => new FakeNode("#text", text),
+};
+
+const fetches = [];
+globalThis.fetch = async (url, init) => {
+ fetches.push((init && init.method ? init.method : "GET") + " " + String(url));
+ const isRun = String(url) === "/api/compose/run";
+ const raw = JSON.stringify(isRun ? scenario.answer : {});
+ return { status: 200, ok: true, text: async () => raw };
+};
+
+const rejections = [];
+process.on("unhandledRejection", (e) => rejections.push(String(e && e.stack ? e.stack : e)));
+
+const scratch = fs.mkdtempSync(path.join(os.tmpdir(), "compose-notice-"));
+let compose;
+try {
+ fs.writeFileSync(path.join(scratch, "package.json"), '{ "type": "module" }');
+ fs.copyFileSync(path.join(jsDir, "util.js"), path.join(scratch, "util.js"));
+ fs.copyFileSync(path.join(jsDir, "compose.js"), path.join(scratch, "compose.js"));
+ fs.writeFileSync(
+ path.join(scratch, "charts.js"),
+ 'import { el } from "./util.js";\n' +
+ "export const CATEGORICAL_COLORS = ['#111', '#222', '#333', '#444', '#555', '#666'];\n" +
+ "const stub = () => el('div', { class: 'chart-stub' });\n" +
+ "export const renderLineChart = stub;\n" +
+ "export const renderBarChart = stub;\n" +
+ "export const renderPieChart = stub;\n" +
+ "export const renderScatterChart = stub;\n"
+ );
+ fs.writeFileSync(path.join(scratch, "panels.js"), "export function navigateServer() {}\n");
+ fs.writeFileSync(path.join(scratch, "views-api.js"), "export async function getCatalog() { return { compose: {} }; }\n");
+ const load = (rel) => import(pathToFileURL(path.join(scratch, rel)).href);
+ compose = await load("compose.js");
+} finally {
+ fs.rmSync(scratch, { recursive: true, force: true });
+}
+
+const root = new FakeNode("main");
+await compose.renderComposedInto(root, scenario.panel, scenario.scope);
+// Let the run and the mount settle (each is a few promise hops).
+for (let i = 0; i < 50; i++) await new Promise((r) => setTimeout(r, 0));
+
+const strips = (kind) => {
+ const found = [];
+ const walk = (n) => {
+ if (!n || typeof n !== "object") return;
+ if (n.className === "strip " + kind) found.push(n.textContent);
+ n.children.forEach(walk);
+ };
+ walk(root);
+ return found;
+};
+
+console.log(JSON.stringify({
+ fetches,
+ notices: strips("notice"),
+ errors: strips("error"),
+ rejections,
+}));
diff --git a/Darling/PerformanceMonitor.Darling.Service/Compose/ComposeSourceRouter.cs b/Darling/PerformanceMonitor.Darling.Service/Compose/ComposeSourceRouter.cs
index 9a29ad3542..68e0b84ab8 100644
--- a/Darling/PerformanceMonitor.Darling.Service/Compose/ComposeSourceRouter.cs
+++ b/Darling/PerformanceMonitor.Darling.Service/Compose/ComposeSourceRouter.cs
@@ -30,8 +30,13 @@ public enum ComposeSourceTier
/// The routing decision for one panel: which tier to read and the relation/time-column that implies. The raw tier
/// carries no relation (the compiler keeps its existing SourceTable + prefix-time-column path); a CAGG tier
/// names the rollup view, whose time column is always the bucket the CAGG produced.
+///
+/// is the instant a stitched splits at
+/// (): the superseded rollup is read below it and its successor from it up. It
+/// is null when the clause names one relation. It is carried as a value, set where the router builds the clause, so
+/// the data-start probe reads the successor over the same range the panel does.
///
-public sealed record ComposeRoute(ComposeSourceTier Tier, string? CaggRelation, string? CaggFromClause = null)
+public sealed record ComposeRoute(ComposeSourceTier Tier, string? CaggRelation, string? CaggFromClause = null, DateTime? StitchBoundaryUtc = null)
{
/// The raw route — the compiler's unchanged behaviour.
public static readonly ComposeRoute Raw = new(ComposeSourceTier.Raw, null);
@@ -294,7 +299,13 @@ private static (ComposeRoute Route, DateTime? FloorUtc) PreferDayGrainDaily(
return resolved;
}
- var dayGrain = (Route: new ComposeRoute(ComposeSourceTier.Daily, cagg.DayGrainDailyView),
+ /* The day-grain daily has no successor to stitch to, so its FROM clause is the one relation. It is set here,
+ like every other rollup route's, because the data-start probe reads the relations a panel read off that
+ clause; compiled, it is the same text the compiler built from the name alone (" AS f" appended). */
+ var dayGrain = (Route: new ComposeRoute(
+ ComposeSourceTier.Daily,
+ cagg.DayGrainDailyView,
+ $"{PgSchemaGenerator.CollectSchema}.{cagg.DayGrainDailyView} AS {ComposeRoute.FactAlias}"),
FloorUtc: coverage.FloorOf(cagg.DayGrainDailyView));
if (TierCoverage.Covers(dayGrain.FloorUtc, windowStartUtc))
@@ -346,11 +357,14 @@ its own interval-honest successor daily at F_d — the tier CHOICE itself stays
the FROM-clause item the compiler actually splices in, which may stitch the legacy hourly to its
interval-honest successor. With no successor this is byte-identical to
"collect. AS f" — the same text the old CaggRelation-only path produced once the
- compiler appended " AS f" itself. */
+ compiler appended " AS f" itself. The route also carries the stitch boundary as a value
+ (RollupCoverage.StitchFloor, the F the clause splits at, null when the clause names one relation),
+ so the data-start probe reads the successor from that boundary up, as the panel does. */
RetentionTier.Hourly => (new ComposeRoute(
ComposeSourceTier.Hourly,
coverage.HourlyRelationNameFor(hourlyView, windowStartUtc),
- coverage.StitchedRelationSql(hourlyView, ComposeRoute.FactAlias, windowStartUtc, RollupCoverage.StitchTier.Hourly)),
+ coverage.StitchedRelationSql(hourlyView, ComposeRoute.FactAlias, windowStartUtc, RollupCoverage.StitchTier.Hourly),
+ coverage.StitchFloor(hourlyView, RollupCoverage.StitchTier.Hourly, windowStartUtc)),
tierCoverage.HourlyFloorUtc),
/* #3653 A6: same shape as the hourly arm above, but StitchTier.Daily — the successor daily's own
floor and its successor hourly's ceiling-of-day decide F_d (RollupCoverage.StitchedRelationSql).
@@ -358,7 +372,8 @@ compiler appended " AS f" itself. */
_ => (new ComposeRoute(
ComposeSourceTier.Daily,
dailyView!,
- coverage.StitchedRelationSql(dailyView!, ComposeRoute.FactAlias, windowStartUtc, RollupCoverage.StitchTier.Daily)),
+ coverage.StitchedRelationSql(dailyView!, ComposeRoute.FactAlias, windowStartUtc, RollupCoverage.StitchTier.Daily),
+ coverage.StitchFloor(dailyView!, RollupCoverage.StitchTier.Daily, windowStartUtc)),
tierCoverage.DailyFloorUtc),
};
}
diff --git a/Darling/PerformanceMonitor.Darling.Service/Compose/ComposeStoreAvailability.cs b/Darling/PerformanceMonitor.Darling.Service/Compose/ComposeStoreAvailability.cs
index 673daaa960..f265f13991 100644
--- a/Darling/PerformanceMonitor.Darling.Service/Compose/ComposeStoreAvailability.cs
+++ b/Darling/PerformanceMonitor.Darling.Service/Compose/ComposeStoreAvailability.cs
@@ -7,11 +7,14 @@
*/
using System;
+using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Runtime.CompilerServices;
+using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
+using Microsoft.Extensions.Logging;
using Npgsql;
using PerformanceMonitor.Collectors;
using PerformanceMonitor.Darling.Storage;
@@ -241,7 +244,9 @@ so GetMeasuredRollupsAsync can refuse to route on it. */
/// without the extension no retention policy ever drops raw, so raw holds the complete answer (#1665).
/// Scoped to the TIERED tables (): every other source reaches Raw via
/// the no-CAGG early return and lives on the 30-day collector purge, not the 4-day tier — a 7-day
- /// wait_stats panel is complete on raw, and a notice there would be a false alarm.
+ /// wait_stats panel is complete on raw, and a notice there would be a false alarm. When this returns null,
+ /// the runner asks where the panel's own rows start instead (), which
+ /// covers those sources, a store with no rollups, and one server whose rows start later than the rest.
///
/// MEASURED beats assumed (#1759). The retention SPAN is only a proxy for how far a tier
/// reaches, and on the stores #1759 is about it is the wrong proxy in the dangerous direction: their raw
@@ -314,6 +319,110 @@ longer reads "reaches back about 1 days". */
$"partial window: this panel read the {tierName} tier, which on this store reaches back about {heldText} day{(heldText == "1" ? "" : "s")}, but the requested window starts {windowText} day{(windowText == "1" ? "" : "s")} back — older points are not included.");
}
+ ///
+ /// The "partial window" notice for a panel whose coverage starts after its window does, or null when coverage
+ /// reaches the window's start (whether or not the window holds rows), when no server in scope holds a row or
+ /// logged a run in the window, when the window lies wholly before the coverage (the run log outlives the table,
+ /// so a logged run makes a server count in a window whose rows are purged; its coverage then starts after the
+ /// window ends and there is none to report), or when the probe fails (a failed probe costs the panel its notice,
+ /// never its chart; a caller that cancelled still sees the cancellation). Coverage is read per server for the relations
+ /// the panel read (): the later of the server's first collection and the table's
+ /// retention edge, moved earlier by any row it holds in the window; for a source the schedule gives no edge
+ /// (a rollup, the raw relations the gated purge owns, a baseline-floored collector) the oldest row the server
+ /// holds at or before the window's end (). It names no cause: retention and a
+ /// server added last week truncate the same way.
+ /// The same slack the Queries tab's "Showing since" banner uses
+ /// decides, so the two surfaces call the same window cut.
+ ///
+ /// A probe that fails is reported through at Debug, with the exception: the panel
+ /// is still answered (only its notice is lost), and this runs once per panel run, so a standing fault at Warning
+ /// would put a line in the service log on every refresh of every panel. That is also the level, and the logger,
+ /// the runner's read-latency recording uses for a failure that never fails the request. Null reports nothing.
+ ///
+ internal static async Task BuildDataStartNoticeAsync(
+ NpgsqlDataSource postgres, string sourceTable, ComposeRoute route, IReadOnlyList? servers,
+ DateTime windowStartUtc, DateTime windowEndUtc, int commandTimeoutSeconds, CancellationToken cancellationToken,
+ ILogger? logger = null)
+ {
+ var sources = DataStartSources(sourceTable, route);
+ if (sources.Count == 0)
+ {
+ return null;
+ }
+
+ DateTime? dataStart;
+ try
+ {
+ dataStart = await DataWindowFloor.GetAsync(postgres, sources, servers, windowStartUtc, windowEndUtc, commandTimeoutSeconds, cancellationToken);
+ }
+ catch (Exception ex) when (!cancellationToken.IsCancellationRequested)
+ {
+ logger?.LogDebug(ex, "The data-start probe for a composed panel over {SourceTable} failed; the panel is answered without a partial-window notice.", sourceTable);
+ return null;
+ }
+
+ return RawWindowFloor.IsTruncated(dataStart, windowStartUtc)
+ ? BuildDataStartNotice(dataStart!.Value, windowStartUtc, windowEndUtc)
+ : null;
+ }
+
+ /// The text of 's notice: where the data starts, where the
+ /// window started, and the window the panel really covers, all in UTC.
+ internal static string BuildDataStartNotice(DateTime dataStartUtc, DateTime windowStartUtc, DateTime windowEndUtc) =>
+ string.Create(
+ CultureInfo.InvariantCulture,
+ $"partial window: this panel's data starts at {dataStartUtc:yyyy-MM-dd HH:mm} UTC, after the window's start at {windowStartUtc:yyyy-MM-dd HH:mm} UTC. The panel covers {dataStartUtc:yyyy-MM-dd HH:mm} to {windowEndUtc:yyyy-MM-dd HH:mm} UTC.");
+
+ ///
+ /// The relations a compiled panel read, as data-start probe sources: the raw table on a raw route (none when
+ /// the probe cannot read it by index, ), or each rollup
+ /// a rollup route's FROM clause names, both halves when the route stitches a superseded rollup to its
+ /// successor. Read off the FROM clause the router built (, set on every
+ /// rollup route, the day-grain daily included), so the probe asks about exactly what the panel read, and each
+ /// name is checked against the rollup registry () before the
+ /// probe splices it. Each half is probed within the bounds the route reads it. The superseded half is probed
+ /// whole: the route reads it below the stitch boundary, and its oldest bucket is where the stitched read starts.
+ /// The successor is probed from the boundary up (, the value the
+ /// router carried, never read out of the clause text), because that is all the route reads of it: a bucket it
+ /// holds below the boundary (a wide refresh after the boundary was measured) cannot move the start earlier.
+ /// The successor is the clause's relation the registry names as another one's successor, so the bound does not
+ /// depend on the order the clause lists them in. A rollup route without a FROM clause is a router defect, and
+ /// throws: there is no relation name to fall back to that is known to be the one the panel read.
+ ///
+ internal static IReadOnlyList DataStartSources(string sourceTable, ComposeRoute route)
+ {
+ if (!route.IsCagg)
+ {
+ return DataWindowFloor.Source.TryForCollectorTable(sourceTable, out var raw) ? [raw] : [];
+ }
+
+ var fromClause = route.CaggFromClause
+ ?? throw new ArgumentException("a rollup route carries the FROM clause the router built; the data-start probe reads the relations it names off that clause.", nameof(route));
+ var relations = s_collectRelation.Matches(fromClause).Select(m => m.Groups[1].Value).Distinct(StringComparer.Ordinal).ToList();
+ var sources = new List();
+ foreach (var relation in relations)
+ {
+ DateTime? lowerBound = route.StitchBoundaryUtc is { } boundary && IsSuccessorOfAnother(relation, relations) ? boundary : null;
+ if (DataWindowFloor.Source.TryForRollup(relation, out var rollup, lowerBound))
+ {
+ sources.Add(rollup);
+ }
+ }
+
+ return sources;
+ }
+
+ /* The registry's pairs: an hourly legacy and its successor (TimescaleSupport.SuccessorOf), a daily legacy and its
+ successor daily (TimescaleSupport.SupersededDailyRollups). */
+ private static bool IsSuccessorOfAnother(string relation, IReadOnlyList clauseRelations) =>
+ clauseRelations.Any(other =>
+ string.Equals(TimescaleSupport.SuccessorOf(other), relation, StringComparison.Ordinal)
+ || TimescaleSupport.SupersededDailyRollups.Any(p =>
+ string.Equals(p.LegacyDaily, other, StringComparison.Ordinal) && string.Equals(p.SuccessorDaily, relation, StringComparison.Ordinal)));
+
+ private static readonly Regex s_collectRelation =
+ new(@"\b" + Regex.Escape(PgSchemaGenerator.CollectSchema) + @"\.([a-z_][a-z0-9_]*)\b", RegexOptions.CultureInvariant);
+
///
/// The row-cap sibling of (#1687): a time-series panel whose
/// (buckets × groups) product exceeds is truncated by the
diff --git a/Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs b/Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs
index 44c7d685ff..ed6455f8e6 100644
--- a/Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs
+++ b/Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs
@@ -1114,7 +1114,7 @@ run_custom_view_panel tool -- both of which call this ONE runner -- contribute e
a bucket update is the only work in the try, and any failure there is swallowed and logged at
Debug, exactly like the web loop's own recording. */
var stopwatch = Stopwatch.StartNew();
- var outcome = await RunComposedPanelCoreAsync(postgres, body, cancellationToken);
+ var outcome = await RunComposedPanelCoreAsync(postgres, body, cancellationToken, readLatency?.Logger);
RecordComposeLatency(readLatency, body, outcome, stopwatch.ElapsedMilliseconds, cancellationToken);
return outcome;
}
@@ -1179,9 +1179,12 @@ the caller's own token already explains it. */
}
/// The compile-and-run body wraps with latency recording --
- /// unchanged from before #4442 scope 2 added the wrapper.
+ /// unchanged from before #4442 scope 2 added the wrapper. is the one the host's
+ /// read-latency recorder carries (), where the data-start probe reports
+ /// a failure it swallows.
private static async Task RunComposedPanelCoreAsync(
- NpgsqlDataSource postgres, JsonObject body, System.Threading.CancellationToken cancellationToken)
+ NpgsqlDataSource postgres, JsonObject body, System.Threading.CancellationToken cancellationToken,
+ ILogger? logger = null)
{
if (body["panel"] is not JsonObject panel)
{
@@ -1313,8 +1316,20 @@ returning a short slice of what they asked for. Additive — absent when the win
ends — retention loses the oldest points, the row cap now drops them too by keeping the
newest buckets — and a panel can genuinely hit both at once. Showing one and swallowing
the other would under-report exactly the panel that is worst off. */
+ var retentionNotice = ComposeStoreAvailability.BuildRetentionNotice(plan!.Measure.SourceTable, compiled.Route, start, now, rollups, coverage);
+
+ /* The tier notice judges a tier by the store-wide floors, so it is silent for a source with no rollups,
+ a store with no TimescaleDB, and one server whose rows start later than the rest. When it is silent,
+ ask where this panel's own rows start. The Query Store wide table reports its own start in the
+ history note below, so a panel it served is not asked twice. */
+ if (retentionNotice is null && !queryStoreWideEligible)
+ {
+ retentionNotice = await ComposeStoreAvailability.BuildDataStartNoticeAsync(
+ postgres, plan.Measure.SourceTable, compiled.Route, serverScope, start, end, composedQuerySeconds, cancellationToken, logger);
+ }
+
if (ComposeStoreAvailability.CombineNotices(
- ComposeStoreAvailability.BuildRetentionNotice(plan!.Measure.SourceTable, compiled.Route, start, now, rollups, coverage),
+ retentionNotice,
ComposeStoreAvailability.BuildRowCapNotice(plan.Mode, rows.Count)) is string notice)
{
payload["notice"] = notice;
diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpCustomViewTools.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpCustomViewTools.cs
index 56c02a787b..af07f8c68b 100644
--- a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpCustomViewTools.cs
+++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpCustomViewTools.cs
@@ -243,13 +243,15 @@ public static async Task DeleteCustomView(
[McpServerTool(Name = "run_custom_view_panel"), Description(
"Runs one composed (v2) panel and returns DATA: {sql, rows, annotations, notice?}, with no 'status' " +
"field on success. Failures return {status, message}: \"invalid\" for a bad spec or panel, or a " +
- "failed or timed-out query; \"error\" for an internal fault. notice means retention covered only " +
- "part of the window, or the row cap truncated the result; absent means neither happened. Window ends " +
- "now: 'hours' (default 24), unless ISO-8601 'windowStart'+'windowEnd' win instead (max 90 days; old " +
- "windows read rollups). 'server' omitted or \"All\" runs the whole fleet. Only 'panel' is " +
- "required. <> " +
+ "failed or timed-out query; \"error\" for an internal fault. notice is a partial-window caveat: the " +
+ "tier's retention could not cover the window, or the panel's own data starts after the window does, " +
+ "or the row cap truncated the result; absent means the window was served whole. Window ends now: " +
+ "'hours' (default 24), unless ISO-8601 'windowStart'+'windowEnd' win. Only 'panel' is required. " +
+ "<> " +
"Runs a single composed (v2) panel and returns the DATA it produces — {sql, rows, annotations, notice?} " +
- "(notice = a partial-window caveat when the store's retention cannot cover the whole requested range) — " +
+ "(notice is a partial-window caveat: the tier's retention could not cover the window, or the panel's own " +
+ "data starts after the window does, or the row cap truncated the result; absent means the window was " +
+ "served whole) — " +
"so a generated view can be checked end-to-end without saving it. This is the SAME compile-and-run the " +
"web composer's live preview uses: the panel is validated, compiled to catalog-only bound SQL, and " +
"executed against the collected store under a statement_timeout. The spec is a JSON object " +
diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpDataTools.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpDataTools.cs
index 95c8571fe1..f597e5773d 100644
--- a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpDataTools.cs
+++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpDataTools.cs
@@ -1044,7 +1044,9 @@ that was served rather than echo the one that was asked for. */
else
{
var floor = await DarlingDataReader.GetQueryStoreWindowFloorAsync(postgres, resolved.ServerId, requestedStart, now, cancellationToken);
- effectiveStart = floor ?? requestedStart;
+ /* The floor can sit before the requested start when the rows reach back past the window; the
+ shared helper never reports a served start earlier than the one asked for. */
+ effectiveStart = RawWindowFloor.EffectiveStart(floor, requestedStart);
/* #4231: the shared helper's own boundary, not a bare 90-minute literal restated here. */
truncated = RawWindowFloor.IsTruncated(floor, requestedStart);
}
diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpQueryStoreClutterTools.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpQueryStoreClutterTools.cs
index 1607962702..07c5011887 100644
--- a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpQueryStoreClutterTools.cs
+++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpQueryStoreClutterTools.cs
@@ -83,11 +83,6 @@ public sealed class DarlingMcpQueryStoreClutterTools
/// so the default page is usually the whole list; truncated says when it is not.
public const int DefaultLimit = 50;
- /// The window floor's tolerance: the raw tier's first row inside the window is allowed to sit
- /// this far after the requested start before the window is called truncated — get_query_store_top's
- /// 90 minutes, the cadence slack between a request and the first collection that could have served it.
- private static readonly TimeSpan WindowFloorTolerance = TimeSpan.FromMinutes(90);
-
/// The sentence beside query_capture_mode, naming what a null there means. A null is a
/// health row captured before the V137 rung (#3796) created the column — the mode was never asked for —
/// and a reader must not read it as NONE, which is a mode the engine really can be in.
@@ -143,8 +138,10 @@ the clerk are not consulted here — a server with QDS waits and no Query Store
/* #2364 / #3653 item 17: what the raw tier actually held, beside what was asked for. The
plan-churn arm is raw-only (the rollups carry no plan_id), so its floor is the window's. */
var floor = await DarlingDataReader.GetQueryStoreWindowFloorAsync(postgres, resolved.ServerId, requestedStart, now, cancellationToken);
- var effectiveStart = floor ?? requestedStart;
- var windowTruncated = floor is DateTime f && f > requestedStart + WindowFloorTolerance;
+ /* The floor can sit before the requested start when the rows reach back past the window; the shared
+ helper never reports a served start earlier than the one asked for. */
+ var effectiveStart = RawWindowFloor.EffectiveStart(floor, requestedStart);
+ var windowTruncated = RawWindowFloor.IsTruncated(floor, requestedStart);
var discontinuities = await DarlingTrendReader.GetBaselineDiscontinuitiesAsync(postgres, resolved.ServerId, requestedStart, now, cancellationToken);
diff --git a/Darling/PerformanceMonitor.Darling.Service/ReadLatencyRecorder.cs b/Darling/PerformanceMonitor.Darling.Service/ReadLatencyRecorder.cs
index 4b645326e8..31c2a0aa9f 100644
--- a/Darling/PerformanceMonitor.Darling.Service/ReadLatencyRecorder.cs
+++ b/Darling/PerformanceMonitor.Darling.Service/ReadLatencyRecorder.cs
@@ -34,6 +34,8 @@ public ReadLatencyRecorder(ReadLatencyAccumulator? readLatency, ILogger? logger)
/// Where a sample goes; null records nothing.
internal ReadLatencyAccumulator? Accumulator { get; }
- /// Where a recording failure is reported, at Debug; null reports nothing.
+ /// Where a recording failure is reported, at Debug; null reports nothing. The composed-panel runner
+ /// reports through it too, at the same level, a data-start probe it swallows (the panel is answered without
+ /// its notice).
internal ILogger? Logger { get; }
}
diff --git a/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/compose.js b/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/compose.js
index cbc405626a..f2545f5ea1 100644
--- a/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/compose.js
+++ b/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/compose.js
@@ -257,8 +257,10 @@ export async function renderComposedInto(body, panelSpec, scope, opts = {}) {
`scope`, so it is resolved here (where scope + zoom both live) and threaded through opts. */
const chartWindow = resolveChartWindow(panelSpec, scope, opts.zoom);
const nodes = [renderComposedResult(data, panelSpec, { ...opts, annotationMeta, chartWindow })];
- /* The run endpoint's partial-window notice (#1665): the chosen tier could not retain the whole
- requested window on this store — good data, honestly caveated, above the chart. */
+ /* The run endpoint's partial-window notice (#1665, #4953): a caveat that the panel did not cover the whole
+ requested window. The chosen tier's retention could not cover it, OR the panel's own data starts after
+ the window does, OR the row cap truncated the result. Good data, honestly caveated, above the chart.
+ No notice means the window was served whole. */
if (typeof data.notice === "string" && data.notice) nodes.unshift(noticeStrip(data.notice));
mount(body, nodes);
} catch (e) {
diff --git a/Darling/PerformanceMonitor.Darling.Storage/DataWindowFloor.cs b/Darling/PerformanceMonitor.Darling.Storage/DataWindowFloor.cs
new file mode 100644
index 0000000000..1893ea52d1
--- /dev/null
+++ b/Darling/PerformanceMonitor.Darling.Storage/DataWindowFloor.cs
@@ -0,0 +1,401 @@
+/*
+ * 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 System.Threading;
+using System.Threading.Tasks;
+using Npgsql;
+using NpgsqlTypes;
+using PerformanceMonitor.Collectors;
+
+namespace PerformanceMonitor.Darling.Storage;
+
+///
+/// Where a read's coverage starts: the instant from which the store holds everything the table's collector wrote for
+/// the servers in scope. A chart over 30 days of a table that keeps 7 draws 7 days; this is the instant its caller
+/// compares with the window's start () to say so. The cause does not matter
+/// here: retention and a server added last week look the same, and none is guessed.
+///
+/// Why coverage, not the oldest row. waiting_tasks and query_snapshots hold a row only while something
+/// waits or runs. A server idle overnight, whose older rows the purge dropped, has its first row hours after a 7-day
+/// window starts, and a server whose first waiting task came 3 days ago has none before that, though the store
+/// covered both windows whole. So each server's coverage starts at the later of two instants, and its rows only
+/// ever move that earlier:
+///
+/// Its first collection: the registry's created_date, the server's first successful connect. The
+/// service writes it once and no purge touches collect.servers, so it outlives every table. collection_log is
+/// not used for it: it keeps 60 days, and a fleet override can keep a table longer, so the log can forget a first
+/// run the table still covers. Every collector runs after the first connect, so this never comes after the real
+/// first collection; a collector turned on later reads as covered from the connect (no notice, never a false
+/// one).
+/// The table's edge: where the table's own rows start. For a collector table the schedule governs
+/// as written, it is the purge's cutoff: the probe's clock less the collector's fleet retention days (the fleet
+/// override in config.config_collector_schedules, else , as
+/// resolves it). The purge applies one horizon
+/// to the whole table (per-server overrides never reach it), so this is the same for every server, and it does
+/// not depend on a row or a chunk surviving near it. The schedule gives no edge to three kinds of source: a
+/// rollup (the tier notice measures each tier's reach), the raw relations the gated purge owns, and the
+/// collectors whose purge horizon is floored at the baseline window
+/// (). For those the edge is the oldest row
+/// the server holds at or before the window's end, the rule this probe applied before it read coverage, and it
+/// stands for the server's whole coverage (a row proves the store held the server from that row on; the first
+/// collection serves only a server with no row at all). That is right for these tables because they are dense:
+/// every collection, or every hour of a rollup, writes a row for a server that is running, so the oldest row is
+/// where the table's coverage starts and a quiet start is not in play. Reading coverage from the first
+/// collection alone would give an old server no notice at all, however long before anything the table keeps the
+/// window starts; and on the web the tier notice covers a rollup route on a TimescaleDB store, but not these
+/// collectors, and not a store without TimescaleDB, so these panels would lose the notice they had. That keeps
+/// a walk to the table's oldest chunk (see ).
+/// Its first row in the window: a row proves the store covered the server from that row on.
+///
+///
+/// Which servers count. A server counts when it holds a row in [start, end] or, for a collector
+/// table, when collection_log records a run of the collector for it in [start, end]. A server stopped or removed
+/// before the window (its registry row and history stay) does not count, so its old rows cannot hide a newer
+/// server's late start on a fleet panel. The answer is the earliest coverage start among the servers that count,
+/// and null when none counts: the window holds nothing, and an empty panel already says so. It is null too when
+/// that start is at or after the window's end (): collection_log outlives a table the purge
+/// keeps shorter, so a logged run makes a server count in a window whose rows are gone, and its coverage, the
+/// purge's edge, starts after the window ended.
+///
+/// Why per server, through collect.servers. Every collect table is indexed on
+/// (server_id, time), every continuous aggregate a panel reads keeps (server_id, bucket), and
+/// collection_log keeps (server_id, collector_name, collection_time). A LATERAL per registered server reads
+/// the first index entry inside the window, so only the window's chunks are probed; the walk to a source with no
+/// schedule edge runs only for a server that counts, and stops at the oldest chunk that holds the server.
+/// Filtering the fact table by server_name, as a panel's own read does, has no index to use. So the probe
+/// answers per server identity, through the registry's current name and server_id, while the panel's read
+/// filters fact rows by their stored server_name: for a renamed server, rows written before the rename
+/// count toward coverage even though the panel's filter misses them.
+///
+/// Why this lives in Storage. The web viewer's Custom Views runner and the desktop viewer's server tab
+/// both ask it, and the viewer does not reference the service assembly (#1661 / #2530), the same reason
+/// lives here.
+///
+public static class DataWindowFloor
+{
+ ///
+ /// One relation the probe reads, with the column its rows are stamped on. Built only by the factories, which
+ /// check the name against the collector catalog or the rollup registry, so nothing outside those two lists is
+ /// ever spliced into the probe's SQL.
+ ///
+ public sealed class Source
+ {
+ private Source(
+ string relation, string timeColumn, bool endExclusive, string? collectorName = null, int? retentionDefaultDays = null,
+ DateTime? lowerBoundUtc = null)
+ {
+ Relation = relation;
+ TimeColumn = timeColumn;
+ EndExclusive = endExclusive;
+ CollectorName = collectorName;
+ RetentionDefaultDays = retentionDefaultDays;
+ LowerBoundUtc = lowerBoundUtc;
+ }
+
+ ///
+ /// The instant (naive UTC) from which a panel reads this relation, when it reads only from there up: the
+ /// successor half of a stitched rollup read, which the stitched clause takes from the stitch boundary up.
+ /// Null when the panel reads the relation whole. The window read and the oldest-row walk both start at the
+ /// bound, so a bucket the relation holds below it (a wide refresh after the boundary was measured), which the
+ /// panel did not read, moves neither the answer nor a server's coverage. Bound as a parameter, never spliced
+ /// (). Only a rollup carries one (), and a rollup has no
+ /// schedule edge, so the bound always applies to the walk.
+ ///
+ public DateTime? LowerBoundUtc { get; }
+
+ ///
+ /// The collector that writes the table, whose runs collection_log records; null for a rollup, which no
+ /// collector writes.
+ ///
+ public string? CollectorName { get; }
+
+ ///
+ /// The collector's default retention days, when the probe reads the table's retention edge from the
+ /// schedule; null when the schedule gives no edge (a rollup, a raw relation the gated purge owns, a
+ /// collector whose purge horizon is floored at the baseline window), and the probe walks to the oldest row
+ /// the server holds at or before the window's end instead.
+ ///
+ public int? RetentionDefaultDays { get; }
+
+ /// The unqualified collect.* relation name.
+ public string Relation { get; }
+
+ /// The column the probe orders and bounds on.
+ public string TimeColumn { get; }
+
+ ///
+ /// True for a rollup: a bucket is stamped at its START, so a bucket at the window's end lies after the
+ /// window, the same end-exclusive rule the Custom Views compiler applies to a rollup read.
+ ///
+ public bool EndExclusive { get; }
+
+ ///
+ /// The raw collector table , or false when the catalog does not list it or its
+ /// index does not lead with (server_id, time) (). The
+ /// config snapshots have no index, and index_object_stats orders a server's rows by object before time,
+ /// so the probe would read every row the server holds; neither is probed.
+ ///
+ public static bool TryForCollectorTable(string table, out Source source)
+ {
+ source = null!;
+ var schema = CollectorCatalog.All.FirstOrDefault(c => string.Equals(c.TargetTable, table, StringComparison.Ordinal));
+ if (schema is null
+ || PgSchemaGenerator.CreateIndex(schema)?.EndsWith($"(server_id, {schema.PrefixTimeColumnName});", StringComparison.Ordinal) != true)
+ {
+ return false;
+ }
+
+ /* The schedule's horizon is the purge's only where the daily sweep applies it as written: the gated
+ purge owns the raw relations under TimescaleDB, and the baseline-serving collectors are floored at
+ the baseline window. */
+ int? retentionDefaultDays =
+ !TimescaleSupport.RawRelations.Contains(schema.TargetTable)
+ && !DarlingRetentionHorizons.BaselineServingRawCollectors.Contains(schema.Name)
+ && CollectorScheduleDefaults.All.TryGetValue(schema.Name, out var schedule)
+ ? schedule.RetentionDays
+ : null;
+ source = new Source(schema.TargetTable, schema.PrefixTimeColumnName, endExclusive: false, schema.Name, retentionDefaultDays);
+ return true;
+ }
+
+ /// , throwing for a table it refuses.
+ public static Source ForCollectorTable(string table) =>
+ TryForCollectorTable(table, out var source)
+ ? source
+ : throw new ArgumentException($"'{table}' is not a collector table the data-start probe can read.", nameof(table));
+
+ ///
+ /// The continuous aggregate , or false when does not
+ /// know the name. Every rollup a panel reads keeps a (server_id, bucket) index.
+ /// is set for a rollup the panel reads only from that instant up
+ /// (), and null for one it reads whole.
+ ///
+ public static bool TryForRollup(string view, out Source source, DateTime? lowerBoundUtc = null)
+ {
+ source = null!;
+ if (string.IsNullOrEmpty(view) || !RollupAvailability.All.Has(view))
+ {
+ return false;
+ }
+
+ source = new Source(view, "bucket", endExclusive: true, lowerBoundUtc: lowerBoundUtc);
+ return true;
+ }
+ }
+
+ /// Which servers the probe asks about.
+ public enum Scope
+ {
+ /// Every registered server: a fleet-wide panel.
+ Fleet,
+
+ /// The servers named in $4 (text[]): a panel scoped by name.
+ ServerNames,
+
+ /// The one server in $4 (int): the desktop viewer's server tab.
+ ServerId,
+ }
+
+ ///
+ /// The probe over : $1 the window's end, $2 its start, $3 the probe's clock (all naive
+ /// UTC), $4 the scope's servers when names any. Several sources (the two halves of a
+ /// stitched rollup read) are probed side by side and the earliest answer wins. One arm per source, over the
+ /// servers that count (a row in [$2, $1], or a logged run of the collector there). For a source the schedule
+ /// governs: the later of the purge cutoff and the server's first collection, moved earlier by its first row in
+ /// the window. For one it does not (see ): the oldest row the server
+ /// holds at or before the window's end, a walk unbounded below that runs only for a server that counts, and
+ /// the server's first collection when it holds no row at all.
+ ///
+ /// A source that reads its relation only from an instant up () takes that
+ /// instant as a further parameter, naive UTC, numbered after the scope's ($4 for a fleet probe, $5 otherwise, then
+ /// one more for each such source in order), and starts both its window read and its walk there. Binding them is
+ /// the caller's: and add them in that order.
+ ///
+ public static string FloorSql(IReadOnlyList sources, Scope scope)
+ {
+ ArgumentNullException.ThrowIfNull(sources);
+ if (sources.Count == 0)
+ {
+ throw new ArgumentException("the data-start probe needs at least one source.", nameof(sources));
+ }
+
+ var scopeClause = scope switch
+ {
+ Scope.Fleet => null,
+ Scope.ServerNames => "s.server_name = ANY($4)",
+ Scope.ServerId => "s.server_id = $4",
+ _ => throw new ArgumentOutOfRangeException(nameof(scope), scope, "unknown data-start scope"),
+ };
+
+ var schema = PgSchemaGenerator.CollectSchema;
+ var sql = new StringBuilder();
+ sql.Append("SELECT MIN(u.t)\nFROM\n(\n");
+ var nextBoundParameter = scope == Scope.Fleet ? 4 : 5;
+ for (var i = 0; i < sources.Count; i++)
+ {
+ var source = sources[i];
+ if (i > 0)
+ {
+ sql.Append(" UNION ALL\n");
+ }
+
+ /* The collector name and the default days come from the collector catalog and its schedule table,
+ never from a caller, so splicing them is safe (Source's factories are the only constructors). */
+ var collector = source.CollectorName?.ToLowerInvariant();
+ var time = source.TimeColumn;
+ var endBound = source.EndExclusive ? " < $1" : " <= $1";
+
+ /* A relation the panel reads only from an instant up starts there, in the window read and in the walk, so
+ a row it holds below that instant (one the panel did not read) moves neither. The instant is a bind
+ parameter, numbered after the scope's; the caller adds the values in the same order. */
+ var lowerBound = source.LowerBoundUtc is null
+ ? null
+ : "$" + (nextBoundParameter++).ToString(System.Globalization.CultureInfo.InvariantCulture);
+
+ string coverage;
+ if (source.RetentionDefaultDays is int days && collector is not null)
+ {
+ /* The table's edge is the purge cutoff: the schedule's horizon is the purge's, as written. The later
+ of it and the server's first collection is where coverage starts, whatever rows survive near it. */
+ coverage = $"GREATEST($3 - make_interval(days => COALESCE((SELECT o.retention_days FROM config.config_collector_schedules AS o WHERE o.server_id IS NULL AND lower(o.collector_name) = '{collector}' AND o.retention_days >= 1 LIMIT 1), {days.ToString(System.Globalization.CultureInfo.InvariantCulture)})), s.created_date)";
+ }
+ else
+ {
+ /* No schedule edge (a rollup, a raw relation the gated purge owns, a baseline-floored collector): the
+ edge is the oldest row the server holds at or before the window's end, the rule before this probe
+ read coverage. Right for these dense tables, where a running server writes a row every collection
+ (every hour, for a rollup), so the oldest row IS where the table's coverage starts and a quiet
+ start is not in play; and these panels would otherwise lose the notice they had (the tier notice
+ covers a rollup route on a TimescaleDB store, not these collectors, and not a store without
+ TimescaleDB). Unbounded below, so the walk starts at the table's oldest chunk. It sits in the select
+ list, so it runs only for a server that survives the WHERE below (one that counts). With no row at
+ all, coverage falls back to the server's first collection. */
+ var walkLowerBound = lowerBound is null ? string.Empty : $" AND h.{time} >= {lowerBound}";
+ coverage = $"COALESCE((SELECT h.{time} FROM {schema}.{source.Relation} AS h WHERE h.server_id = s.server_id AND h.{time}{endBound}{walkLowerBound} ORDER BY h.{time} LIMIT 1), s.created_date)";
+ }
+
+ sql.Append(" SELECT LEAST(").Append(coverage).Append(", w.t) AS t\n");
+ sql.Append(" FROM ").Append(schema).Append(".servers AS s\n");
+ sql.Append(" LEFT JOIN LATERAL\n");
+ sql.Append(" (\n");
+ sql.Append(" SELECT f.").Append(time).Append(" AS t\n");
+ sql.Append(" FROM ").Append(schema).Append('.').Append(source.Relation).Append(" AS f\n");
+ sql.Append(" WHERE f.server_id = s.server_id\n");
+ sql.Append(" AND f.").Append(time).Append(" >= $2\n");
+ if (lowerBound is not null)
+ {
+ sql.Append(" AND f.").Append(time).Append(" >= ").Append(lowerBound).Append('\n');
+ }
+
+ sql.Append(" AND f.").Append(time).Append(endBound).Append('\n');
+ sql.Append(" ORDER BY f.").Append(time).Append('\n');
+ sql.Append(" LIMIT 1\n");
+ sql.Append(" ) AS w ON TRUE\n");
+ sql.Append(" WHERE (w.t IS NOT NULL");
+ if (collector is not null)
+ {
+ sql.Append("\n OR EXISTS (SELECT 1 FROM ").Append(schema).Append(".collection_log AS c WHERE c.server_id = s.server_id AND c.collector_name = '")
+ .Append(collector).Append("' AND c.collection_time >= $2 AND c.collection_time <= $1)");
+ }
+
+ sql.Append(")\n");
+ if (scopeClause is not null)
+ {
+ sql.Append(" AND ").Append(scopeClause).Append('\n');
+ }
+ }
+
+ sql.Append(") AS u");
+ return sql.ToString();
+ }
+
+ ///
+ /// Where coverage starts for the servers named in , or for every registered server
+ /// when it is null or empty: the earliest coverage start among the servers that hold a row, or logged a run, in
+ /// [, ]. At or before the start means the window is covered,
+ /// whether or not it holds rows. Null when no server in scope counts, which the caller reads as "nothing to
+ /// report": an empty panel already says so.
+ ///
+ /// Null too when that start is at or after : the window lies wholly before the
+ /// coverage, so there is none to report. The run log outlives a table the purge keeps shorter (a schedule-edge
+ /// source drops its rows after days, collection_log keeps weeks more), so a window whose rows are all
+ /// purged still counts for a server through the runs logged in it, and the coverage found then is the purge
+ /// edge, which comes after the window ends. A start past the window's own end would read as a notice that
+ /// "covers" a span running backwards, and as a "Showing since" banner naming a time after its own range.
+ ///
+ public static async Task GetAsync(
+ NpgsqlDataSource postgres, IReadOnlyList sources, IReadOnlyList? serverNames, DateTime startUtc, DateTime endUtc,
+ int commandTimeoutSeconds, CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(postgres);
+
+ var scoped = serverNames is { Count: > 0 };
+ await using var command = postgres.CreateCommand(FloorSql(sources, scoped ? Scope.ServerNames : Scope.Fleet));
+ command.CommandTimeout = commandTimeoutSeconds;
+ AddWindow(command, startUtc, endUtc);
+ if (scoped)
+ {
+ command.Parameters.Add(new NpgsqlParameter { NpgsqlDbType = NpgsqlDbType.Array | NpgsqlDbType.Text, Value = serverNames!.ToArray() });
+ }
+
+ AddLowerBounds(command, sources);
+
+ return CoverageWithin(await command.ExecuteScalarAsync(cancellationToken), endUtc);
+ }
+
+ ///
+ /// Where coverage of starts for one server: the desktop viewer's server-tab form of
+ /// , with the same answers, null included for a window wholly before the coverage (the run
+ /// log outlives the table, so a logged run there makes the server count though its rows are gone).
+ ///
+ public static async Task GetForServerAsync(
+ NpgsqlDataSource postgres, Source source, int serverId, DateTime startUtc, DateTime endUtc,
+ int commandTimeoutSeconds, CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(postgres);
+ ArgumentNullException.ThrowIfNull(source);
+
+ await using var command = postgres.CreateCommand(FloorSql([source], Scope.ServerId));
+ command.CommandTimeout = commandTimeoutSeconds;
+ AddWindow(command, startUtc, endUtc);
+ command.Parameters.AddWithValue(serverId);
+ AddLowerBounds(command, [source]);
+
+ return CoverageWithin(await command.ExecuteScalarAsync(cancellationToken), endUtc);
+ }
+
+ /* The probe's answer when it starts before the window's end, null otherwise (and for no answer at all). */
+ private static DateTime? CoverageWithin(object? value, DateTime endUtc) =>
+ value is DateTime start && start < endUtc ? start : null;
+
+ /* SpecifyKind(Unspecified), not the bare value: Npgsql infers timestamptz from Kind=Utc and PostgreSQL then
+ zone-shifts the bounds against the store's NAIVE timestamp columns (RawWindowFloor.GetAsync says the same). */
+ private static void AddWindow(NpgsqlCommand command, DateTime startUtc, DateTime endUtc)
+ {
+ command.Parameters.AddWithValue(DateTime.SpecifyKind(endUtc, DateTimeKind.Unspecified));
+ command.Parameters.AddWithValue(DateTime.SpecifyKind(startUtc, DateTimeKind.Unspecified));
+ command.Parameters.AddWithValue(DateTime.SpecifyKind(DateTime.UtcNow, DateTimeKind.Unspecified));
+ }
+
+ /* One parameter per source that reads its relation only from an instant up, in source order, after the scope's:
+ the numbering FloorSql gave them. Unspecified for the same reason as the window's bounds. */
+ private static void AddLowerBounds(NpgsqlCommand command, IReadOnlyList sources)
+ {
+ foreach (var source in sources)
+ {
+ if (source.LowerBoundUtc is DateTime lowerBound)
+ {
+ command.Parameters.AddWithValue(DateTime.SpecifyKind(lowerBound, DateTimeKind.Unspecified));
+ }
+ }
+ }
+}
diff --git a/Darling/PerformanceMonitor.Darling.Storage/RawWindowFloor.cs b/Darling/PerformanceMonitor.Darling.Storage/RawWindowFloor.cs
index 97a144dc78..a6fd8fcc53 100644
--- a/Darling/PerformanceMonitor.Darling.Storage/RawWindowFloor.cs
+++ b/Darling/PerformanceMonitor.Darling.Storage/RawWindowFloor.cs
@@ -27,10 +27,14 @@ namespace PerformanceMonitor.Darling.Storage;
/// window actually reached — the most expensive row in a month may have happened this morning. The floor is a
/// property of the TIER, not of the result set, and has to be asked for separately.
///
-/// Why a one-chunk probe. Bounded on both sides ($2 start, $3 end) exactly like the window it is
-/// asked about, so TimescaleDB prunes every chunk outside it and the scan stops at the first row of the oldest
-/// surviving chunk rather than reading the window. Unfiltered by database or module: the floor is the tier's,
-/// so a filter on the caller's real read must not narrow it.
+/// Why the floor is not bounded below. A floor found inside the window called a quiet start a cut: a
+/// server with older rows, quiet for the first hours of the window, read as holding less than the window. The
+/// floor is the oldest row at or before the window's end, the same question asks for
+/// the Custom Views panels. It is one walk of the table's (server_id, collection_time) index in time order,
+/// so TimescaleDB stops at the first row it meets instead of reading the retention window. It is still null when
+/// the window itself holds nothing: a bounded existence check, cheap the same way, keeps that answer for the
+/// callers that report an empty window. Unfiltered by database or module: the floor is the tier's, so a filter on
+/// the caller's real read must not narrow it.
///
/// Why this lives in Storage. The desktop viewer's Queries grids (ViewerDataService) and
/// the MCP tools (DarlingDataReader) both need the identical probe over the identical SQL, and the
@@ -58,22 +62,35 @@ public enum Table
};
///
- /// The probe SQL for — #2364's QueryStoreWindowFloorSql shape verbatim,
- /// the table name the only thing that varies. $1 server_id, $2/$3 window (naive UTC).
+ /// The probe SQL for , the table name the only thing that varies. $1 server_id,
+ /// $2/$3 window (naive UTC). The window's start bounds only the existence check, never the floor.
///
public static string FloorSql(Table table) => $"""
- SELECT MIN(collection_time)
- FROM {TableName(table)}
- WHERE server_id = $1
- AND collection_time >= $2
- AND collection_time <= $3
+ SELECT o.t
+ FROM
+ (
+ SELECT f.collection_time AS t
+ FROM {PgSchemaGenerator.CollectSchema}.{TableName(table)} AS f
+ WHERE f.server_id = $1
+ AND f.collection_time <= $3
+ ORDER BY f.collection_time
+ LIMIT 1
+ ) AS o
+ WHERE EXISTS
+ (
+ SELECT 1
+ FROM {PgSchemaGenerator.CollectSchema}.{TableName(table)} AS w
+ WHERE w.server_id = $1
+ AND w.collection_time >= $2
+ AND w.collection_time <= $3
+ )
""";
///
- /// The oldest collection_time actually has inside
- /// [, ] for . Null when the
- /// window holds nothing at all, which the caller reports as "nothing was read" rather than as an absence
- /// of activity.
+ /// The oldest collection_time holds for at or before
+ /// , so it can sit before when the rows reach back past the
+ /// window. Null when the window [, ] holds nothing at all,
+ /// which the caller reports as "nothing was read" rather than as an absence of activity.
///
/// The caller's deadline class — the MCP read deadline by default; the
/// viewer passes its interactive one.
@@ -97,8 +114,11 @@ PostgreSQL window read here follows (DarlingPgBlockingReader, DarlingPgXminReade
return value is DateTime dt ? dt : null;
}
- /// The window actually served: the floor when the tier had one, the requested start otherwise.
- public static DateTime EffectiveStart(DateTime? floor, DateTime requestedStartUtc) => floor ?? requestedStartUtc;
+ /// The window actually served: the floor when it sits after the requested start, the requested start
+ /// otherwise. A floor before the start means the rows reach back past the window, so the window was served
+ /// whole; the served start never precedes the one asked for.
+ public static DateTime EffectiveStart(DateTime? floor, DateTime requestedStartUtc) =>
+ floor is DateTime f && f > requestedStartUtc ? f : requestedStartUtc;
///
/// Whether the floor sits far enough past the requested start to call the window window_truncated —
diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.BlockingTrends.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.BlockingTrends.cs
index ecdad459f8..9a9e2199e9 100644
--- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.BlockingTrends.cs
+++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.BlockingTrends.cs
@@ -346,6 +346,19 @@ public async Task> GetLockWaitTrendAsync(
return items;
}
+ ///
+ /// Where this server's waiting_tasks coverage starts for the range, through the shared probe
+ /// (): the later of its first collection and the table's retention edge, or its
+ /// first row in the range if that is earlier. Current Waits compares it with the range's start and shows
+ /// "Showing since" when a custom range reaches back past what the store covers. A quiet stretch, when nothing
+ /// waited, does not raise it. Null when the range holds no row and no logged run, and when the range lies wholly
+ /// before the coverage (the run log outlives the table, so a logged run there makes the server count though its
+ /// rows are purged, and its coverage starts after the range ends).
+ ///
+ public Task GetWaitingTasksDataStartAsync(int serverId, DateTime startUtc, DateTime endUtc, CancellationToken cancellationToken = default) =>
+ DataWindowFloor.GetForServerAsync(_dataSource, DataWindowFloor.Source.ForCollectorTable("waiting_tasks"), serverId, startUtc, endUtc,
+ ViewerCommandDeadlines.CurrentInteractiveReadSeconds, cancellationToken);
+
///
/// Waiting-task total duration by wait type for one server over the window (Current Waits).
/// #4349: bucketed the same way as — see its remarks for
diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.QuerySnapshots.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.QuerySnapshots.cs
index dc5ab38d3d..fd96244e5b 100644
--- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.QuerySnapshots.cs
+++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.QuerySnapshots.cs
@@ -12,6 +12,7 @@
using System.Threading.Tasks;
using Npgsql;
using PerformanceMonitor.Common;
+using PerformanceMonitor.Darling.Storage;
namespace PerformanceMonitor.Darling.Viewer;
@@ -409,6 +410,19 @@ GROUP BY date_trunc('hour', collection_time)
ORDER BY bucket
""";
+ ///
+ /// Where this server's query_snapshots coverage starts for the range, through the shared probe
+ /// (): the later of its first collection and the table's retention edge, or its
+ /// first row in the range if that is earlier. The Active Queries tab compares it with the range's start and
+ /// shows "Showing since" when a custom range reaches back past what the store covers. Null when the range holds
+ /// no row and no logged run, and when the range lies wholly before the coverage (the run log outlives the table,
+ /// so a logged run there makes the server count though its rows are purged, and its coverage starts after the
+ /// range ends).
+ ///
+ public Task GetQuerySnapshotsDataStartAsync(int serverId, DateTime startUtc, DateTime endUtc, CancellationToken cancellationToken = default) =>
+ DataWindowFloor.GetForServerAsync(_dataSource, DataWindowFloor.Source.ForCollectorTable("query_snapshots"), serverId, startUtc, endUtc,
+ ViewerCommandDeadlines.CurrentInteractiveReadSeconds, cancellationToken);
+
/// Hourly Active-Queries slicer buckets over the window (Value = session count).
public async Task> GetActiveQuerySlicerDataAsync(
int serverId, DateTime startUtc, DateTime endUtc, IReadOnlyList? databaseNames = null, CancellationToken cancellationToken = default)
diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.ActiveQueries.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.ActiveQueries.cs
index 31e1fc8a10..74c445f07d 100644
--- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.ActiveQueries.cs
+++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.ActiveQueries.cs
@@ -73,18 +73,25 @@ read. Honor a pending deep-link window instead so a concurrent activation load u
if (_pendingActiveQueriesWindow is { } pending)
{
_pendingActiveQueriesWindow = null;
+ var pendingDataStartTask = _dataService.GetQuerySnapshotsDataStartAsync(_server.ServerId, pending.FromUtc, pending.ToUtc);
var (pendingTotalCount, pendingSnapshots) = await _dataService.GetLatestQuerySnapshotsAsync(_server.ServerId, pending.FromUtc, pending.ToUtc, databaseNames: SelectedDatabaseFilter);
_querySnapshotsFilterMgr!.UpdateData(pendingSnapshots);
LatestSnapshotIndicator.Text = pendingSnapshots.Count < pendingTotalCount
? $"{pending.Indicator} — Showing the newest 1,000 of {pendingTotalCount:N0}"
: pending.Indicator;
+ UpdateTruncationBanner(QuerySnapshotsTruncationBanner, await DataStartOrNullAsync(pendingDataStartTask, "Active Queries"), pending.FromUtc);
await LoadActiveQueriesSlicerAsync(pending.FromUtc.AddHours(-1), pending.ToUtc.AddHours(1));
return;
}
+ /* query_snapshots keeps 7 days by default, and a custom range can reach past it, or past a server added
+ since: say where coverage starts (the Queries tab's "Showing since" banner) rather than draw a shorter
+ range as if it were the whole. */
+ var dataStartTask = _dataService.GetQuerySnapshotsDataStartAsync(_server.ServerId, startUtc, endUtc);
var (totalCount, snapshots) = await _dataService.GetLatestQuerySnapshotsAsync(_server.ServerId, startUtc, endUtc, databaseNames: SelectedDatabaseFilter);
_querySnapshotsFilterMgr!.UpdateData(snapshots);
LatestSnapshotIndicator.Text = snapshots.Count < totalCount ? $"Showing the newest 1,000 of {totalCount:N0}" : "";
+ UpdateTruncationBanner(QuerySnapshotsTruncationBanner, await DataStartOrNullAsync(dataStartTask, "Active Queries"), startUtc);
await LoadActiveQueriesSlicerAsync(startUtc, endUtc);
}
@@ -101,9 +108,11 @@ private async void OnActiveQueriesSlicerChanged(object? sender, SlicerRangeEvent
{
try
{
+ var dataStartTask = _dataService.GetQuerySnapshotsDataStartAsync(_server.ServerId, e.StartUtc, e.EndUtc);
var (totalCount, snapshots) = await _dataService.GetLatestQuerySnapshotsAsync(_server.ServerId, e.StartUtc, e.EndUtc, databaseNames: SelectedDatabaseFilter);
_querySnapshotsFilterMgr!.UpdateData(snapshots);
LatestSnapshotIndicator.Text = snapshots.Count < totalCount ? $"Showing the newest 1,000 of {totalCount:N0}" : "";
+ UpdateTruncationBanner(QuerySnapshotsTruncationBanner, await DataStartOrNullAsync(dataStartTask, "Active Queries"), e.StartUtc);
}
catch (Exception ex)
{
@@ -184,6 +193,11 @@ private async void LatestSnapshot_Click(object sender, RoutedEventArgs e)
LatestSnapshotIndicator.Text = "Loading...";
var (batchTime, rows) = await _dataService.GetLatestQuerySnapshotBatchAsync(_server.ServerId, databaseNames: SelectedDatabaseFilter);
_querySnapshotsFilterMgr!.UpdateData(rows);
+ /* #4953: the grid now holds the newest stored batch, not the range the "Showing since" banner described, so the
+ banner comes down (collapsed, text cleared). The next range read raises it again through UpdateTruncationBanner.
+ A failed read never reaches this line: the range rows stay in the grid, and so does the banner that describes them. */
+ QuerySnapshotsTruncationBanner.Visibility = Visibility.Collapsed;
+ QuerySnapshotsTruncationBanner.Text = string.Empty;
LatestSnapshotIndicator.Text = batchTime.HasValue
? $"Latest snapshot: {ViewerTimeHelper.FormatForDisplay(batchTime.Value, "yyyy-MM-dd HH:mm:ss")}"
: "No snapshots stored";
@@ -327,7 +341,9 @@ internal async Task DeepLinkToActiveQueriesAsync(DateTime fromUtc, DateTime toUt
/// Switches to the Active Queries sub-tab and loads the grid + slicer for a narrow window — the target
/// of the heatmap drill-down. The suppress flag skips the sub-tab auto-refresh so it doesn't clobber
/// this filtered read (Lite's SelectActiveQueriesForDrillDown). The slicer window is padded ±1h so the
- /// hourly buckets still overlap a narrow drill window (Lite's LoadActiveQueriesSlicerAsync padding).
+ /// hourly buckets still overlap a narrow drill window (Lite's LoadActiveQueriesSlicerAsync padding). The
+ /// "Showing since" banner is set for the drill window too, as every other read of this grid sets it for the
+ /// window it draws (#4953); left alone it would keep describing the last range read.
///
private async Task NavigateToActiveQueriesForWindowAsync(DateTime fromUtc, DateTime toUtc, string indicator)
{
@@ -346,11 +362,18 @@ private async Task NavigateToActiveQueriesForWindowAsync(DateTime fromUtc, DateT
_suppressDrillDownAutoRefresh = false;
}
+ /* The drill window can start before what query_snapshots still holds (retention, or a server added since), and
+ the banner the last range read raised describes THAT range, so it could claim a cut this window does not have
+ or miss one it does: ask the probe about the window drawn here (fromUtc, not the toolbar's start), beside the
+ rows read, and set the banner once they are bound, as the deep-link branch of LoadActiveQueriesAsync does for
+ the same window (a deep link reaches both, and the two writes agree). */
+ var dataStartTask = _dataService.GetQuerySnapshotsDataStartAsync(_server.ServerId, fromUtc, toUtc);
var (totalCount, snapshots) = await _dataService.GetLatestQuerySnapshotsAsync(_server.ServerId, fromUtc, toUtc, databaseNames: SelectedDatabaseFilter);
_querySnapshotsFilterMgr!.UpdateData(snapshots);
LatestSnapshotIndicator.Text = snapshots.Count < totalCount
? $"{indicator} — Showing the newest 1,000 of {totalCount:N0}"
: indicator;
+ UpdateTruncationBanner(QuerySnapshotsTruncationBanner, await DataStartOrNullAsync(dataStartTask, "Active Queries"), fromUtc);
await LoadActiveQueriesSlicerAsync(fromUtc.AddHours(-1), toUtc.AddHours(1));
}
diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.Blocking.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.Blocking.cs
index 3ab5a57151..0ec0e25c6a 100644
--- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.Blocking.cs
+++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.Blocking.cs
@@ -173,14 +173,20 @@ v_deadlocks window as the count (deadlock_time in the window, plus the collectio
}
case BlockingCurrentWaitsSubTabIndex:
{
- using var readFanOut = ViewerReadFanOut.Of(2);
+ using var readFanOut = ViewerReadFanOut.Of(3);
+ /* waiting_tasks keeps 7 days by default, and a custom range can reach past it, or past a server
+ added since: say where coverage starts (the Queries tab's "Showing since" banner) rather than
+ draw a shorter range as if it were the whole. Both charts read waiting_tasks, so one banner
+ covers them. */
+ var dataStartTask = _dataService.GetWaitingTasksDataStartAsync(_server.ServerId, startUtc, endUtc);
var durationTask = _dataService.GetWaitingTaskTrendAsync(_server.ServerId, startUtc, endUtc);
var blockedTask = _dataService.GetBlockedSessionTrendAsync(_server.ServerId, startUtc, endUtc, databaseNames: SelectedDatabaseFilter);
var duration = await durationTask;
var blocked = await blockedTask;
RenderCurrentWaitsDurationChart(duration);
RenderCurrentWaitsBlockedChart(blocked);
+ UpdateTruncationBanner(CurrentWaitsTruncationBanner, await DataStartOrNullAsync(dataStartTask, "Current Waits"), startUtc);
break;
}
case BlockedProcessReportsSubTabIndex:
diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.Queries.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.Queries.cs
index 100ecd2841..0f87b7f6e8 100644
--- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.Queries.cs
+++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.Queries.cs
@@ -181,7 +181,7 @@ private async Task LoadTopQueriesAsync(DateTime startUtc, DateTime endUtc)
ViewerDataService.GetTopQueriesByCpuTierAsync) — the raw-floor banner (#4231 stage 1/2) and this
tier disclosure are independent facts, so both may show at once (a window aged past raw AND
routed to hourly). */
- UpdateTruncationBanner(QueryStatsTruncationBanner, await floorTask, startUtc, tier == "hourly" ? HourlyTierSuffix : null);
+ UpdateTruncationBanner(QueryStatsTruncationBanner, await DataStartOrNullAsync(floorTask, "Query Stats"), startUtc, tier == "hourly" ? HourlyTierSuffix : null);
await LoadQueryStatsSlicerAsync(startUtc, endUtc);
await RefreshQueryStatsComparisonAsync(startUtc, endUtc);
}
@@ -198,7 +198,7 @@ private async Task LoadTopProceduresAsync(DateTime startUtc, DateTime endUtc)
SetDefaultSortIfNone(ProcedureStatsGrid, "TotalElapsedMs", ListSortDirection.Descending);
/* #4231 stage 3b: an hourly-routed page holds no object_type/sql_handle/plan_handle — the raw-floor
banner and this tier disclosure are independent facts, same reasoning as the Queries sub-tab. */
- UpdateTruncationBanner(ProcStatsTruncationBanner, await floorTask, startUtc, tier == "hourly" ? HourlyTierSuffix : null);
+ UpdateTruncationBanner(ProcStatsTruncationBanner, await DataStartOrNullAsync(floorTask, "Procedure Stats"), startUtc, tier == "hourly" ? HourlyTierSuffix : null);
await LoadProcStatsSlicerAsync(startUtc, endUtc);
await RefreshProcStatsComparisonAsync(startUtc, endUtc);
}
@@ -213,7 +213,7 @@ ordinary read (M1) — null tells the gate this end is open. */
var (rows, widePlan) = await _dataService.GetQueryStoreTopQueriesWithReachAsync(_server.ServerId, startUtc, endUtc, databaseNames: SelectedDatabaseFilter, literalEndUtc: IsCustomRange ? endUtc : null);
_queryStoreFilterMgr!.UpdateData(rows);
SetDefaultSortIfNone(QueryStoreGrid, "TotalDurationMs", ListSortDirection.Descending);
- UpdateTruncationBanner(QueryStoreTruncationBanner, await floorTask, startUtc, widePlan: widePlan);
+ UpdateTruncationBanner(QueryStoreTruncationBanner, await DataStartOrNullAsync(floorTask, "Query Store"), startUtc, widePlan: widePlan);
await LoadQueryStoreSlicerAsync(startUtc, endUtc);
await RefreshQueryStoreComparisonAsync(startUtc, endUtc);
}
@@ -223,8 +223,8 @@ ordinary read (M1) — null tells the gate this end is open. */
/// sits past 's slack after
/// , collapsed otherwise. The chart-title idiom
/// () states the same fact on Performance Trends; this is its grid-header
- /// form, in 's yyyy-MM-dd HH:mm, the format
- /// every trend chart title already uses for a head timestamp.
+ /// form, in yyyy-MM-dd HH:mm, the format every trend chart title already uses for a head timestamp, in the
+ /// display zone and on the invariant culture ().
///
internal static void UpdateTruncationBanner(TextBlock banner, DateTime? floor, DateTime requestedStartUtc, string? tierSuffix = null,
QueryStoreIntervalWide.WideReadPlan? widePlan = null)
@@ -241,13 +241,13 @@ named whether or not the grid itself was cut. */
if (wideTruncated || slicerTruncated || !string.IsNullOrEmpty(tierSuffix))
{
var slicerSince = slicerTruncated
- ? ViewerTimeHelper.FormatForDisplay(RawWindowFloor.EffectiveStart(floor, requestedStartUtc), "yyyy-MM-dd HH:mm")
+ ? BannerTime(RawWindowFloor.EffectiveStart(floor, requestedStartUtc))
: null;
var text = wideTruncated
- ? $"Showing since {ViewerTimeHelper.FormatForDisplay(wideStart, "yyyy-MM-dd HH:mm")}{QueryStoreIntervalWide.BannerReason(widePlan.Value.StartBound)}"
+ ? $"Showing since {BannerTime(wideStart)}{QueryStoreIntervalWide.BannerReason(widePlan.Value.StartBound)}"
+ (slicerSince is null ? string.Empty : $" · slicer since {slicerSince}")
: slicerSince is null
- ? $"Showing {ViewerTimeHelper.FormatForDisplay(requestedStartUtc, "yyyy-MM-dd HH:mm")}"
+ ? $"Showing {BannerTime(requestedStartUtc)}"
: $"Slicer since {slicerSince} (the grid shows the full window)";
banner.Text = text + tierSuffix;
@@ -272,11 +272,47 @@ named whether or not the grid itself was cut. */
}
banner.Text = truncated
- ? $"Showing since {ViewerTimeHelper.FormatForDisplay(RawWindowFloor.EffectiveStart(floor, requestedStartUtc), "yyyy-MM-dd HH:mm")}{tierSuffix}"
- : $"Showing {ViewerTimeHelper.FormatForDisplay(requestedStartUtc, "yyyy-MM-dd HH:mm")}{tierSuffix}";
+ ? $"Showing since {BannerTime(RawWindowFloor.EffectiveStart(floor, requestedStartUtc))}{tierSuffix}"
+ : $"Showing {BannerTime(requestedStartUtc)}{tierSuffix}";
banner.Visibility = Visibility.Visible;
}
+ ///
+ /// The instant a "Showing since" banner names, in the display zone with the ambiguous-hour offset suffix (the same
+ /// zone and suffix gives) but on the invariant
+ /// culture, as Lite's banner (DisplayZone.Format) and the web's notice are. A banner is one line of
+ /// disclosure, not a grid cell, and a machine whose default calendar is not the Gregorian one (Thai Buddhist,
+ /// Hijri) would otherwise print 2026 as 2569 or 1448. FormatForDisplay stays on the current culture on
+ /// purpose for the grids' own columns.
+ ///
+ private static string BannerTime(DateTime naiveUtc) =>
+ PerformanceMonitor.Ui.DisplayZone.Format(naiveUtc, ViewerTimeHelper.CurrentDisplayZone(), "yyyy-MM-dd HH:mm");
+
+ ///
+ /// Where a data-start probe says the data starts (Active Queries, Current Waits, and the Queries tab's Query Stats,
+ /// Procedure Stats and Query Store window-floor probes: the caller starts the probe before its grid read and awaits
+ /// it here), or null when the probe throws. The probe is only the "Showing since" disclosure, so its failure must
+ /// not unwind the refresh past the slicer, the comparison or the charts that follow it: the answer goes on to
+ /// , for which a null floor names no cut (the banner hides, unless the hourly-tier
+ /// suffix or the interval-table plan the Queries tab passes beside it still has something to say), the failure is
+ /// logged ( takes the log source and the message, by
+ /// default), and the refresh goes on.
+ ///
+ internal static async Task DataStartOrNullAsync(Task probe, string surface, Action? warn = null)
+ {
+ try
+ {
+ return await probe;
+ }
+ catch (Exception ex)
+ {
+ (warn ?? ViewerLogger.Warn)(
+ "ViewerServerTab",
+ $"{surface}: the data-start probe failed, so no \"Showing since\" banner is shown | {ex.GetType().Name}: {ex.Message}");
+ return null;
+ }
+ }
+
///
/// Loads the Query Store Regressions grid — the Dashboard's regressions view (baseline-vs-recent Query
/// Store performance) ported to Darling's store, over the toolbar's settable window. Like the Dashboard
@@ -324,7 +360,7 @@ private async void OnQueryStatsSlicerChanged(object? sender, SlicerRangeEventArg
var floorTask = _dataService.GetQueryStatsWindowFloorAsync(_server.ServerId, e.StartUtc, e.EndUtc);
var (rows, tier) = await _dataService.GetTopQueriesByCpuTierAsync(_server.ServerId, e.StartUtc, e.EndUtc, databaseNames: SelectedDatabaseFilter);
_queryStatsFilterMgr!.UpdateData(rows);
- UpdateTruncationBanner(QueryStatsTruncationBanner, await floorTask, e.StartUtc, tier == "hourly" ? HourlyTierSuffix : null);
+ UpdateTruncationBanner(QueryStatsTruncationBanner, await DataStartOrNullAsync(floorTask, "Query Stats"), e.StartUtc, tier == "hourly" ? HourlyTierSuffix : null);
await RefreshQueryStatsComparisonAsync(e.StartUtc, e.EndUtc);
}
catch (Exception ex)
@@ -349,7 +385,7 @@ private async void OnProcStatsSlicerChanged(object? sender, SlicerRangeEventArgs
var floorTask = _dataService.GetProcedureStatsWindowFloorAsync(_server.ServerId, e.StartUtc, e.EndUtc);
var (rows, tier) = await _dataService.GetTopProceduresByCpuTierAsync(_server.ServerId, e.StartUtc, e.EndUtc, databaseNames: SelectedDatabaseFilter);
_procStatsFilterMgr!.UpdateData(rows);
- UpdateTruncationBanner(ProcStatsTruncationBanner, await floorTask, e.StartUtc, tier == "hourly" ? HourlyTierSuffix : null);
+ UpdateTruncationBanner(ProcStatsTruncationBanner, await DataStartOrNullAsync(floorTask, "Procedure Stats"), e.StartUtc, tier == "hourly" ? HourlyTierSuffix : null);
await RefreshProcStatsComparisonAsync(e.StartUtc, e.EndUtc);
}
catch (Exception ex)
@@ -375,7 +411,7 @@ private async void OnQueryStoreSlicerChanged(object? sender, SlicerRangeEventArg
/* #3953 clause 4: a slicer selection is always a literal, user-drawn sub-range, never a preset. */
var rows = await _dataService.GetQueryStoreTopQueriesAsync(_server.ServerId, e.StartUtc, e.EndUtc, databaseNames: SelectedDatabaseFilter, literalEndUtc: e.EndUtc);
_queryStoreFilterMgr!.UpdateData(rows);
- UpdateTruncationBanner(QueryStoreTruncationBanner, await floorTask, e.StartUtc);
+ UpdateTruncationBanner(QueryStoreTruncationBanner, await DataStartOrNullAsync(floorTask, "Query Store"), e.StartUtc);
await RefreshQueryStoreComparisonAsync(e.StartUtc, e.EndUtc);
}
catch (Exception ex)
diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.xaml b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.xaml
index 61bb05e3ab..859f44dda3 100644
--- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.xaml
+++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.xaml
@@ -688,12 +688,19 @@
+
-
+
+
+
-
@@ -2463,13 +2470,20 @@
+
-
+
+
+
-
+
diff --git a/Lite.Tests/DataStartBannerProbeFailureTests.cs b/Lite.Tests/DataStartBannerProbeFailureTests.cs
new file mode 100644
index 0000000000..202882fdc1
--- /dev/null
+++ b/Lite.Tests/DataStartBannerProbeFailureTests.cs
@@ -0,0 +1,126 @@
+/*
+ * Copyright (c) 2026 Erik Darling, Darling Data LLC
+ *
+ * This file is part of the SQL Server Performance Monitor Lite.
+ *
+ * Licensed under the MIT License. See LICENSE file in the project root for full license information.
+ */
+
+using System;
+using System.IO;
+using System.Linq;
+using System.Runtime.CompilerServices;
+using System.Text.RegularExpressions;
+using System.Threading;
+using System.Threading.Tasks;
+using PerformanceMonitorLite.Controls;
+using PerformanceMonitorLite.Services;
+using Xunit;
+
+namespace PerformanceMonitorLite.Tests;
+
+///
+/// A data-start probe that throws (Active Queries, Current Waits, and the three Queries grids share one) costs its
+/// "Showing since" banner and nothing else. The probe runs before the Query Stats grid bind in the Queries refresh,
+/// and before the slicer loads and the tab-badge counts in the Locking refresh, so an uncaught failure skipped them.
+///
+/* AppLogger.DrainBufferedLines is destructive, so a class that reads it shares the one collection that serializes the
+ others that do. */
+[Collection("app-logger-statics")]
+public sealed class DataStartBannerProbeFailureTests
+{
+ private static readonly DateTime WindowStart = new(2026, 9, 1, 0, 0, 0, DateTimeKind.Utc);
+
+ [Fact]
+ public void AProbeThatThrows_HidesTheBanner_IsLogged_AndLetsTheRefreshGoOn()
+ {
+ var tag = "probe-" + Guid.NewGuid().ToString("N")[..8];
+ AppLogger.DrainBufferedLines();
+
+ var (visible, text, answer) = OnStaThread(() =>
+ {
+ /* Seeded visible, with the last read's text, so a helper that never touched the banner cannot pass. */
+ var banner = new System.Windows.Controls.TextBlock
+ {
+ Visibility = System.Windows.Visibility.Visible,
+ Text = "Showing since 2026-09-04 00:00:00",
+ };
+ /* What the refresh does: the guarded probe, then the banner step on its answer. */
+ var floor = ServerTab.ProbeWindowFloorOrNullAsync(
+ () => Task.FromException(new InvalidOperationException(tag)),
+ "[DataStartServer] QuerySnapshots").GetAwaiter().GetResult();
+ var result = ServerTab.ApplyWindowFloorToBanner(banner, floor, WindowStart, TimeZoneInfo.Utc);
+ return (banner.Visibility == System.Windows.Visibility.Visible, banner.Text, result);
+ });
+
+ Assert.False(answer);
+ Assert.False(visible);
+ Assert.Equal(string.Empty, text);
+ var logged = Assert.Single(AppLogger.DrainBufferedLines(), l => l.Contains(tag, StringComparison.Ordinal));
+ Assert.Contains("[WARN ]", logged, StringComparison.Ordinal);
+ Assert.Contains("[DataStartServer] QuerySnapshots", logged, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void AProbeThatAnswersAfterTheWindowStart_ShowsTheBanner_AndLogsNothing()
+ {
+ var tag = "probe-" + Guid.NewGuid().ToString("N")[..8];
+ AppLogger.DrainBufferedLines();
+
+ var (visible, text, answer) = OnStaThread(() =>
+ {
+ var banner = new System.Windows.Controls.TextBlock();
+ var floor = ServerTab.ProbeWindowFloorOrNullAsync(
+ () => Task.FromResult(WindowStart.AddDays(3)), tag).GetAwaiter().GetResult();
+ var result = ServerTab.ApplyWindowFloorToBanner(banner, floor, WindowStart, TimeZoneInfo.Utc);
+ return (banner.Visibility == System.Windows.Visibility.Visible, banner.Text, result);
+ });
+
+ Assert.True(answer);
+ Assert.True(visible);
+ Assert.Equal("Showing since 2026-09-04 00:00:00", text);
+ Assert.DoesNotContain(AppLogger.DrainBufferedLines(), l => l.Contains(tag, StringComparison.Ordinal));
+ }
+
+ ///
+ /// The shared banner refresh, the one place the probe is called, goes through the guarded probe, so every surface
+ /// that carries a banner (the three Queries grids, Active Queries, Current Waits) and every read path of each is
+ /// covered. Text-scans SOURCE, like QueryWindowTruncationTests.
+ ///
+ [Fact]
+ public void TheSharedBannerRefresh_GoesThroughTheGuardedProbe()
+ {
+ var source = File.ReadAllText(ControlsFile("ServerTab.Refresh.cs"));
+
+ Assert.Single(Regex.Matches(source,
+ @"private async System\.Threading\.Tasks\.Task RefreshWindowTruncatedBannerAsync\([^)]*\)\s*\{\s*var floor = await ProbeWindowFloorOrNullAsync\(\s*\(\) => Task\.Run\(\(\) => _dataService\.GetQueryWindowFloorAsync\(relation, _serverId, startUtc, endUtc\)\),[^;]*;\s*ApplyWindowFloorToBanner\(banner, floor, startUtc, GetPickerZone\(\)\);"));
+ }
+
+ /// WPF objects require STA, and a probe that has already completed keeps the continuation on this thread.
+ private static T OnStaThread(Func body)
+ {
+ T result = default!;
+ Exception? error = null;
+ var thread = new Thread(() =>
+ {
+ try { result = body(); }
+ catch (Exception ex) { error = ex; }
+ });
+ thread.SetApartmentState(ApartmentState.STA);
+ thread.Start();
+ thread.Join();
+
+ if (error is not null)
+ {
+ throw error;
+ }
+
+ return result;
+ }
+
+ private static string ControlsFile(string name) =>
+ Path.GetFullPath(Path.Combine(ControlsDir(), name));
+
+ private static string ControlsDir([CallerFilePath] string thisFile = "") =>
+ Path.GetFullPath(Path.Combine(Path.GetDirectoryName(thisFile)!, "..", "Lite", "Controls"));
+}
diff --git a/Lite.Tests/DataStartBannerTests.cs b/Lite.Tests/DataStartBannerTests.cs
new file mode 100644
index 0000000000..f94ebe7d7b
--- /dev/null
+++ b/Lite.Tests/DataStartBannerTests.cs
@@ -0,0 +1,405 @@
+/*
+ * Copyright (c) 2026 Erik Darling, Darling Data LLC
+ *
+ * This file is part of the SQL Server Performance Monitor Lite.
+ *
+ * Licensed under the MIT License. See LICENSE file in the project root for full license information.
+ */
+
+using System;
+using System.IO;
+using System.Linq;
+using System.Runtime.CompilerServices;
+using System.Text.RegularExpressions;
+using System.Threading;
+using System.Threading.Tasks;
+using DuckDB.NET.Data;
+using PerformanceMonitorLite.Controls;
+using PerformanceMonitorLite.Database;
+using PerformanceMonitorLite.Mcp;
+using PerformanceMonitorLite.Services;
+using Xunit;
+
+namespace PerformanceMonitorLite.Tests;
+
+///
+/// The Lite twin of #4953's "where the data starts" notice: Active Queries (query_snapshots) and Current
+/// Waits (waiting_tasks) say where their stored rows start when a window or custom range reaches further
+/// back than the store holds (retention, the 3-month archive's first month, or a server added recently), through
+/// the ONE shared probe () and the ONE banner step
+/// () the Queries grids already use. A server that holds a row
+/// before the window has had the window served whole (the probe answers the requested start), so a quiet start
+/// inside the window, with older rows in the store, raises no banner.
+///
+[Collection("server-time-helper")]
+public sealed class DataStartBannerTests : IDisposable
+{
+ private const int ServerId = 4242;
+ private const string ServerName = "DataStartServer";
+ private readonly string _tempDir;
+ private readonly DuckDbInitializer _duckDb;
+ private long _nextId = 1;
+
+ public DataStartBannerTests()
+ {
+ _tempDir = Path.Combine(Path.GetTempPath(), "DataStartBanner_" + Guid.NewGuid().ToString("N")[..8]);
+ Directory.CreateDirectory(_tempDir);
+ _duckDb = new DuckDbInitializer(Path.Combine(_tempDir, "test.duckdb"));
+ }
+
+ public void Dispose()
+ {
+ try { if (Directory.Exists(_tempDir)) Directory.Delete(_tempDir, recursive: true); }
+ catch { /* best-effort cleanup */ }
+ }
+
+ private static DateTime Naive(DateTime instant) => DateTime.SpecifyKind(instant, DateTimeKind.Unspecified);
+
+ private async Task SeedSnapshotAsync(DateTime at)
+ {
+ using var connection = _duckDb.CreateConnection();
+ await connection.OpenAsync();
+ using var readLock = _duckDb.AcquireReadLock();
+ using var cmd = connection.CreateCommand();
+ cmd.CommandText = @"
+INSERT INTO query_snapshots
+ (collection_id, collection_time, server_id, server_name, session_id, database_name, query_text, status, cpu_time_ms, total_elapsed_time_ms)
+VALUES ($1, $2, $3, $4, 55, 'Db', 'SELECT 1', 'running', 10, 20)";
+ cmd.Parameters.Add(new DuckDBParameter { Value = _nextId++ });
+ cmd.Parameters.Add(new DuckDBParameter { Value = Naive(at) });
+ cmd.Parameters.Add(new DuckDBParameter { Value = ServerId });
+ cmd.Parameters.Add(new DuckDBParameter { Value = ServerName });
+ await cmd.ExecuteNonQueryAsync();
+ }
+
+ private async Task SeedWaitingTaskAsync(DateTime at)
+ {
+ using var connection = _duckDb.CreateConnection();
+ await connection.OpenAsync();
+ using var readLock = _duckDb.AcquireReadLock();
+ using var cmd = connection.CreateCommand();
+ cmd.CommandText = @"
+INSERT INTO waiting_tasks (collection_id, collection_time, server_id, server_name, session_id, wait_type, wait_duration_ms, blocking_session_id, database_name)
+VALUES ($1, $2, $3, $4, 55, 'LCK_M_X', 3000, 60, 'Db')";
+ cmd.Parameters.Add(new DuckDBParameter { Value = _nextId++ });
+ cmd.Parameters.Add(new DuckDBParameter { Value = Naive(at) });
+ cmd.Parameters.Add(new DuckDBParameter { Value = ServerId });
+ cmd.Parameters.Add(new DuckDBParameter { Value = ServerName });
+ await cmd.ExecuteNonQueryAsync();
+ }
+
+ ///
+ /// The collector's runs in collection_log, every minutes from
+ /// to , whether or not anything waited or ran.
+ ///
+ private async Task SeedLogRunsAsync(string collector, DateTime firstUtc, DateTime lastUtc, int everyMinutes)
+ {
+ using var connection = _duckDb.CreateConnection();
+ await connection.OpenAsync();
+ using var readLock = _duckDb.AcquireReadLock();
+ using var cmd = connection.CreateCommand();
+ cmd.CommandText = $@"
+INSERT INTO collection_log (log_id, server_id, server_name, collector_name, collection_time, duration_ms, status, rows_collected)
+SELECT $1 + row_number() OVER (), $2, $3, $4, g.t, 12, 'SUCCESS', 0
+FROM generate_series($5::TIMESTAMP, $6::TIMESTAMP, INTERVAL {everyMinutes} MINUTE) AS g(t)";
+ cmd.Parameters.Add(new DuckDBParameter { Value = _nextId });
+ cmd.Parameters.Add(new DuckDBParameter { Value = ServerId });
+ cmd.Parameters.Add(new DuckDBParameter { Value = ServerName });
+ cmd.Parameters.Add(new DuckDBParameter { Value = collector });
+ cmd.Parameters.Add(new DuckDBParameter { Value = Naive(firstUtc) });
+ cmd.Parameters.Add(new DuckDBParameter { Value = Naive(lastUtc) });
+ _nextId += await cmd.ExecuteNonQueryAsync() + 1;
+ }
+
+ /// The probe, then the banner step the surface runs on the result: (visible, text).
+ private async Task<(bool Visible, string Text)> BannerForAsync(QueryWindowRelation relation, DateTime startUtc, DateTime endUtc)
+ {
+ var floor = await new LocalDataService(_duckDb).GetQueryWindowFloorAsync(relation, ServerId, startUtc, endUtc);
+ return OnStaThread(() =>
+ {
+ var banner = new System.Windows.Controls.TextBlock();
+ ServerTab.ApplyWindowFloorToBanner(banner, floor, startUtc, TimeZoneInfo.Utc);
+ return (banner.Visibility == System.Windows.Visibility.Visible, banner.Text);
+ });
+ }
+
+ ///
+ /// The relation list is a closed enum, so no view name ever comes from the caller. Each member must name a real
+ /// archive view (v_ plus a table in the archivable set) whose time column is collection_time, with
+ /// a server_id column, which is what the probe's SQL assumes.
+ ///
+ [Fact]
+ public async Task EveryRelation_NamesARealArchiveView_WithServerIdAndCollectionTime()
+ {
+ await _duckDb.InitializeAsync();
+ foreach (var relation in Enum.GetValues())
+ {
+ var view = LocalDataService.QueryWindowRelationView(relation);
+ Assert.StartsWith("v_", view, StringComparison.Ordinal);
+ var table = view[2..];
+ Assert.Contains(table, DuckDbInitializer.ArchivableTables);
+ Assert.Contains(ArchiveService.ArchivableTables, t => t.Table == table && t.TimeColumn == "collection_time");
+
+ using var connection = _duckDb.CreateConnection();
+ await connection.OpenAsync();
+ using var readLock = _duckDb.AcquireReadLock();
+ using var cmd = connection.CreateCommand();
+ cmd.CommandText = $"SELECT COUNT(*) FROM information_schema.columns WHERE table_name = '{view}' AND column_name IN ('server_id', 'collection_time')";
+ Assert.Equal(2L, Convert.ToInt64(await cmd.ExecuteScalarAsync()));
+ }
+ }
+
+ /// A custom range that starts before the oldest stored snapshot gets the banner, worded at that oldest snapshot.
+ [Fact]
+ public async Task ActiveQueries_RangeStartsBeforeTheOldestStoredSnapshot_ShowsTheBanner()
+ {
+ await _duckDb.InitializeAsync();
+ var end = DateTime.UtcNow;
+ var oldest = end.AddDays(-2);
+ await SeedSnapshotAsync(oldest);
+ await SeedSnapshotAsync(end.AddHours(-1));
+
+ var (visible, text) = await BannerForAsync(QueryWindowRelation.QuerySnapshots, end.AddDays(-7), end);
+
+ Assert.True(visible);
+ Assert.StartsWith("Showing since ", text, StringComparison.Ordinal);
+ Assert.Contains(Naive(oldest).ToString("yyyy-MM-dd HH:"), text, StringComparison.Ordinal);
+ }
+
+ /// Same for Current Waits (waiting_tasks), which holds a row only while something waits.
+ [Fact]
+ public async Task CurrentWaits_RangeStartsBeforeTheOldestStoredRow_ShowsTheBanner()
+ {
+ await _duckDb.InitializeAsync();
+ var end = DateTime.UtcNow;
+ await SeedWaitingTaskAsync(end.AddDays(-2));
+ await SeedWaitingTaskAsync(end.AddHours(-1));
+
+ var (visible, text) = await BannerForAsync(QueryWindowRelation.WaitingTasks, end.AddDays(-7), end);
+
+ Assert.True(visible);
+ Assert.StartsWith("Showing since ", text, StringComparison.Ordinal);
+ }
+
+ /// A window the stored snapshots fully cover (rows reach back past its start) shows no banner.
+ [Fact]
+ public async Task ActiveQueries_WindowInsideTheStoredRows_ShowsNoBanner()
+ {
+ await _duckDb.InitializeAsync();
+ var end = DateTime.UtcNow;
+ for (var day = 10; day >= 0; day--)
+ {
+ await SeedSnapshotAsync(end.AddDays(-day).AddMinutes(-5));
+ }
+
+ var (visible, text) = await BannerForAsync(QueryWindowRelation.QuerySnapshots, end.AddDays(-7), end);
+
+ Assert.False(visible);
+ Assert.Equal(string.Empty, text);
+ }
+
+ ///
+ /// The quiet-start guard on the sparse table: one old wait row, then nothing until two hours ago. The window's
+ /// own oldest row sits far past its start while the store holds older rows, so nothing is missing and there is no banner.
+ ///
+ [Fact]
+ public async Task CurrentWaits_QuietStartInsideTheWindow_WithOlderRowsBeforeIt_ShowsNoBanner()
+ {
+ await _duckDb.InitializeAsync();
+ var end = DateTime.UtcNow;
+ await SeedWaitingTaskAsync(end.AddDays(-27));
+ await SeedWaitingTaskAsync(end.AddHours(-2));
+
+ var (visible, text) = await BannerForAsync(QueryWindowRelation.WaitingTasks, end.AddDays(-7), end);
+
+ Assert.False(visible);
+ Assert.Equal(string.Empty, text);
+ }
+
+ ///
+ /// A server monitored for months and idle overnight, whose older rows are gone: the 7-day window's first waiting
+ /// task comes 5 hours after its start and no older row exists. Its collector ran all along, so the window is
+ /// covered: no banner.
+ ///
+ [Fact]
+ public async Task CurrentWaits_IdleStart_WithNoOlderRow_OnAServerMonitoredForMonths_ShowsNoBanner()
+ {
+ await _duckDb.InitializeAsync();
+ var end = DateTime.UtcNow;
+ await SeedLogRunsAsync("waiting_tasks", end.AddDays(-9), end, 30);
+ await SeedWaitingTaskAsync(end.AddDays(-7).AddHours(5));
+ await SeedWaitingTaskAsync(end.AddHours(-1));
+
+ var (visible, text) = await BannerForAsync(QueryWindowRelation.WaitingTasks, end.AddDays(-7), end);
+
+ Assert.False(visible);
+ Assert.Equal(string.Empty, text);
+ }
+
+ ///
+ /// A server collected for 30 days whose first waiting task ever came 3 days ago: a 7-day window is covered, so
+ /// there is no banner. Active Queries reads the same way through its own collector's runs.
+ ///
+ [Fact]
+ public async Task FirstRowThreeDaysAgo_OnAServerCollectedForThirtyDays_ShowsNoBanner()
+ {
+ await _duckDb.InitializeAsync();
+ var end = DateTime.UtcNow;
+ await SeedLogRunsAsync("waiting_tasks", end.AddDays(-30), end, 360);
+ await SeedLogRunsAsync("query_snapshots", end.AddDays(-30), end, 360);
+ await SeedWaitingTaskAsync(end.AddDays(-3));
+ await SeedSnapshotAsync(end.AddDays(-3));
+
+ Assert.False((await BannerForAsync(QueryWindowRelation.WaitingTasks, end.AddDays(-7), end)).Visible);
+ Assert.False((await BannerForAsync(QueryWindowRelation.QuerySnapshots, end.AddDays(-7), end)).Visible);
+ }
+
+ ///
+ /// A server added 2 days ago, whose first waiting task came a day later: a 7-day window starts before its
+ /// coverage, and the banner names where coverage starts (its first collection), not its first row.
+ ///
+ [Fact]
+ public async Task CurrentWaits_ServerAddedTwoDaysAgo_SaysSinceItsFirstCollection_NotItsFirstRow()
+ {
+ await _duckDb.InitializeAsync();
+ var end = DateTime.UtcNow;
+ var added = end.AddDays(-2);
+ await SeedLogRunsAsync("waiting_tasks", added, end, 30);
+ await SeedWaitingTaskAsync(end.AddDays(-1));
+
+ var (visible, text) = await BannerForAsync(QueryWindowRelation.WaitingTasks, end.AddDays(-7), end);
+
+ Assert.True(visible);
+ Assert.Equal("Showing since " + PerformanceMonitor.Ui.DisplayZone.Format(Naive(added), TimeZoneInfo.Utc, "yyyy-MM-dd HH:mm:ss"), text);
+ }
+
+ /// A server with no row in the window at all has nothing to say about where its data starts: no banner.
+ [Fact]
+ public async Task ActiveQueries_NoSnapshotInTheWindow_ShowsNoBanner()
+ {
+ await _duckDb.InitializeAsync();
+ var end = DateTime.UtcNow;
+ await SeedSnapshotAsync(end.AddDays(-20));
+
+ var (visible, _) = await BannerForAsync(QueryWindowRelation.QuerySnapshots, end.AddDays(-7), end);
+
+ Assert.False(visible);
+ }
+
+ ///
+ /// Source pins for the wiring: each surface has its banner TextBlock, and each read path of the surface (the
+ /// sub-tab switch and the full refresh, and for Active Queries the slicer handler) refreshes it through the
+ /// shared helper over the UTC window the grid read. Text-scans SOURCE, like QueryWindowTruncationTests.
+ ///
+ [Fact]
+ public void ActiveQueriesAndCurrentWaits_AreWiredThroughTheSharedBannerHelper()
+ {
+ var xaml = File.ReadAllText(ControlsFile("ServerTab.xaml"));
+ Assert.Contains("x:Name=\"ActiveQueriesWindowTruncatedBanner\"", xaml, StringComparison.Ordinal);
+ Assert.Contains("x:Name=\"CurrentWaitsWindowTruncatedBanner\"", xaml, StringComparison.Ordinal);
+
+ var refreshSource = File.ReadAllText(ControlsFile("ServerTab.Refresh.cs"));
+ var slicersSource = File.ReadAllText(ControlsFile("ServerTab.Slicers.cs"));
+
+ Assert.Equal(2, Regex.Matches(refreshSource,
+ @"RefreshWindowTruncatedBannerAsync\(QueryWindowRelation\.QuerySnapshots,\s*ActiveQueriesWindowTruncatedBanner,\s*windowStart\d?,\s*windowEnd\d?\)").Count);
+ Assert.Equal(2, Regex.Matches(refreshSource,
+ @"RefreshWindowTruncatedBannerAsync\(QueryWindowRelation\.WaitingTasks,\s*CurrentWaitsWindowTruncatedBanner,\s*windowStart\d?,\s*windowEnd\d?\)").Count);
+ Assert.Single(Regex.Matches(slicersSource,
+ @"RefreshWindowTruncatedBannerAsync\(QueryWindowRelation\.QuerySnapshots,\s*ActiveQueriesWindowTruncatedBanner,\s*e\.StartUtc,\s*e\.EndUtc\)"));
+ }
+
+ ///
+ /// The two drill-downs that open Active Queries on a narrow window (the chart and Overview "Show Active Queries at
+ /// This Time" drill, and the heatmap drill) load the grid for that window, so they refresh the "Showing since" banner for
+ /// it too: left alone it keeps describing the last range read, and can claim a cut the drill window does not have or
+ /// miss one it does. The banner takes the SAME pair the grid read takes. That pair is naive UTC end to end:
+ /// GetDrillWindow builds it from the clicked UTC instant (#4766), and GetLatestQuerySnapshotsAsync hands a
+ /// custom range straight through GetTimeRange to UTC collection_time, as the slicer handler does with
+ /// e.StartUtc/e.EndUtc, so nothing between them converts through the server's clock. The refresh follows
+ /// the bind, as the range read's does. Comments are stripped first, so a sentence that names the call cannot satisfy
+ /// the pin.
+ ///
+ [Theory]
+ [InlineData("private async void OnActiveQueriesDrillDown(")]
+ [InlineData("private async void OnHeatmapDrillDown(")]
+ public void ActiveQueriesDrillDowns_RefreshTheBannerForTheDrillWindow_AfterTheRowsAreBound(string signature)
+ {
+ var lf = File.ReadAllText(ControlsFile("ServerTab.DrillDown.cs")).Replace("\r\n", "\n");
+ var code = Regex.Replace(Regex.Replace(lf, @"/\*.*?\*/", string.Empty, RegexOptions.Singleline), @"//[^\n]*", string.Empty);
+ var start = code.IndexOf(signature, StringComparison.Ordinal);
+ Assert.True(start >= 0, $"'{signature}' is no longer in ServerTab.DrillDown.cs; update this pin.");
+ var end = code.IndexOf("\n }\n", start, StringComparison.Ordinal);
+ Assert.True(end > start, $"the end of '{signature}' was not found.");
+ var body = code[start..end];
+
+ var read = Regex.Match(body, @"GetLatestQuerySnapshotsAsync\(_serverId,\s*0,\s*(?\w+),\s*(?\w+)\)");
+ Assert.True(read.Success, $"'{signature}' no longer reads the grid through GetLatestQuerySnapshotsAsync(_serverId, 0, from, to); update this pin.");
+ var bound = body.IndexOf("_querySnapshotsFilterMgr!.UpdateData(snapshots);", StringComparison.Ordinal);
+ Assert.True(bound > read.Index, $"'{signature}' no longer binds the rows it read; update this pin.");
+
+ var banner = Regex.Matches(body,
+ @"await RefreshWindowTruncatedBannerAsync\(QueryWindowRelation\.QuerySnapshots,\s*ActiveQueriesWindowTruncatedBanner,\s*"
+ + Regex.Escape(read.Groups["from"].Value) + @",\s*" + Regex.Escape(read.Groups["to"].Value) + @"\);");
+ Assert.True(banner.Count == 1,
+ $"'{signature}' must refresh the Active Queries banner exactly once over the pair its grid read takes ("
+ + read.Groups["from"].Value + ", " + read.Groups["to"].Value + "); found " + banner.Count);
+ Assert.True(banner[0].Index > bound, $"'{signature}' must refresh the banner after the rows are bound, as the range read does");
+ }
+
+ ///
+ /// The Live Snapshot button swaps the Active Queries grid for the rows the server returns now, so the "Showing since"
+ /// banner the last range read raised no longer describes the grid. The handler collapses it and clears its text through
+ /// the shared step (, whose not-truncated state
+ /// QueryWindowTruncationTests drives) as the live rows are bound, and not before, so a live read that fails,
+ /// which leaves the range rows in the grid, keeps the banner that still describes them. The next range read raises
+ /// the banner again through the shared helper (the pin above).
+ ///
+ [Fact]
+ public void LiveSnapshot_HidesTheActiveQueriesBanner_AsTheLiveRowsAreBound_AndNotWhenTheReadFails()
+ {
+ var source = File.ReadAllText(ControlsFile("ServerTab.xaml.cs"));
+ var handler = Regex.Match(source, @"private async void LiveSnapshot_Click\(.*?\r?\n \}\r?\n", RegexOptions.Singleline);
+ Assert.True(handler.Success, "LiveSnapshot_Click was not found in ServerTab.xaml.cs");
+ var body = handler.Value;
+
+ var bound = body.IndexOf("_querySnapshotsFilterMgr!.UpdateData(results);", StringComparison.Ordinal);
+ var failed = body.IndexOf("catch (Exception ex)", StringComparison.Ordinal);
+ Assert.True(bound >= 0, "LiveSnapshot_Click no longer binds the live rows through _querySnapshotsFilterMgr.UpdateData(results)");
+ Assert.True(failed > bound, "LiveSnapshot_Click no longer has its failure arm after the rows are bound");
+
+ var hides = Regex.Matches(body, @"SetWindowTruncatedBanner\(ActiveQueriesWindowTruncatedBanner,\s*truncated:\s*false,");
+ Assert.True(hides.Count == 1,
+ "LiveSnapshot_Click must hide the Active Queries banner exactly once (found " + hides.Count + "): the live rows replace the range rows the banner described");
+ Assert.True(hides[0].Index > bound && hides[0].Index < failed,
+ "the banner must come down after the live rows are bound and before the failure arm, so a failed live read keeps it");
+ }
+
+ /// WPF objects require STA; same shape as QueryWindowTruncationTests.
+ private static T OnStaThread(Func body)
+ {
+ T result = default!;
+ Exception? error = null;
+ var thread = new Thread(() =>
+ {
+ try { result = body(); }
+ catch (Exception ex) { error = ex; }
+ });
+ thread.SetApartmentState(ApartmentState.STA);
+ thread.Start();
+ thread.Join();
+
+ if (error is not null)
+ {
+ throw error;
+ }
+
+ return result;
+ }
+
+ private static string ControlsFile(string name) =>
+ Path.GetFullPath(Path.Combine(ControlsDir(), name));
+
+ private static string ControlsDir([CallerFilePath] string thisFile = "") =>
+ Path.GetFullPath(Path.Combine(Path.GetDirectoryName(thisFile)!, "..", "Lite", "Controls"));
+}
diff --git a/Lite.Tests/DisplayZoneTextFrameTests.cs b/Lite.Tests/DisplayZoneTextFrameTests.cs
index bb86f10d76..9cc11598ef 100644
--- a/Lite.Tests/DisplayZoneTextFrameTests.cs
+++ b/Lite.Tests/DisplayZoneTextFrameTests.cs
@@ -225,12 +225,49 @@ public void TheRefreshHeaderAndTheTruncationBanner_UseTheTabsOwnClockAndZone()
Assert.Contains(
"ServerTimeHelper.GetTimezoneLabel(ServerTimeHelper.CurrentDisplayMode, _serverClock, refreshedUtc)", refresh, StringComparison.Ordinal);
Assert.Contains("DisplayZone.ToDisplay(refreshedUtc, GetPickerZone())", refresh, StringComparison.Ordinal);
- Assert.Contains("SetWindowTruncatedBanner(banner, truncated, floor ?? startUtc, GetPickerZone());", refresh, StringComparison.Ordinal);
+ Assert.Contains("ApplyWindowFloorToBanner(banner, floor, startUtc, GetPickerZone());", refresh, StringComparison.Ordinal);
+ Assert.Contains(
+ "internal static bool ApplyWindowFloorToBanner(TextBlock banner, DateTime? floor, DateTime startUtc, TimeZoneInfo zone)",
+ refresh, StringComparison.Ordinal);
+ Assert.Contains("SetWindowTruncatedBanner(banner, truncated, floor ?? startUtc, zone);", refresh, StringComparison.Ordinal);
Assert.Contains(
"internal static void SetWindowTruncatedBanner(TextBlock banner, bool truncated, DateTime effectiveStart, TimeZoneInfo zone)",
refresh, StringComparison.Ordinal);
}
+ ///
+ /// The shared window-floor step takes the zone as an argument, so a caller could still hand the banner another
+ /// one (the machine's, or the active server's). Every call in the ServerTab files hands it the tab's own picker
+ /// zone as its last argument, the zone the tab's other text is worded in. The definition is not a call, and at
+ /// least one call must exist, so the check cannot pass by finding nothing.
+ ///
+ [Fact]
+ public void EveryWindowFloorBannerCall_IsHandedTheTabsPickerZone()
+ {
+ var calls = 0;
+ var offenders = new List();
+ foreach (var (name, code) in ServerTabCode())
+ {
+ /* A definition has its return type right before the name; a call does not. */
+ foreach (Match m in Regex.Matches(code, @"(?= 1, "no ApplyWindowFloorToBanner call is left in the ServerTab files; update this pin.");
+ Assert.True(
+ offenders.Count == 0,
+ "These hand the 'Showing since' banner a zone other than the tab's own; pass GetPickerZone() as the last " +
+ "argument: " + string.Join(", ", offenders));
+ }
+
///
/// The wait drill-down window takes the tab's zone at construction (required, so no caller can leave it on the
/// active clock), and the tab hands it GetPickerZone(). Its range line goes through
@@ -330,6 +367,25 @@ private static string MethodBody(string code, string signature)
throw new InvalidOperationException($"no closing brace for {signature}");
}
+ /* The text between the parenthesis at and its matching close, so a nested call such as GetPickerZone() stays whole. */
+ private static string CallArguments(string code, int open)
+ {
+ var depth = 0;
+ for (var i = open; i < code.Length; i++)
+ {
+ if (code[i] == '(')
+ {
+ depth++;
+ }
+ else if (code[i] == ')' && --depth == 0)
+ {
+ return code[(open + 1)..i];
+ }
+ }
+
+ throw new InvalidOperationException($"no closing parenthesis for the call at index {open}");
+ }
+
/* Line and block comments removed, and line endings normalised, so a pin reads code only. */
private static string CodeOnly(string source)
{
diff --git a/Lite.Tests/QueryWindowTruncationTests.cs b/Lite.Tests/QueryWindowTruncationTests.cs
index 13bea0d2a5..f191cb5392 100644
--- a/Lite.Tests/QueryWindowTruncationTests.cs
+++ b/Lite.Tests/QueryWindowTruncationTests.cs
@@ -84,7 +84,7 @@ private async Task OpenSeedConnectionAsync()
return connection;
}
- private async Task SeedQueryStatsAsync(DuckDBConnection connection, DateTime collected, string queryHash)
+ private async Task SeedQueryStatsAsync(DuckDBConnection connection, DateTime collected, string queryHash, int? serverId = null)
{
using var readLock = _duckDb.AcquireReadLock();
using var cmd = connection.CreateCommand();
@@ -96,7 +96,7 @@ INSERT INTO query_stats
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)";
cmd.Parameters.Add(new DuckDBParameter { Value = _nextId++ });
cmd.Parameters.Add(new DuckDBParameter { Value = collected });
- cmd.Parameters.Add(new DuckDBParameter { Value = ServerId });
+ cmd.Parameters.Add(new DuckDBParameter { Value = serverId ?? ServerId });
cmd.Parameters.Add(new DuckDBParameter { Value = "TestServer" });
cmd.Parameters.Add(new DuckDBParameter { Value = "TestDb" });
cmd.Parameters.Add(new DuckDBParameter { Value = queryHash });
@@ -307,6 +307,187 @@ public async Task FloorHelper_ReadsTheArchivedFloor_NotJustTheHotTable()
$"floor {floor:o} should be the ARCHIVED start {archivedFloor:o}, not the hot table's {hotStart:o}");
}
+ private static DateTime NaiveUtc(DateTime instant) => DateTime.SpecifyKind(instant, DateTimeKind.Unspecified);
+
+ ///
+ /// Quiet-start guard (Lite twin of #4953's data-start rule): a server with an old row before the window and
+ /// then a quiet stretch at the window's start (nothing collected until two hours ago) is not a window the
+ /// store failed to hold, so it must get no banner. A server that holds a row before the window has had the
+ /// window served whole, so the probe answers the REQUESTED START: not the first row inside the window (a probe
+ /// bounded below at the window's start reads the row from two hours ago and raises a false banner), and not
+ /// the oldest row the server holds either (finding that reads every archived row for the server, and no
+ /// caller needs it: IsWindowTruncated and EffectiveWindowStart give the same answer for any
+ /// floor at or before the start).
+ ///
+ [Fact]
+ public async Task FloorHelper_QuietStartInsideTheWindow_WithOlderRowsBeforeIt_IsNotTruncated()
+ {
+ await _duckDb.InitializeAsync();
+ var windowEnd = DateTime.UtcNow;
+ var requestedStart = windowEnd.AddDays(-7);
+ using (var connection = await OpenSeedConnectionAsync())
+ {
+ await SeedQueryStatsAsync(connection, NaiveUtc(requestedStart.AddDays(-20)), "0xOLD");
+ await SeedQueryStatsAsync(connection, NaiveUtc(windowEnd.AddHours(-2)), "0xRECENT");
+ }
+
+ var floor = await new LocalDataService(_duckDb).GetQueryWindowFloorAsync(QueryWindowRelation.QueryStats, ServerId, requestedStart, windowEnd);
+
+ Assert.NotNull(floor);
+ Assert.True(floor!.Value == requestedStart,
+ $"floor {floor:o} must be the requested start {requestedStart:o} (the server holds a row before it), not the window's first row");
+ Assert.Equal(requestedStart, McpQueryTools.EffectiveWindowStart(floor, requestedStart));
+ Assert.False(McpQueryTools.IsWindowTruncated(floor, requestedStart),
+ "a quiet start inside the window, with older rows in the store, must not raise the data-start banner");
+ }
+
+ ///
+ /// A server whose rows all end before the window: the window holds nothing, so the probe says NULL ("nothing
+ /// was read"), never an old row or the requested start, either of which would read as the whole window being
+ /// served. The older-row check runs only after the window's own first row is found, so it never answers for
+ /// a window with no row in it.
+ ///
+ [Fact]
+ public async Task FloorHelper_NoRowInsideTheWindow_ReturnsNull_EvenWhenOlderRowsExist()
+ {
+ await _duckDb.InitializeAsync();
+ var windowEnd = DateTime.UtcNow;
+ var requestedStart = windowEnd.AddDays(-7);
+ using (var connection = await OpenSeedConnectionAsync())
+ {
+ await SeedQueryStatsAsync(connection, NaiveUtc(requestedStart.AddDays(-20)), "0xOLD1");
+ await SeedQueryStatsAsync(connection, NaiveUtc(requestedStart.AddDays(-10)), "0xOLD2");
+ }
+
+ var floor = await new LocalDataService(_duckDb).GetQueryWindowFloorAsync(QueryWindowRelation.QueryStats, ServerId, requestedStart, windowEnd);
+
+ Assert.Null(floor);
+ }
+
+ /// A range that starts before the oldest stored row (retention, or a server added recently) is the case the banner exists for.
+ [Fact]
+ public async Task FloorHelper_RangeStartsBeforeTheOldestStoredRow_IsTruncated_AtTheOldestRow()
+ {
+ await _duckDb.InitializeAsync();
+ var windowEnd = DateTime.UtcNow;
+ var requestedStart = windowEnd.AddDays(-7);
+ var oldest = windowEnd.AddDays(-2);
+ using (var connection = await OpenSeedConnectionAsync())
+ {
+ await SeedQueryStatsAsync(connection, NaiveUtc(oldest), "0xFIRST");
+ await SeedQueryStatsAsync(connection, NaiveUtc(windowEnd.AddHours(-1)), "0xLATER");
+ }
+
+ var floor = await new LocalDataService(_duckDb).GetQueryWindowFloorAsync(QueryWindowRelation.QueryStats, ServerId, requestedStart, windowEnd);
+
+ Assert.NotNull(floor);
+ Assert.True(Math.Abs((floor!.Value - oldest).TotalMinutes) < 2, $"floor {floor:o} should be the oldest row {oldest:o}");
+ Assert.True(McpQueryTools.IsWindowTruncated(floor, requestedStart));
+ }
+
+ ///
+ /// A window the store fully covers (the server holds a row before the window's start) gets no banner, and the
+ /// probe answers the requested start.
+ ///
+ [Fact]
+ public async Task FloorHelper_WindowInsideTheStoredRows_IsNotTruncated_AndGivesTheStart()
+ {
+ await _duckDb.InitializeAsync();
+ var windowEnd = DateTime.UtcNow;
+ var requestedStart = windowEnd.AddDays(-7);
+ using (var connection = await OpenSeedConnectionAsync())
+ {
+ for (var day = 10; day >= 0; day--)
+ await SeedQueryStatsAsync(connection, NaiveUtc(windowEnd.AddDays(-day).AddMinutes(-5)), $"0xDAY{day}");
+ }
+
+ var floor = await new LocalDataService(_duckDb).GetQueryWindowFloorAsync(QueryWindowRelation.QueryStats, ServerId, requestedStart, windowEnd);
+
+ Assert.NotNull(floor);
+ Assert.True(floor!.Value == requestedStart, $"floor {floor:o} must be the requested start {requestedStart:o}");
+ Assert.False(McpQueryTools.IsWindowTruncated(floor, requestedStart));
+ }
+
+ ///
+ /// A row exactly at the window's start is inside the window (the bound is inclusive): the probe answers that
+ /// start, and the window is not truncated. Pins the boundary of the first, bounded step. The start is a whole
+ /// second so DuckDB's microsecond precision cannot move the row off it.
+ ///
+ [Fact]
+ public async Task FloorHelper_RowExactlyAtTheWindowStart_GivesTheStart_NotTruncated()
+ {
+ await _duckDb.InitializeAsync();
+ var now = DateTime.UtcNow;
+ var windowEnd = new DateTime(now.Ticks - now.Ticks % TimeSpan.TicksPerSecond, DateTimeKind.Utc);
+ var requestedStart = windowEnd.AddDays(-7);
+ using (var connection = await OpenSeedConnectionAsync())
+ {
+ await SeedQueryStatsAsync(connection, NaiveUtc(requestedStart), "0xATSTART");
+ await SeedQueryStatsAsync(connection, NaiveUtc(windowEnd.AddHours(-1)), "0xLATER");
+ }
+
+ var floor = await new LocalDataService(_duckDb).GetQueryWindowFloorAsync(QueryWindowRelation.QueryStats, ServerId, requestedStart, windowEnd);
+
+ Assert.NotNull(floor);
+ Assert.True(floor!.Value == requestedStart, $"floor {floor:o} must be the row at the window's start {requestedStart:o}");
+ Assert.False(McpQueryTools.IsWindowTruncated(floor, requestedStart));
+ }
+
+ ///
+ /// Another server's older rows are not this server's coverage: the older-row step is filtered on the server,
+ /// so a server whose rows begin late in the window still reports that first row and is truncated, however
+ /// much older history a neighbour holds.
+ ///
+ [Fact]
+ public async Task FloorHelper_OnlyAnotherServerHoldsOlderRows_ThisServerIsTruncated_AtItsFirstRow()
+ {
+ await _duckDb.InitializeAsync();
+ var windowEnd = DateTime.UtcNow;
+ var requestedStart = windowEnd.AddDays(-7);
+ var firstRow = windowEnd.AddDays(-2);
+ using (var connection = await OpenSeedConnectionAsync())
+ {
+ await SeedQueryStatsAsync(connection, NaiveUtc(requestedStart.AddDays(-20)), "0xNEIGHBOUR", serverId: ServerId + 1);
+ await SeedQueryStatsAsync(connection, NaiveUtc(firstRow), "0xFIRST");
+ await SeedQueryStatsAsync(connection, NaiveUtc(windowEnd.AddHours(-1)), "0xLATER");
+ }
+
+ var floor = await new LocalDataService(_duckDb).GetQueryWindowFloorAsync(QueryWindowRelation.QueryStats, ServerId, requestedStart, windowEnd);
+
+ Assert.NotNull(floor);
+ Assert.True(Math.Abs((floor!.Value - firstRow).TotalMinutes) < 2, $"floor {floor:o} should be this server's first row {firstRow:o}, not the start");
+ Assert.True(McpQueryTools.IsWindowTruncated(floor, requestedStart));
+ }
+
+ ///
+ /// The tool's twin of the quiet-start guard, and the clamp: with older rows before the window the probe answers
+ /// the requested start, and the tool must report the window it was asked for (effective_start never earlier
+ /// than the requested start, effective_hours_back never longer than hours_back), not the whole stored
+ /// history.
+ ///
+ [Fact]
+ public async Task GetTopQueriesByCpu_OlderRowsBeforeTheWindow_ReportsTheWholeWindow_NotTruncated()
+ {
+ await _duckDb.InitializeAsync();
+ var nowUtc = DateTime.UtcNow;
+ using (var connection = await OpenSeedConnectionAsync())
+ {
+ await SeedQueryStatsAsync(connection, NaiveUtc(nowUtc.AddDays(-20)), "0xOLDER");
+ await SeedQueryStatsAsync(connection, NaiveUtc(nowUtc.AddHours(-2)), "0xQUIETSTART");
+ }
+
+ var json = await McpQueryTools.GetTopQueriesByCpu(new LocalDataService(_duckDb), _serverManager, "TestServer", hours_back: 24);
+ using var doc = JsonDocument.Parse(json);
+ var root = doc.RootElement;
+
+ Assert.False(root.GetProperty("window_truncated").GetBoolean());
+ Assert.Equal(JsonValueKind.Null, root.GetProperty("truncation_note").ValueKind);
+ var effectiveStart = DateTime.Parse(root.GetProperty("effective_start").GetString()!).ToUniversalTime();
+ Assert.True(Math.Abs((effectiveStart - nowUtc.AddHours(-24)).TotalMinutes) < 2,
+ $"effective_start {effectiveStart:o} must be the requested start, never earlier ({nowUtc.AddHours(-24):o})");
+ Assert.InRange(root.GetProperty("effective_hours_back").GetDouble(), 23.9, 24.0);
+ }
+
///
/// #4231 ruling: "the WPF Top Queries, Top Procedures and Query Store grids show 'Showing since <time>'
/// in the header when the window is cut short." Pins --
@@ -501,8 +682,9 @@ public void WindowTruncatedBannerCallSites_TakeUtcBounds_NotServerLocalOnes()
var slicersSource = File.ReadAllText(ControlsFile("ServerTab.Slicers.cs"));
var slicerBannerCallsOnUtc = Regex.Matches(slicersSource,
@"RefreshWindowTruncatedBannerAsync\(\s*QueryWindowRelation\.\w+,\s*\w+,\s*e\.StartUtc,\s*e\.EndUtc\)").Count;
- Assert.True(slicerBannerCallsOnUtc == 3,
- $"expected all 3 OnXSlicerChanged banner calls to pass e.StartUtc, e.EndUtc (found {slicerBannerCallsOnUtc}) " +
+ /* 3 -> 4: Active Queries' slicer handler joined the three Queries grids' (the Lite twin of #4953's data-start notice). */
+ Assert.True(slicerBannerCallsOnUtc == 4,
+ $"expected all 4 OnXSlicerChanged banner calls to pass e.StartUtc, e.EndUtc (found {slicerBannerCallsOnUtc}) " +
"-- fromServer/toServer are server-local and GetQueryWindowFloorAsync compares them straight against " +
"UTC collection_time (#4279).");
Assert.False(Regex.IsMatch(slicersSource, @"RefreshWindowTruncatedBannerAsync\([^)]*fromServer,\s*toServer\)"),
@@ -511,11 +693,13 @@ public void WindowTruncatedBannerCallSites_TakeUtcBounds_NotServerLocalOnes()
var refreshSource = File.ReadAllText(ControlsFile("ServerTab.Refresh.cs"));
var refreshBannerCallsOnHelperOutput = Regex.Matches(refreshSource,
@"RefreshWindowTruncatedBannerAsync\(\s*QueryWindowRelation\.\w+,\s*\w+,\s*windowStart\d?,\s*windowEnd\d?\)").Count;
- Assert.True(refreshBannerCallsOnHelperOutput == 6,
- $"expected all 6 ServerTab.Refresh.cs banner calls to pass a GetQueriesTabWindowUtc result " +
+ /* 6 -> 10: Active Queries (sub-tab switch + full refresh) and Current Waits (sub-tab switch + full refresh)
+ each take their banner window from GetQueriesTabWindowUtc too (see DataStartBannerTests). */
+ Assert.True(refreshBannerCallsOnHelperOutput == 10,
+ $"expected all 10 ServerTab.Refresh.cs banner calls to pass a GetQueriesTabWindowUtc result " +
$"(windowStart/windowEnd) (found {refreshBannerCallsOnHelperOutput}) -- a server-local cStart must " +
"not feed the banner (#4279/#4284).");
- Assert.Equal(6, Regex.Matches(refreshSource, @"LocalDataService\.GetQueriesTabWindowUtc\(").Count);
+ Assert.Equal(10, Regex.Matches(refreshSource, @"LocalDataService\.GetQueriesTabWindowUtc\(").Count);
}
private static string ControlsFile(string name) => Path.Combine(ControlsDir(), name);
diff --git a/Lite/Controls/ServerTab.DrillDown.cs b/Lite/Controls/ServerTab.DrillDown.cs
index 00839cd234..80dea619ef 100644
--- a/Lite/Controls/ServerTab.DrillDown.cs
+++ b/Lite/Controls/ServerTab.DrillDown.cs
@@ -134,7 +134,9 @@ internal static string DrillDownIndicatorText(DateTime fromUtc, DateTime toUtc,
///
/// Generic "Show Active Queries at This Time" drill-down for resource charts that have no
/// more specific target (memory clerks/grants/pressure, tempdb size + file I/O, file I/O
- /// latency + throughput, current waits, perfmon).
+ /// latency + throughput, current waits, perfmon). The "Showing since" banner is refreshed for the drill
+ /// window too (#4953), over the same UTC pair the grid read takes: left alone it keeps describing the last
+ /// range read, so it can claim a cut the drill window does not have or miss one it does.
///
private async void OnActiveQueriesDrillDown(DateTime time)
{
@@ -146,6 +148,10 @@ private async void OnActiveQueriesDrillDown(DateTime time)
_querySnapshotsFilterMgr!.UpdateData(snapshots);
LiveSnapshotIndicator.Text = DrillDownIndicatorText(fromDate, toDate, GetPickerZone());
_ = LoadActiveQueriesSlicerAsync();
+ /* fromDate/toDate are the naive-UTC pair GetDrillWindow built (#4766). The grid read above took them as they
+ are (GetTimeRange's custom-range branch) and the banner probe compares against the same UTC collection_time,
+ so they go through unconverted, as the slicer handler's e.StartUtc/e.EndUtc do (#4279). */
+ await RefreshWindowTruncatedBannerAsync(QueryWindowRelation.QuerySnapshots, ActiveQueriesWindowTruncatedBanner, fromDate, toDate);
}
private async void OnBlockingDrillDown(DateTime time)
@@ -187,6 +193,9 @@ private async void OnHeatmapDrillDown(DateTime bucketTimeUtc)
_querySnapshotsFilterMgr!.UpdateData(snapshots);
LiveSnapshotIndicator.Text = DrillDownIndicatorText(fromDate, toDate, GetPickerZone());
_ = LoadActiveQueriesSlicerAsync();
+ /* The heatmap drill's window is the same naive-UTC pair the grid read took (see OnActiveQueriesDrillDown):
+ the banner follows it, so it stops describing the last range read (#4953). */
+ await RefreshWindowTruncatedBannerAsync(QueryWindowRelation.QuerySnapshots, ActiveQueriesWindowTruncatedBanner, fromDate, toDate);
}
///
diff --git a/Lite/Controls/ServerTab.Refresh.cs b/Lite/Controls/ServerTab.Refresh.cs
index 6f59dacdba..1c5cc4dcd2 100644
--- a/Lite/Controls/ServerTab.Refresh.cs
+++ b/Lite/Controls/ServerTab.Refresh.cs
@@ -344,6 +344,12 @@ private async System.Threading.Tasks.Task RefreshQueriesAsync(int hoursBack, Dat
_querySnapshotsFilterMgr!.UpdateData(snapshots);
LiveSnapshotIndicator.Text = "";
_ = LoadActiveQueriesSlicerAsync();
+ {
+ /* Where the stored snapshots start, over the SAME UTC window the grid just read
+ (GetLatestQuerySnapshotsAsync goes through GetTimeRange like the three grids below). */
+ var (windowStart4, windowEnd4) = LocalDataService.GetQueriesTabWindowUtc(hoursBack, fromDate, toDate);
+ await RefreshWindowTruncatedBannerAsync(QueryWindowRelation.QuerySnapshots, ActiveQueriesWindowTruncatedBanner, windowStart4, windowEnd4);
+ }
break;
case 2: // Top Queries by Duration
var queryStats = await Task.Run(() => _dataService.GetTopQueriesByCpuAsync(_serverId, hoursBack, 50, fromDate, toDate, ServerClock, SelectedDatabaseFilter));
@@ -427,6 +433,12 @@ await System.Threading.Tasks.Task.WhenAll(
LiveSnapshotIndicator.Text = "";
_ = LoadActiveQueriesSlicerAsync();
+ {
+ /* Where the stored snapshots start, over the SAME UTC window the grid read -- see the Active
+ Queries case of the sub-tab switch above. */
+ var (windowStart4, windowEnd4) = LocalDataService.GetQueriesTabWindowUtc(hoursBack, fromDate, toDate);
+ await RefreshWindowTruncatedBannerAsync(QueryWindowRelation.QuerySnapshots, ActiveQueriesWindowTruncatedBanner, windowStart4, windowEnd4);
+ }
_queryStatsFilterMgr!.UpdateData(queryStatsTask.Result);
SetDefaultSortIfNone(QueryStatsGrid, "TotalElapsedMs", ListSortDirection.Descending);
@@ -475,14 +487,17 @@ await System.Threading.Tasks.Task.WhenAll(
///
/// #4231: probes the shared window-floor helper ()
- /// for one of the three raw-only relations and updates that tab's "Showing since <time>" banner —
+ /// for one of the relations (the three raw-only query tables, and the Active
+ /// Queries and Current Waits tables the Lite twin of Darling's data-start notice added) and updates that
+ /// surface's "Showing since <time>" banner —
/// the SAME probe and the same truncation verdict () the
/// matching MCP tool uses, so the grid and the tool never disagree about whether a window was cut short.
/// Called from the sub-tab switch and full-refresh paths below AND from the three OnXSlicerChanged
/// handlers in ServerTab.Slicers.cs — a slicer drag re-reads the same grid over a narrower window, which
- /// can itself start after the raw table's floor, so it needs the same disclosure. No try/catch here: every
- /// caller already runs inside its own (ServerTabCapabilityPinTests pins that every OnXSlicerChanged keeps
- /// its own try/catch; RefreshQueriesAsync has one around the whole sub-tab switch).
+ /// can itself start after the raw table's floor, so it needs the same disclosure. A probe that throws costs
+ /// only the banner ( hides it and logs): the callers' own try/catch
+ /// would otherwise skip whatever follows the probe, such as the Query Stats grid bind after the Active Queries
+ /// banner, or the Locking tab's slicers and tab-badge counts after the Current Waits banner.
///
/// #4279: / MUST be the same UTC window the
/// matching grid read (, or a slicer's own
@@ -492,9 +507,49 @@ await System.Threading.Tasks.Task.WhenAll(
///
private async System.Threading.Tasks.Task RefreshWindowTruncatedBannerAsync(QueryWindowRelation relation, TextBlock banner, DateTime startUtc, DateTime endUtc)
{
- var floor = await Task.Run(() => _dataService.GetQueryWindowFloorAsync(relation, _serverId, startUtc, endUtc));
+ var floor = await ProbeWindowFloorOrNullAsync(
+ () => Task.Run(() => _dataService.GetQueryWindowFloorAsync(relation, _serverId, startUtc, endUtc)),
+ $"[{_server.DisplayName}] {relation}");
+ ApplyWindowFloorToBanner(banner, floor, startUtc, GetPickerZone());
+ }
+
+ ///
+ /// The answer of a window-floor probe, or null when the probe throws. The probe is only the banner's disclosure, so
+ /// its failure must not unwind the refresh past what follows it: the null goes on to
+ /// , for which a null floor hides the banner and clears its text (a "Showing
+ /// since" from the last read does not outlive the answer it came from), the failure is logged at Warn, and the
+ /// refresh goes on. internal static with the probe passed in so the tests drive a throwing probe without
+ /// building the UserControl.
+ ///
+ internal static async System.Threading.Tasks.Task ProbeWindowFloorOrNullAsync(
+ Func> probe, string what)
+ {
+ try
+ {
+ return await probe();
+ }
+ catch (Exception ex)
+ {
+ AppLogger.Warn("ServerTab", $"{what} data-start probe failed, so no \"Showing since\" banner is shown: {ex.Message}");
+ return null;
+ }
+ }
+
+ ///
+ /// The verdict and the banner for one probe result: the SAME 90-minute slack
+ /// () the MCP tools use, then the "Showing since" text. A floor at or
+ /// before the window's start (the store reaches back to it, however quiet the window's own start was) and a null
+ /// floor both hide the banner. Null means the window holds nothing to report: no row for the three Queries grids,
+ /// and for Active Queries and Current Waits no row and no logged run of the collector either (a covered window
+ /// in which nothing ran or waited answers the start, not null, so it too shows no banner). The same step serves
+ /// every surface that carries the banner (the three Queries grids, Active Queries, Current Waits), and
+ /// internal static so the tests drive it, with a real probe result, without building the UserControl.
+ ///
+ internal static bool ApplyWindowFloorToBanner(TextBlock banner, DateTime? floor, DateTime startUtc, TimeZoneInfo zone)
+ {
var truncated = McpQueryTools.IsWindowTruncated(floor, startUtc);
- SetWindowTruncatedBanner(banner, truncated, floor ?? startUtc, GetPickerZone());
+ SetWindowTruncatedBanner(banner, truncated, floor ?? startUtc, zone);
+ return truncated;
}
///
@@ -675,6 +730,12 @@ private async System.Threading.Tasks.Task RefreshBlockingAsync(int hoursBack, Da
await System.Threading.Tasks.Task.WhenAll(cwd, cwb);
UpdateCurrentWaitsDurationChart(cwd.Result, hoursBack, fromDate, toDate);
UpdateCurrentWaitsBlockedChart(cwb.Result, hoursBack, fromDate, toDate);
+ {
+ /* Where the stored waiting-task rows start, over the SAME UTC window both charts read
+ (GetWaitingTaskTrendAsync and GetBlockedSessionTrendAsync both take it from GetTimeRange). */
+ var (windowStart5, windowEnd5) = LocalDataService.GetQueriesTabWindowUtc(hoursBack, fromDate, toDate);
+ await RefreshWindowTruncatedBannerAsync(QueryWindowRelation.WaitingTasks, CurrentWaitsWindowTruncatedBanner, windowStart5, windowEnd5);
+ }
break;
case 2: // Blocked Process Reports
var bpr = await Task.Run(() => _dataService.GetRecentBlockedProcessReportsAsync(_serverId, hoursBack, fromDate, toDate, SelectedDatabaseFilter));
@@ -750,6 +811,13 @@ the deadlock count (same deadlock window). */
UpdateBlockingStatsSummary(blockingDurationStatsTask.Result, deadlockTrendTask.Result, deadlockSeverityStatsTask.Result);
}
+ {
+ /* Where the stored waiting-task rows start, over the SAME UTC window the Current Waits charts read --
+ see the Current Waits case of the sub-tab switch above. */
+ var (windowStart5, windowEnd5) = LocalDataService.GetQueriesTabWindowUtc(hoursBack, fromDate, toDate);
+ await RefreshWindowTruncatedBannerAsync(QueryWindowRelation.WaitingTasks, CurrentWaitsWindowTruncatedBanner, windowStart5, windowEnd5);
+ }
+
await LoadBlockingSlicerAsync();
await LoadDeadlockSlicerAsync();
diff --git a/Lite/Controls/ServerTab.Slicers.cs b/Lite/Controls/ServerTab.Slicers.cs
index 0cfcdb2a1a..4a4badd1a9 100644
--- a/Lite/Controls/ServerTab.Slicers.cs
+++ b/Lite/Controls/ServerTab.Slicers.cs
@@ -101,6 +101,8 @@ private async void OnActiveQueriesSlicerChanged(object? sender, Controls.SlicerR
var snapshots = await Task.Run(() => _dataService.GetLatestQuerySnapshotsAsync(_serverId, 0, e.StartUtc, e.EndUtc, SelectedDatabaseFilter));
_querySnapshotsFilterMgr!.UpdateData(snapshots);
LiveSnapshotIndicator.Text = "";
+ /* The banner takes e.StartUtc/e.EndUtc, the UTC pair the grid read above takes (same as the three Queries grids). */
+ await RefreshWindowTruncatedBannerAsync(QueryWindowRelation.QuerySnapshots, ActiveQueriesWindowTruncatedBanner, e.StartUtc, e.EndUtc);
}
catch (Exception ex)
{
diff --git a/Lite/Controls/ServerTab.xaml b/Lite/Controls/ServerTab.xaml
index 5efbe80775..2a03e91ef8 100644
--- a/Lite/Controls/ServerTab.xaml
+++ b/Lite/Controls/ServerTab.xaml
@@ -364,13 +364,21 @@
-
-
-
-
+
+
+
+
+
+
+
+
+
-
+
+
+
-
+
diff --git a/Lite/Controls/ServerTab.xaml.cs b/Lite/Controls/ServerTab.xaml.cs
index 9f4aea1298..13a5f564f8 100644
--- a/Lite/Controls/ServerTab.xaml.cs
+++ b/Lite/Controls/ServerTab.xaml.cs
@@ -574,6 +574,11 @@ private async void LiveSnapshot_Click(object sender, RoutedEventArgs e)
}
_querySnapshotsFilterMgr!.UpdateData(results);
+ /* #4953: the grid now holds the live rows, not the range the "Showing since" banner described, so the banner
+ comes down (collapsed, text cleared) through the same step that hides it for a window nothing cuts short.
+ The next range read raises it again through RefreshWindowTruncatedBannerAsync. A failed live read never
+ reaches this line: the range rows stay in the grid, and so does the banner that describes them. */
+ SetWindowTruncatedBanner(ActiveQueriesWindowTruncatedBanner, truncated: false, DateTime.UtcNow, GetPickerZone());
/* #4766: the refresh instant is UTC now, worded in the tab's own display zone; the machine clock is
neither the server's nor the mode's. */
LiveSnapshotIndicator.Text = $"LIVE at {DisplayZone.Format(DateTime.UtcNow, GetPickerZone(), "HH:mm:ss")} ({results.Count} queries)";
diff --git a/Lite/Mcp/McpQueryTools.cs b/Lite/Mcp/McpQueryTools.cs
index 84c9ac1be9..c56e504855 100644
--- a/Lite/Mcp/McpQueryTools.cs
+++ b/Lite/Mcp/McpQueryTools.cs
@@ -79,7 +79,7 @@ before the window was kept. See McpServerLocalWindow. */
young install can cut a "Last 7 days" ask well short of 7 days with nothing on the page saying
so. One probe, shared with the Queries-tab grid (LocalDataService.GetQueryWindowFloorAsync). */
var floor = await dataService.GetQueryWindowFloorAsync(QueryWindowRelation.QueryStats, resolved.ServerId, requestedStart, nowUtc);
- var effectiveStart = floor ?? requestedStart;
+ var effectiveStart = EffectiveWindowStart(floor, requestedStart);
var truncated = IsWindowTruncated(floor, requestedStart);
/* #2320: what fraction of the box's measured CPU the RETURNED rows explain — numerator is
@@ -212,7 +212,7 @@ public static async Task GetTopProceduresByCpu(
/* #4231: what the window ACTUALLY reached, from the SAME relation (v_procedure_stats) the ranking
read above just used. One probe, shared with the Queries-tab grid. */
var floor = await dataService.GetQueryWindowFloorAsync(QueryWindowRelation.ProcedureStats, resolved.ServerId, requestedStart, nowUtc);
- var effectiveStart = floor ?? requestedStart;
+ var effectiveStart = EffectiveWindowStart(floor, requestedStart);
var truncated = IsWindowTruncated(floor, requestedStart);
/* #2320: same attributed-CPU disclosure as the queries tool — one shared computation, one
@@ -341,7 +341,7 @@ has no rollup fallback (the only one that keeps query_id/plan_id would approach
so the honest move is to report what was served rather than echo what was asked for. Deliberately
unfiltered: the floor is a property of the tier, not narrowed by database/execution_type/module. */
var floor = await dataService.GetQueryWindowFloorAsync(QueryWindowRelation.QueryStoreStats, resolved.ServerId, requestedStart, nowUtc);
- var effectiveStart = floor ?? requestedStart;
+ var effectiveStart = EffectiveWindowStart(floor, requestedStart);
var truncated = IsWindowTruncated(floor, requestedStart);
if (rows.Count == 0)
@@ -969,11 +969,27 @@ private static TrendGrain Bucketed(int bucketMinutes, bool requested, int autoPo
/// data starting LATER than the window asked for, by more than — the window
/// floor, not a page cut; no top/limit changes it. Shared so get_top_queries_by_cpu,
/// get_top_procedures_by_cpu, get_query_store_top and the Queries-tab grids that read the
- /// same three tables all compute the same verdict from the same probe instead of drifting apart.
+ /// same three tables all compute the same verdict from the same probe instead of drifting apart. A null floor
+ /// is never truncated: the probe answers null only when the window holds nothing to report (no row, and for
+ /// the Active Queries and Current Waits relations, which the probe reads by coverage, no logged run of the
+ /// collector either), and an empty panel already says so.
///
internal static bool IsWindowTruncated(DateTime? floor, DateTime requestedStart) =>
floor is DateTime f && f > requestedStart + TruncationSlack;
+ ///
+ /// The instant the served window really starts: the probe's floor, but never earlier than the start that was
+ /// asked for. The probe () answers the requested start
+ /// itself when the server also holds a row before the window (the window's start is covered), and the
+ /// server's first row inside the window when its data starts late, so only a floor AFTER the requested start
+ /// moves the served window. The clamp stays so that a floor at or before the start can never read as the window
+ /// having begun earlier. A null floor leaves the requested start in place, as it always did. Null means the
+ /// window holds nothing: no row, and for the Active Queries and Current Waits relations no logged run of the
+ /// collector either (a window the collector ran in, with nothing to record, answers the start, not null).
+ ///
+ internal static DateTime EffectiveWindowStart(DateTime? floor, DateTime requestedStart) =>
+ floor is DateTime f && f > requestedStart ? f : requestedStart;
+
///
/// The disclosure block every Performance-Trends payload carries (#3541 A2), written in the same key order
/// on both SKUs and on both branches. Lite has ONE tier — DuckDB keeps raw rows for the collector's whole
diff --git a/Lite/Services/LocalDataService.QueryWindowFloor.cs b/Lite/Services/LocalDataService.QueryWindowFloor.cs
index c41566e5a6..6b6df7d39a 100644
--- a/Lite/Services/LocalDataService.QueryWindowFloor.cs
+++ b/Lite/Services/LocalDataService.QueryWindowFloor.cs
@@ -13,68 +13,167 @@
namespace PerformanceMonitorLite.Services;
///
-/// The three RAW-ONLY relations #4231's window-floor disclosure covers: every Queries-tab grid and MCP tool
-/// that reads one of these has no rollup underneath it to fall back on when the requested window reaches past
-/// what the raw table actually retains.
+/// The relations a window-floor probe () may read. Each is a
+/// closed member that maps to ONE v_ view with server_id and collection_time columns, so no
+/// caller hands a view name, and nothing a user typed, into the probe's SQL. #4231 began with the three RAW-ONLY
+/// relations (, , ): every
+/// Queries-tab grid and MCP tool that reads one has no rollup underneath it to fall back on when the requested
+/// window reaches past what the raw table actually retains. The Lite twin of Darling's Active Queries and Current
+/// Waits data-start banners adds and . A further surface adds
+/// its own member and its arm in QueryWindowRelationView; QueryWindowTruncationTests pins that every
+/// member names a real archive view.
///
public enum QueryWindowRelation
{
QueryStats,
ProcedureStats,
- QueryStoreStats
+ QueryStoreStats,
+ QuerySnapshots,
+ WaitingTasks
}
public partial class LocalDataService
{
- private static string QueryWindowRelationView(QueryWindowRelation relation) => relation switch
+ internal static string QueryWindowRelationView(QueryWindowRelation relation) => relation switch
{
QueryWindowRelation.QueryStats => "v_query_stats",
QueryWindowRelation.ProcedureStats => "v_procedure_stats",
QueryWindowRelation.QueryStoreStats => "v_query_store_stats",
+ QueryWindowRelation.QuerySnapshots => "v_query_snapshots",
+ QueryWindowRelation.WaitingTasks => "v_waiting_tasks",
_ => throw new ArgumentOutOfRangeException(nameof(relation), relation, "unknown QueryWindowRelation")
};
///
- /// #4231: the oldest collection_time this server actually has inside the requested window, for
- /// whichever of the three raw relations the caller names. ONE probe shared by every Queries-tab grid
- /// (Lite/Controls/ServerTab.*) and every MCP tool that reads that relation (get_top_queries_by_cpu,
- /// get_top_procedures_by_cpu, get_query_store_top), so there is one place that computes the
- /// floor rather than three near-identical copies that can drift apart. Twin of Darling's
- /// DarlingDataReader.GetQueryStoreWindowFloorAsync (#2364), generalized to Lite's three raw tables:
- /// Lite's default 30-day retention_days is per-collector and user-settable (lower it and the raw
- /// table is shorter than a "Last 7 days" ask), and a server added mid-window has less history than that
- /// regardless of the setting — the same silent cut #2364 fixed on Darling's query_store_stats can
- /// happen on any of Lite's three (#4231).
+ /// The collector whose runs collection_log records for a relation the probe reads by coverage
+ /// (, ), or null for
+ /// the three Queries-tab relations, which keep the row-only probe. A closed map, so nothing a caller passes
+ /// reaches the probe's SQL.
+ ///
+ internal static string? QueryWindowRelationCollector(QueryWindowRelation relation) => relation switch
+ {
+ QueryWindowRelation.QuerySnapshots => "query_snapshots",
+ QueryWindowRelation.WaitingTasks => "waiting_tasks",
+ _ => null
+ };
+
+ ///
+ /// Where this server's data starts for the requested window, as far as any caller needs to know it. NULL when
+ /// the server holds no row inside [, ] at all (nothing was
+ /// read; for Active Queries and Current Waits, no row and no logged run of the collector either).
+ /// itself when the server also holds a row BEFORE the window (the window was
+ /// served whole). Otherwise the server's first row inside the window (the data starts late). ONE probe shared by
+ /// every Queries-tab grid (Lite/Controls/ServerTab.*), the Active Queries and Current Waits banners and
+ /// every MCP tool that reads one of the relations
+ /// (get_top_queries_by_cpu, get_top_procedures_by_cpu, get_query_store_top), so there is
+ /// one place that computes the floor rather than near-identical copies that can drift apart. Twin of Darling's
+ /// DarlingDataReader.GetQueryStoreWindowFloorAsync (#2364) and of #4953's DataWindowFloor: Lite's
+ /// retention_days is user-settable (lower it and the raw table is shorter than a "Last 7 days" ask), a
+ /// custom range can start before the archive's oldest month
+ /// (), and a server added mid-window has less history than
+ /// the window asks for regardless of the setting. The silent cut #2364 fixed on Darling's
+ /// query_store_stats can happen on any of these relations.
///
/// Reads the v_ view, not the bare table: on a store with archived history that view is the
/// hot table UNION ALL the parquet archive (),
- /// so the floor this returns is the true floor of everything the matching grid or tool read, archive
- /// included — never just the hot table's.
+ /// so a row found here is a row of everything the matching grid or tool read, archive included, never just
+ /// the hot table's.
+ ///
+ /// Why it is not the oldest row. Finding the server's oldest row means an unbounded
+ /// MIN(collection_time), and DuckDB has no (server_id, collection_time) index to stop at the first
+ /// row, so it reads every archived row for the server. No caller needs it: a caller compares the result with the
+ /// window's start () and, to word the window it covered,
+ /// clamps it (), and any floor at or before the start gives
+ /// the same output. Whether the data reaches back to the start is the whole question, and the first row inside
+ /// the window alone cannot answer it on a sparse table (waiting_tasks holds a row only while something waits: a
+ /// quiet first stretch puts the window's own first row far past its start while older rows are stored, and a
+ /// probe that read only the window would raise a false banner).
///
- /// Bounded on both sides of the window. An unbounded MIN would scan back to the start of
- /// retention to answer a question about the window's edge; the upper bound is what lets DuckDB's zone-map
- /// statistics prune row groups in the hot table AND skip whole files in the parquet glob, the same way the
- /// callers' own collection_time filters do (no separate index needed — see
- /// DuckDbSchemaGenerator's idx_query_store_time comment neighbors for why the column stats
- /// alone are enough here). Null when the window holds nothing at all, which the caller reports as "nothing
- /// was read" rather than as a measured absence of activity.
+ /// Active Queries and Current Waits read coverage, not rows. query_snapshots and waiting_tasks hold
+ /// a row only while something runs or waits, so a server idle overnight, or one whose first waiting task came
+ /// days after it was added, has no row near the window's start though the store covered it. For these two the
+ /// collector's runs in v_collection_log count as well as rows: a run proves the collector was collecting
+ /// then. The log survives exactly as long as the table: it is archived and deleted by the same single horizon
+ /// (, the same monthly files), so a run older than the
+ /// window means the table's rows from then on are still held, and the first run inside the window is where
+ /// coverage starts, whether that is the server's first collection or the retention edge, whichever is later.
+ /// For these two the probe answers NULL when the window holds no row and no run, the window's start when a
+ /// row or run sits at or before it, and otherwise the first row or run inside the window, whichever is
+ /// earlier. A covered window that holds no row (the collector ran, nothing waited) answers the start, so it
+ /// shows no banner.
+ ///
+ /// The two steps. The first is bounded on both sides, so DuckDB's zone-map statistics prune every
+ /// row group and parquet file outside the window; it ends the probe when the window holds no row (NULL, never
+ /// an old row or the start, either of which would read as the window having been served) or when its first row
+ /// is already at the start. Only when that first row sits after the start does a second query ask whether the
+ /// server holds ANY row before the start, LIMIT 1, which stops at the first hit.
///
public async Task GetQueryWindowFloorAsync(QueryWindowRelation relation, int serverId, DateTime startUtc, DateTime endUtc)
{
var view = QueryWindowRelationView(relation);
- using var _q = TimeQuery("GetQueryWindowFloorAsync", $"{view} MIN(collection_time) window floor");
+ using var _q = TimeQuery("GetQueryWindowFloorAsync", $"{view} window floor");
using var connection = await OpenConnectionAsync();
- using var command = connection.CreateCommand();
- command.CommandText = $@"
+
+ var collector = QueryWindowRelationCollector(relation);
+ DateTime? firstInWindow;
+ using (var windowCommand = connection.CreateCommand())
+ {
+ /* Coverage relations take the earlier of the first row and the collector's first run in the window.
+ LEAST skips a NULL, so either one alone answers. */
+ windowCommand.CommandText = collector is null
+ ? $@"
SELECT MIN(collection_time)
FROM {view}
WHERE server_id = $1
AND collection_time >= $2
-AND collection_time <= $3";
- command.Parameters.Add(new DuckDBParameter { Value = serverId });
- command.Parameters.Add(new DuckDBParameter { Value = startUtc });
- command.Parameters.Add(new DuckDBParameter { Value = endUtc });
- var result = await command.ExecuteScalarAsync();
- return result is DateTime dt ? dt : null;
+AND collection_time <= $3"
+ : $@"
+SELECT LEAST(
+ (SELECT MIN(collection_time) FROM {view} WHERE server_id = $1 AND collection_time >= $2 AND collection_time <= $3),
+ (SELECT MIN(collection_time) FROM v_collection_log WHERE server_id = $1 AND collector_name = '{collector}' AND collection_time >= $2 AND collection_time <= $3))";
+ windowCommand.Parameters.Add(new DuckDBParameter { Value = serverId });
+ windowCommand.Parameters.Add(new DuckDBParameter { Value = startUtc });
+ windowCommand.Parameters.Add(new DuckDBParameter { Value = endUtc });
+ firstInWindow = await windowCommand.ExecuteScalarAsync() is DateTime first ? first : null;
+ }
+
+ if (firstInWindow is not DateTime firstRow || firstRow <= startUtc)
+ {
+ return firstInWindow;
+ }
+
+ using (var olderCommand = connection.CreateCommand())
+ {
+ olderCommand.CommandText = $@"
+SELECT 1
+FROM {view}
+WHERE server_id = $1
+AND collection_time < $2
+LIMIT 1";
+ olderCommand.Parameters.Add(new DuckDBParameter { Value = serverId });
+ olderCommand.Parameters.Add(new DuckDBParameter { Value = startUtc });
+ if (await olderCommand.ExecuteScalarAsync() is not null)
+ {
+ return startUtc;
+ }
+ }
+
+ if (collector is null)
+ {
+ return firstRow;
+ }
+
+ /* No older row: an older run of the collector still proves the window is covered. */
+ using var olderRunCommand = connection.CreateCommand();
+ olderRunCommand.CommandText = $@"
+SELECT 1
+FROM v_collection_log
+WHERE server_id = $1
+AND collector_name = '{collector}'
+AND collection_time < $2
+LIMIT 1";
+ olderRunCommand.Parameters.Add(new DuckDBParameter { Value = serverId });
+ olderRunCommand.Parameters.Add(new DuckDBParameter { Value = startUtc });
+ return await olderRunCommand.ExecuteScalarAsync() is null ? firstRow : startUtc;
}
}
From 44ec9b2ea12efeebd3729bae4da4d6ba99061765 Mon Sep 17 00:00:00 2001
From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com>
Date: Fri, 2 Oct 2026 21:22:21 -0400
Subject: [PATCH 013/399] collection_log compresses by server and collector
(#4951) (#4959)
New collection_log chunks compress by collector as well as by server (server_id, collector_name), so a read of one collector's runs decompresses only that collector's segment. Chunks that are already compressed keep their settings and age out with retention, so no chunk is rewritten. A fresh store gets the new setting when collection_log first becomes a hypertable, and a converged store issues no ALTER.
The settings ALTER runs in its own transaction with SET LOCAL lock_timeout = '3s'. If another session holds the table, the pass logs an Information line and the next hourly pass tries again. Any other failure rolls back and logs a Warning, and the compression policy step still runs. Below the TimescaleDB release that can change compression settings while compressed chunks exist, the pass does not issue the ALTER.
Measured on a copy of the same rows: the compressed chunks took 336 MB by server_id and 76 MB with the new setting at 12-hour chunks, and 357 MB and 151 MB at 6-hour chunks.
---
.../CollectionLogSegmentByLiveTests.cs | 722 ++++++++++++++++++
...tBaselineCoverageReadPlanShapeLiveTests.cs | 15 +-
.../DarlingObservabilityTests.cs | 4 +-
.../StoreObjectConvergenceTests.cs | 37 +-
.../Darling.Tests/TimescaleSupportTests.cs | 57 +-
.../DarlingWorker.cs | 17 +-
.../TimescaleSupport.cs | 276 ++++++-
7 files changed, 1074 insertions(+), 54 deletions(-)
create mode 100644 Darling/Darling.Tests/CollectionLogSegmentByLiveTests.cs
diff --git a/Darling/Darling.Tests/CollectionLogSegmentByLiveTests.cs b/Darling/Darling.Tests/CollectionLogSegmentByLiveTests.cs
new file mode 100644
index 0000000000..9f64d15021
--- /dev/null
+++ b/Darling/Darling.Tests/CollectionLogSegmentByLiveTests.cs
@@ -0,0 +1,722 @@
+/*
+ * 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.Diagnostics;
+using System.Linq;
+using System.Text;
+using System.Text.Json;
+using System.Threading;
+using System.Threading.Tasks;
+using Npgsql;
+using PerformanceMonitor.Analysis.Baselines;
+using PerformanceMonitor.Darling.Analysis;
+using PerformanceMonitor.Darling.Storage;
+using Xunit;
+
+namespace Darling.Tests;
+
+///
+/// #4951: collection_log compresses segmented by collector as well as by server, so a read for one collector
+/// decompresses only that collector's batches instead of every collector's runs on the server.
+///
+/// Each test mints its own scratch database: the chunk census and the plan shape are statements about one
+/// store's chunks, and another class's leftovers would change them.
+///
+/// The expected segmentby is a literal here on purpose. These tests pin the value the store ends up with, so they
+/// must not move when the product's constant does.
+///
+/// The "upgraded" stores are built with the statements every build before #4951 ran (create_hypertable, then
+/// the enable ALTER with server_id alone), with their older chunks compressed under those settings.
+///
+[Collection("live-postgres")]
+public sealed class CollectionLogSegmentByLiveTests
+{
+ /// What the store must end with, in the spelling the helper below reads it back in.
+ private const string WantedSegmentBy = "server_id, collector_name";
+
+ /// What every store converted before #4951 has.
+ private const string OldSegmentBy = "server_id";
+
+ /// The same two values as the per-chunk view spells them (comma, no space).
+ private const string WantedChunkSegmentBy = "server_id,collector_name";
+ private const string OldChunkSegmentBy = "server_id";
+
+ private const int ServerA = -495101;
+ private const int ServerB = -495102;
+ private const string ServerName = "SEGMENTBY-SRV";
+ private const string HeldCollectorColumn = "collector_name_held";
+
+ /// Well below any id the generator hands out, so a seeded row can never collide with a real one.
+ private const long LogIdBase = -495_100_000_000L;
+
+ private static readonly string[] Collectors = { "blocked_process_report", "wait_stats", "cpu_utilization" };
+
+ /// The plan test's analysis hour, in the past, so every seeded chunk is old enough to compress. It is the
+ /// coverage-read plan class's own hour, because that class's EXPLAIN helper reads the window at it.
+ private static readonly DateTime AnalysisTime = DarlingEventBaselineCoverageReadPlanShapeLiveTests.AnalysisTime;
+
+ /// Inside the plan test's baseline window.
+ private static readonly DateTime EventTime = DarlingEventBaselineCoverageReadPlanShapeLiveTests.EventTime;
+
+ /* ---------------- fresh stores (condition 1) ---------------- */
+
+ ///
+ /// The usual fresh store: the migrations run before CREATE EXTENSION, so V23's guard skips and the runtime ensure
+ /// both converts collection_log and sets its compression. This one collected on plain PostgreSQL for days before
+ /// the extension arrived, so the conversion moves rows old enough to compress at once. The ensure's first ALTER
+ /// must already be the new setting, so the policy's first run compresses every one of those chunks with it, and
+ /// the store never compresses a chunk under server_id alone.
+ ///
+ [Fact]
+ public async Task AFreshStore_WhoseExtensionComesAfterTheMigrations_GetsTheNewSegmentBy_OnItsFirstConversion()
+ {
+ var baseConnectionString = RequireLivePostgres();
+ var ct = TestContext.Current.CancellationToken;
+ await using var scratch = await ScratchPostgres.CreateAsync(baseConnectionString, ct);
+ using var connection = new NpgsqlConnection(scratch.ConnectionString);
+ await connection.OpenAsync(ct);
+
+ Assert.SkipWhen(
+ await ScalarAsync(connection, "SELECT count(*) FROM pg_extension WHERE extname = 'timescaledb'", ct) > 0,
+ "this cluster's template database already carries TimescaleDB, so a new database cannot start without it; the extension-first test covers that order.");
+
+ await PgMigrations.MigrateAsync(connection, ct);
+ Assert.False((await ReadHypertableAsync(connection, ct)).IsHypertable,
+ "V23 converted collection_log on a store without the extension; this test needs the usual order");
+
+ /* Six days of runs, written while collection_log is still a plain table. */
+ var now = DateTime.SpecifyKind(DateTime.UtcNow, DateTimeKind.Unspecified);
+ await SeedAsync(connection, "collector_name", new[] { ServerA, ServerB }, now.Date.AddDays(-6), now.AddMinutes(-15), ct);
+ var dataBefore = await DataHashAsync(connection, "collector_name", ct);
+
+ Assert.SkipUnless(await LiveTimescaleProbe.TryEnableAsync(scratch.ConnectionString, ct),
+ "TimescaleDB is not available on this cluster.");
+ await StopBackgroundWorkersAsync(connection, ct);
+ await RequirePerChunkSettingsAsync(connection, ct);
+
+ Assert.True(await TimescaleSupport.EnsureCollectionLogHypertableAsync(connection, null, ct));
+
+ var state = await ReadHypertableAsync(connection, ct);
+ Assert.True(state.IsHypertable);
+ Assert.True(state.CompressionEnabled);
+ Assert.Equal(WantedSegmentBy, state.SegmentBy);
+ Assert.Contains(TimescaleSupport.CollectionLogTable, await ConvergedTablesAsync(connection, ct));
+
+ /* The policy's first run compresses the converted rows' older chunks, every one with the new value, and
+ changes no row. */
+ await RunCompressionPolicyAsync(connection, ct);
+ var chunks = await ReadChunkSettingsAsync(connection, ct);
+ Assert.NotEmpty(chunks);
+ Assert.All(chunks, chunk => Assert.Equal(WantedChunkSegmentBy, chunk.SegmentBy));
+ Assert.Equal(dataBefore, await DataHashAsync(connection, "collector_name", ct));
+ }
+
+ ///
+ /// The other fresh-store order: TimescaleDB is installed before the migrations run (a bring-your-own store whose
+ /// administrator created it first), so V23 does the first conversion, with the server_id its text has always
+ /// had (a migration is never edited). V23's policy has no start time, so the scheduler runs it as soon as it is
+ /// added; on a new store that run finds an empty table. The first start's ensure then moves the hypertable to the
+ /// new value before any row is old enough to compress, so every chunk the policy compresses later uses the new
+ /// value and none uses server_id alone.
+ ///
+ [Fact]
+ public async Task AFreshStore_WhoseExtensionPredatesTheMigrations_HasTheNewSegmentBy_AfterItsFirstStart_AndNoServerIdOnlyChunk()
+ {
+ var baseConnectionString = RequireLivePostgres();
+ var ct = TestContext.Current.CancellationToken;
+ await using var scratch = await ScratchPostgres.CreateAsync(baseConnectionString, ct);
+
+ Assert.SkipUnless(await LiveTimescaleProbe.TryEnableAsync(scratch.ConnectionString, ct),
+ "TimescaleDB is not available on this cluster.");
+
+ using var connection = new NpgsqlConnection(scratch.ConnectionString);
+ await connection.OpenAsync(ct);
+ await StopBackgroundWorkersAsync(connection, ct);
+ await RequirePerChunkSettingsAsync(connection, ct);
+ await PgMigrations.MigrateAsync(connection, ct);
+
+ /* V23's own work: converted, compression on, with its text's server_id. */
+ var afterMigrations = await ReadHypertableAsync(connection, ct);
+ Assert.True(afterMigrations.IsHypertable, "V23 did not convert collection_log although the extension existed when it ran");
+ Assert.True(afterMigrations.CompressionEnabled);
+ Assert.Equal(OldSegmentBy, afterMigrations.SegmentBy);
+
+ /* V23's policy's immediate first run (the background workers are stopped, so it runs here). A new store's
+ table is empty, so it compresses nothing under server_id. */
+ await RunCompressionPolicyAsync(connection, ct);
+ Assert.Empty(await ReadChunkSettingsAsync(connection, ct));
+
+ /* The first start. */
+ Assert.True(await TimescaleSupport.EnsureCollectionLogHypertableAsync(connection, null, ct));
+ Assert.Equal(WantedSegmentBy, (await ReadHypertableAsync(connection, ct)).SegmentBy);
+ Assert.Contains(TimescaleSupport.CollectionLogTable, await ConvergedTablesAsync(connection, ct));
+
+ /* Days of runs later, the policy compresses the older chunks, every one with the new value. */
+ var now = DateTime.SpecifyKind(DateTime.UtcNow, DateTimeKind.Unspecified);
+ await SeedAsync(connection, "collector_name", new[] { ServerA, ServerB }, now.Date.AddDays(-6), now.AddMinutes(-15), ct);
+ await RunCompressionPolicyAsync(connection, ct);
+ var chunks = await ReadChunkSettingsAsync(connection, ct);
+ Assert.NotEmpty(chunks);
+ Assert.All(chunks, chunk => Assert.Equal(WantedChunkSegmentBy, chunk.SegmentBy));
+ }
+
+ /* ---------------- upgraded stores ---------------- */
+
+ ///
+ /// An upgraded store whose chunks are already compressed under server_id. The ensure moves the hypertable to
+ /// the new value, leaves every compressed chunk as it was, and from then on reads as converged WHILE those old
+ /// chunks remain, so the hourly pass issues no ALTER. The settings read looks at the hypertable's own row, never at
+ /// a chunk's.
+ ///
+ [Fact]
+ public async Task AnUpgradedStore_MovesTheHypertableToTheNewSegmentBy_LeavesOldChunksAlone_AndReadsAsConverged()
+ {
+ var baseConnectionString = RequireLivePostgres();
+ var ct = TestContext.Current.CancellationToken;
+ await using var scratch = await ScratchPostgres.CreateAsync(baseConnectionString, ct);
+ using var connection = new NpgsqlConnection(scratch.ConnectionString);
+ await connection.OpenAsync(ct);
+ await BuildUpgradedLogAsync(connection, scratch.ConnectionString, "collector_name", ct);
+
+ var chunksBefore = await ReadChunkSettingsAsync(connection, ct);
+ Assert.NotEmpty(chunksBefore);
+ Assert.All(chunksBefore, chunk => Assert.Equal(OldChunkSegmentBy, chunk.SegmentBy));
+ var dataBefore = await DataHashAsync(connection, "collector_name", ct);
+
+ Assert.True(await TimescaleSupport.EnsureCollectionLogHypertableAsync(connection, null, ct));
+
+ Assert.Equal(WantedSegmentBy, (await ReadHypertableAsync(connection, ct)).SegmentBy);
+ Assert.Equal(chunksBefore, await ReadChunkSettingsAsync(connection, ct));
+ Assert.Equal(dataBefore, await DataHashAsync(connection, "collector_name", ct));
+ Assert.Contains(TimescaleSupport.CollectionLogTable, await ConvergedTablesAsync(connection, ct));
+
+ /* A second pass is a no-op on the same store. */
+ Assert.True(await TimescaleSupport.EnsureCollectionLogHypertableAsync(connection, null, ct));
+ Assert.Equal(chunksBefore, await ReadChunkSettingsAsync(connection, ct));
+ }
+
+ ///
+ /// The pin behind "a converged store takes no ACCESS EXCLUSIVE lock each hour". With the setting converged and old
+ /// chunks still present, another session holds a lock that any ALTER would have to wait behind. The pass must
+ /// finish without waiting and report success: had it issued the ALTER, it would have waited out its lock timeout
+ /// and reported the busy lock instead.
+ ///
+ [Fact]
+ public async Task AConvergedStore_IssuesNoAlter_SoAHeldLockDoesNotHoldUpTheHourlyPass()
+ {
+ var baseConnectionString = RequireLivePostgres();
+ var ct = TestContext.Current.CancellationToken;
+ await using var scratch = await ScratchPostgres.CreateAsync(baseConnectionString, ct);
+ using var connection = new NpgsqlConnection(scratch.ConnectionString);
+ await connection.OpenAsync(ct);
+ await BuildUpgradedLogAsync(connection, scratch.ConnectionString, "collector_name", ct);
+
+ Assert.True(await TimescaleSupport.EnsureCollectionLogHypertableAsync(connection, null, ct));
+ Assert.Equal(WantedSegmentBy, (await ReadHypertableAsync(connection, ct)).SegmentBy);
+
+ await using var holder = new NpgsqlConnection(scratch.ConnectionString);
+ await holder.OpenAsync(ct);
+ await using var hold = await holder.BeginTransactionAsync(ct);
+ await ExecAsync(holder, "SELECT count(*) FROM collection_log", ct, hold);
+
+ var logger = new CapturingTestLogger();
+ using var bounded = CancellationTokenSource.CreateLinkedTokenSource(ct);
+ bounded.CancelAfter(TimeSpan.FromSeconds(60));
+ var elapsed = Stopwatch.StartNew();
+ var ok = await TimescaleSupport.EnsureCollectionLogHypertableAsync(connection, logger, bounded.Token);
+ elapsed.Stop();
+
+ Assert.True(ok, $"a converged pass did not finish cleanly beside a reader's lock (it took {elapsed.Elapsed}): {logger.Joined}");
+ Assert.Equal(0, logger.CountAtLevel(Microsoft.Extensions.Logging.LogLevel.Warning));
+ Assert.DoesNotContain(logger.Lines, line => line.Contains("compression settings", StringComparison.Ordinal));
+ Assert.Equal(WantedSegmentBy, (await ReadHypertableAsync(connection, ct)).SegmentBy);
+
+ await hold.RollbackAsync(ct);
+ }
+
+ /* ---------------- the lock (M1) ---------------- */
+
+ ///
+ /// A long reader holds collection_log while the change is due. The ALTER must give up at its lock timeout instead
+ /// of waiting (and queueing every collector's insert behind it): the pass returns false, logs at Information that
+ /// the next hourly pass tries again, changes nothing, and leaves the connection usable. Once the reader lets go,
+ /// the next pass applies the change.
+ ///
+ [Fact]
+ public async Task AHeldLock_BoundsTheAlter_TheStoreIsUnchanged_AndTheNextPassAppliesIt()
+ {
+ var baseConnectionString = RequireLivePostgres();
+ var ct = TestContext.Current.CancellationToken;
+ await using var scratch = await ScratchPostgres.CreateAsync(baseConnectionString, ct);
+ using var connection = new NpgsqlConnection(scratch.ConnectionString);
+ await connection.OpenAsync(ct);
+ await BuildUpgradedLogAsync(connection, scratch.ConnectionString, "collector_name", ct);
+ var chunksBefore = await ReadChunkSettingsAsync(connection, ct);
+
+ await using var holder = new NpgsqlConnection(scratch.ConnectionString);
+ await holder.OpenAsync(ct);
+ var hold = await holder.BeginTransactionAsync(ct);
+ await ExecAsync(holder, "SELECT count(*) FROM collection_log", ct, hold);
+
+ var logger = new CapturingTestLogger();
+ bool ok;
+ var elapsed = Stopwatch.StartNew();
+ using (var bounded = CancellationTokenSource.CreateLinkedTokenSource(ct))
+ {
+ bounded.CancelAfter(TimeSpan.FromSeconds(60));
+ ok = await TimescaleSupport.EnsureCollectionLogHypertableAsync(connection, logger, bounded.Token);
+ }
+
+ elapsed.Stop();
+
+ Assert.False(ok, $"the pass reported success while another session held collection_log: {logger.Joined}");
+ Assert.True(elapsed.Elapsed < TimeSpan.FromSeconds(30), $"the ALTER waited {elapsed.Elapsed} behind the held lock: {logger.Joined}");
+ Assert.Contains(logger.Lines, line =>
+ line.StartsWith("Information:", StringComparison.Ordinal)
+ && line.Contains("collection_log's compression settings", StringComparison.Ordinal)
+ && line.Contains("next hourly pass tries again", StringComparison.Ordinal));
+ Assert.Equal(0, logger.CountAtLevel(Microsoft.Extensions.Logging.LogLevel.Warning));
+ Assert.Equal(OldSegmentBy, (await ReadHypertableAsync(connection, ct)).SegmentBy);
+ Assert.Equal(chunksBefore, await ReadChunkSettingsAsync(connection, ct));
+ Assert.Equal(1, await ScalarAsync(connection, "SELECT 1", ct));
+
+ await hold.RollbackAsync(ct);
+ await hold.DisposeAsync();
+
+ Assert.True(await TimescaleSupport.EnsureCollectionLogHypertableAsync(connection, null, ct));
+ Assert.Equal(WantedSegmentBy, (await ReadHypertableAsync(connection, ct)).SegmentBy);
+ }
+
+ /* ---------------- any other failure (condition 2) ---------------- */
+
+ ///
+ /// The ALTER fails for a reason other than a lock: here the store's collector column has another name, so the new
+ /// segmentby names a column that does not exist. The change rolls back and leaves the store exactly as it was. The
+ /// log says the settings change failed and the table keeps its settings, not that "setup failed" and the table
+ /// "stays a plain table" (it is a hypertable). The failure does not cost the pass its policy step: the table is
+ /// still compressible under its current setting, and no other step adds a missing compression policy. The
+ /// connection stays usable for the step after it, and the next pass tries again rather than remembering the failure.
+ ///
+ [Fact]
+ public async Task AFailedAlter_LeavesTheStoreAsItWas_LogsWhatHappened_AndIsRetriedNextPass()
+ {
+ var baseConnectionString = RequireLivePostgres();
+ var ct = TestContext.Current.CancellationToken;
+ await using var scratch = await ScratchPostgres.CreateAsync(baseConnectionString, ct);
+ using var connection = new NpgsqlConnection(scratch.ConnectionString);
+ await connection.OpenAsync(ct);
+ await BuildUpgradedLogAsync(connection, scratch.ConnectionString, HeldCollectorColumn, ct);
+
+ var chunksBefore = await ReadChunkSettingsAsync(connection, ct);
+ Assert.NotEmpty(chunksBefore);
+ var dataBefore = await DataHashAsync(connection, HeldCollectorColumn, ct);
+ Assert.Equal(0, await CompressionPolicyCountAsync(connection, ct));
+
+ var logger = new CapturingTestLogger();
+ Assert.False(await TimescaleSupport.EnsureCollectionLogHypertableAsync(connection, logger, ct),
+ $"the pass reported success although the new segmentby names a column this store does not have: {logger.Joined}");
+
+ Assert.Equal(1, CountSettingsFailures(logger));
+ Assert.DoesNotContain("plain table", logger.Joined, StringComparison.Ordinal);
+ Assert.Equal(OldSegmentBy, (await ReadHypertableAsync(connection, ct)).SegmentBy);
+ Assert.Equal(chunksBefore, await ReadChunkSettingsAsync(connection, ct));
+ Assert.Equal(dataBefore, await DataHashAsync(connection, HeldCollectorColumn, ct));
+ Assert.True(1 == await CompressionPolicyCountAsync(connection, ct),
+ $"the failed settings change skipped the policy step, so a store without a compression policy never gets one: {logger.Joined}");
+
+ /* The connection is not left inside a failed transaction: the next statement and the next step both run. */
+ Assert.Equal(1, await ScalarAsync(connection, "SELECT 1", ct));
+ await TimescaleSupport.ConvergeCompressionScheduleAsync(connection, null, ct);
+
+ /* The next pass tries again. */
+ Assert.False(await TimescaleSupport.EnsureCollectionLogHypertableAsync(connection, logger, ct));
+ Assert.Equal(2, CountSettingsFailures(logger));
+ Assert.Equal(chunksBefore, await ReadChunkSettingsAsync(connection, ct));
+ }
+
+ /* ---------------- answers and plan ---------------- */
+
+ ///
+ /// "New chunks only" leaves old and new chunks mixed until retention removes the old ones. Every read below must
+ /// return the same rows over the mix as it did when all chunks had the old settings.
+ ///
+ [Fact]
+ public async Task OldAndNewChunks_Mixed_ReturnTheSameRowsAsBefore()
+ {
+ var baseConnectionString = RequireLivePostgres();
+ var ct = TestContext.Current.CancellationToken;
+ await using var scratch = await ScratchPostgres.CreateAsync(baseConnectionString, ct);
+ using var connection = new NpgsqlConnection(scratch.ConnectionString);
+ await connection.OpenAsync(ct);
+ await BuildUpgradedLogAsync(connection, scratch.ConnectionString, "collector_name", ct);
+
+ var asOf = await ScalarAsync(connection, "SELECT max(collection_time) FROM collection_log", ct);
+ var before = await RunReadsAsync(connection, asOf, ct);
+
+ Assert.True(await TimescaleSupport.EnsureCollectionLogHypertableAsync(connection, null, ct));
+ await CompressChunksAsync(connection, olderThanDays: 1, ct);
+
+ var segmentBys = (await ReadChunkSettingsAsync(connection, ct)).Select(chunk => chunk.SegmentBy).Distinct().ToList();
+ Assert.Contains(OldChunkSegmentBy, segmentBys);
+ Assert.Contains(WantedChunkSegmentBy, segmentBys);
+
+ var after = await RunReadsAsync(connection, asOf, ct);
+ Assert.Equal(before.Keys, after.Keys);
+ foreach (var (read, rows) in before)
+ {
+ Assert.True(rows == after[read], $"'{read}' returned different rows once old and new chunks were mixed");
+ }
+ }
+
+ ///
+ /// The read this change is for. The blocking baseline's coverage read (the logged CTE) asks for one
+ /// collector on one server; with collector_name in the segmentby, that condition must reach the compressed chunk's
+ /// own scan, so only that collector's batches are decompressed. Before the change only server_id reached it,
+ /// and the collector was filtered after every collector's rows were decompressed.
+ ///
+ [Fact]
+ public async Task TheEventBaselineRead_DecompressesOnlyItsCollectorsSegment()
+ {
+ var baseConnectionString = RequireLivePostgres();
+ var ct = TestContext.Current.CancellationToken;
+ await using var scratch = await ScratchPostgres.CreateAsync(baseConnectionString, ct);
+ using var connection = new NpgsqlConnection(scratch.ConnectionString);
+ await connection.OpenAsync(ct);
+ await PgMigrations.MigrateAsync(connection, ct);
+
+ Assert.SkipUnless(await LiveTimescaleProbe.TryEnableAsync(scratch.ConnectionString, ct),
+ "TimescaleDB is not available on this cluster; the compressed-chunk plan shape needs it.");
+ Assert.True(await TimescaleSupport.EnsureCollectionLogHypertableAsync(connection, null, ct));
+ await StopBackgroundWorkersAsync(connection, ct);
+ await TimescaleSupport.EnsureBaselineFallbackViewsAsync(connection, null, ct);
+
+ await SeedAsync(connection, "collector_name", new[] { ServerA }, AnalysisTime.AddDays(-35), AnalysisTime.AddMinutes(-15), ct);
+ await SeedEventAsync(connection, ServerA, ct);
+ await CompressChunksAsync(connection, olderThanDays: 1, ct);
+ Assert.True(await ScalarAsync(connection,
+ "SELECT count(*) FROM timescaledb_information.chunks WHERE hypertable_name = 'collection_log' AND is_compressed", ct) >= 1,
+ "the seed produced no compressed collection_log chunk, so the plan would prove nothing");
+
+ var plan = await ExplainBlockingBaselineAsync(connection, ServerA, ct);
+ var compressedScans = CompressedScans(plan).Where(scan => scan.GetProperty("Actual Loops").GetDouble() > 0).ToList();
+ Assert.True(compressedScans.Count > 0, $"the plan reads no compressed chunk:\n{plan.GetRawText()}");
+ Assert.True(
+ compressedScans.All(scan => Conditions(scan).Contains("(collector_name = ", StringComparison.Ordinal)),
+ $"the collector condition does not reach the compressed chunk's own scan:\n{plan.GetRawText()}");
+ }
+
+ /* ---------------- fixture ---------------- */
+
+ private static string RequireLivePostgres()
+ {
+ var connectionString = Environment.GetEnvironmentVariable("DARLING_TEST_PG");
+ Assert.SkipWhen(string.IsNullOrEmpty(connectionString),
+ "Set DARLING_TEST_PG to a Postgres connection string to run the collection_log segmentby tests (each mints its own scratch database).");
+ return connectionString!;
+ }
+
+ ///
+ /// Skips on a TimescaleDB these tests cannot run on. Before 2.14 a hypertable's compression settings cannot change
+ /// while compressed chunks exist, so the product keeps such a store on server_id by design; and a TimescaleDB
+ /// without the per-chunk settings view gives these tests nothing to read. Literal version, like the segmentby
+ /// literals above: the tests pin the floor rather than follow the product's constant.
+ ///
+ private static async Task RequirePerChunkSettingsAsync(NpgsqlConnection connection, CancellationToken ct)
+ {
+ var version = await TimescaleSupport.ReadTimescaleVersionAsync(connection, null, ct);
+ var hasView = await ScalarAsync(connection,
+ "SELECT to_regclass('timescaledb_information.chunk_compression_settings') IS NOT NULL", ct);
+ Assert.SkipUnless(version is not null && version >= new Version(2, 14) && hasView,
+ $"TimescaleDB {version?.ToString() ?? "(version unknown)"} predates per-chunk compression settings (2.14), which these tests read and change.");
+ }
+
+ ///
+ /// collection_log as every build before #4951 left it: a hypertable compressed by server_id alone, six days
+ /// of runs from two servers and three collectors, and every chunk older than three days compressed. The two newest
+ /// closed chunks stay uncompressed, so a test can compress them under the new settings.
+ /// other than collector_name renames that column BEFORE the conversion, which is how the failure test makes
+ /// the new segmentby name a column the store does not have.
+ ///
+ private static async Task BuildUpgradedLogAsync(NpgsqlConnection connection, string connectionString, string collectorColumn, CancellationToken ct)
+ {
+ await PgMigrations.MigrateAsync(connection, ct);
+
+ if (!string.Equals(collectorColumn, "collector_name", StringComparison.Ordinal))
+ {
+ await ExecAsync(connection, $"ALTER TABLE collection_log RENAME COLUMN collector_name TO {collectorColumn}", ct);
+ }
+
+ Assert.SkipUnless(await LiveTimescaleProbe.TryEnableAsync(connectionString, ct), "TimescaleDB is not available on this cluster.");
+ await StopBackgroundWorkersAsync(connection, ct);
+ await RequirePerChunkSettingsAsync(connection, ct);
+
+ await ExecAsync(connection, TimescaleSupport.CreateHypertableSql(TimescaleSupport.CollectionLogTable, TimescaleSupport.CollectionLogTimeColumn), ct);
+ await ExecAsync(connection, "ALTER TABLE collection_log SET (timescaledb.compress, timescaledb.compress_segmentby = 'server_id')", ct);
+
+ var now = DateTime.SpecifyKind(DateTime.UtcNow, DateTimeKind.Unspecified);
+ await SeedAsync(connection, collectorColumn, new[] { ServerA, ServerB }, now.Date.AddDays(-6), now.AddMinutes(-15), ct);
+ await CompressChunksAsync(connection, olderThanDays: 3, ct);
+
+ Assert.Equal(OldSegmentBy, (await ReadHypertableAsync(connection, ct)).SegmentBy);
+ }
+
+ /// One run every 15 minutes per server and collector; every tenth run failed, so status filters matter.
+ private static async Task SeedAsync(
+ NpgsqlConnection connection, string collectorColumn, int[] servers, DateTime from, DateTime to, CancellationToken ct)
+ {
+ using var command = new NpgsqlCommand(
+ $@"INSERT INTO collection_log (log_id, server_id, server_name, {collectorColumn}, collection_time, status, rows_collected)
+ SELECT $1 - row_number() OVER (ORDER BY s.server_id, c.collector, g.t), s.server_id, $2, c.collector, g.t,
+ CASE WHEN (extract(epoch FROM g.t)::bigint / 900) % 10 = 0 THEN 'ERROR' ELSE 'SUCCESS' END,
+ (extract(epoch FROM g.t)::bigint / 900) % 7
+ FROM unnest($3::integer[]) AS s(server_id)
+ CROSS JOIN unnest($4::text[]) AS c(collector)
+ CROSS JOIN generate_series($5::timestamp, $6::timestamp, INTERVAL '15 minutes') AS g(t)", connection);
+ command.Parameters.AddWithValue(LogIdBase);
+ command.Parameters.AddWithValue(ServerName);
+ command.Parameters.AddWithValue(servers);
+ command.Parameters.AddWithValue(Collectors);
+ command.Parameters.AddWithValue(from);
+ command.Parameters.AddWithValue(to);
+ await command.ExecuteNonQueryAsync(ct);
+ }
+
+ private static async Task SeedEventAsync(NpgsqlConnection connection, int serverId, CancellationToken ct)
+ {
+ using var command = new NpgsqlCommand(
+ "INSERT INTO blocked_process_reports (blocked_report_id, collection_time, server_id, server_name, event_time) VALUES ($1, $2, $3, $4, $5)",
+ connection);
+ command.Parameters.AddWithValue(LogIdBase);
+ command.Parameters.AddWithValue(EventTime);
+ command.Parameters.AddWithValue(serverId);
+ command.Parameters.AddWithValue(ServerName);
+ command.Parameters.AddWithValue(EventTime);
+ await command.ExecuteNonQueryAsync(ct);
+ }
+
+ /// Compresses every not-yet-compressed chunk that closed more than days ago.
+ private static async Task CompressChunksAsync(NpgsqlConnection connection, int olderThanDays, CancellationToken ct)
+ {
+ var chunks = new List();
+ using (var list = new NpgsqlCommand(
+ "SELECT format('%I.%I', chunk_schema, chunk_name) FROM timescaledb_information.chunks "
+ + "WHERE hypertable_schema = 'collect' AND hypertable_name = 'collection_log' AND NOT is_compressed "
+ + "AND range_end <= now() - make_interval(days => $1) ORDER BY range_start", connection))
+ {
+ list.Parameters.AddWithValue(olderThanDays);
+ await using var reader = await list.ExecuteReaderAsync(ct);
+ while (await reader.ReadAsync(ct))
+ {
+ chunks.Add(reader.GetString(0));
+ }
+ }
+
+ foreach (var chunk in chunks)
+ {
+ await ExecAsync(connection, $"SELECT compress_chunk('{chunk}')", ct);
+ }
+ }
+
+ private static Task StopBackgroundWorkersAsync(NpgsqlConnection connection, CancellationToken ct) =>
+ ExecAsync(connection, "SELECT _timescaledb_functions.stop_background_workers()", ct);
+
+ /// collection_log's compression policy jobs: one row per policy, and TimescaleDB allows at most one.
+ private const string CompressionPolicyJobsSql =
+ "FROM timescaledb_information.jobs WHERE proc_name = 'policy_compression' "
+ + "AND hypertable_schema = 'collect' AND hypertable_name = 'collection_log'";
+
+ private static Task CompressionPolicyCountAsync(NpgsqlConnection connection, CancellationToken ct) =>
+ ScalarAsync(connection, "SELECT count(*) " + CompressionPolicyJobsSql, ct);
+
+ /// One run of collection_log's compression policy in this session, as the scheduler runs it: it compresses
+ /// every chunk older than the policy's compress-after with the settings the hypertable has at that moment.
+ private static async Task RunCompressionPolicyAsync(NpgsqlConnection connection, CancellationToken ct)
+ {
+ int? jobId;
+ using (var find = new NpgsqlCommand("SELECT job_id " + CompressionPolicyJobsSql, connection))
+ {
+ jobId = await find.ExecuteScalarAsync(ct) as int?;
+ }
+
+ Assert.True(jobId.HasValue, "collection_log has no compression policy to run");
+ await ExecAsync(connection, $"CALL run_job({jobId.Value})", ct);
+ }
+
+ /* ---------------- reads of the store ---------------- */
+
+ private sealed record HypertableState(bool IsHypertable, bool CompressionEnabled, string? SegmentBy);
+
+ private sealed record ChunkSetting(string Chunk, string SegmentBy);
+
+ /// collection_log's own settings row: whether it is a hypertable, whether compression is on, and its
+ /// segmentby columns in order, joined with ", ".
+ private static async Task ReadHypertableAsync(NpgsqlConnection connection, CancellationToken ct)
+ {
+ using var command = new NpgsqlCommand(
+ @"SELECT h.compression_enabled,
+ (SELECT string_agg(cs.attname, ', ' ORDER BY cs.segmentby_column_index)
+ FROM timescaledb_information.compression_settings AS cs
+ WHERE cs.hypertable_schema = h.hypertable_schema
+ AND cs.hypertable_name = h.hypertable_name
+ AND cs.segmentby_column_index IS NOT NULL)
+ FROM timescaledb_information.hypertables AS h
+ WHERE h.hypertable_schema = 'collect' AND h.hypertable_name = 'collection_log'", connection);
+ try
+ {
+ await using var reader = await command.ExecuteReaderAsync(ct);
+ if (!await reader.ReadAsync(ct))
+ {
+ return new HypertableState(false, false, null);
+ }
+
+ return new HypertableState(true, !reader.IsDBNull(0) && reader.GetBoolean(0), reader.IsDBNull(1) ? null : reader.GetString(1));
+ }
+ catch (PostgresException ex) when (ex.SqlState == PostgresErrorCodes.UndefinedTable)
+ {
+ /* No extension, so no timescaledb_information schema: not a hypertable. */
+ return new HypertableState(false, false, null);
+ }
+ }
+
+ /// Every compressed chunk of collection_log with the segmentby it was compressed under, by chunk name.
+ private static async Task> ReadChunkSettingsAsync(NpgsqlConnection connection, CancellationToken ct)
+ {
+ using var command = new NpgsqlCommand(
+ "SELECT chunk::text, coalesce(segmentby, '') FROM timescaledb_information.chunk_compression_settings "
+ + "WHERE hypertable = 'collect.collection_log'::regclass ORDER BY chunk::text", connection);
+ var chunks = new List();
+ await using var reader = await command.ExecuteReaderAsync(ct);
+ while (await reader.ReadAsync(ct))
+ {
+ chunks.Add(new ChunkSetting(reader.GetString(0), reader.GetString(1)));
+ }
+
+ return chunks;
+ }
+
+ /// The product's own convergence read: the collect tables whose compression is already what it wants.
+ private static async Task> ConvergedTablesAsync(NpgsqlConnection connection, CancellationToken ct) =>
+ await TimescaleSupport.ReadTablesNeedingCompressionEnableAsync(connection, null, ct)
+ ?? throw new InvalidOperationException("the convergence read failed");
+
+ /// One md5 over every row, in log_id order: unchanged data gives the same hash.
+ private static Task DataHashAsync(NpgsqlConnection connection, string collectorColumn, CancellationToken ct) =>
+ ScalarAsync(connection,
+ $"SELECT md5(string_agg(concat_ws('|', log_id, server_id, {collectorColumn}, collection_time, status, rows_collected), ',' ORDER BY log_id)) FROM collection_log",
+ ct);
+
+ /// The read shapes the change can affect, each rendered to one string: every row, a per-collector window, the
+ /// newest run per collector, a fleet-wide aggregate, and the "any earlier success" probe.
+ private static async Task> RunReadsAsync(NpgsqlConnection connection, DateTime asOf, CancellationToken ct)
+ {
+ var reads = new (string Name, string Sql, object[] Args)[]
+ {
+ ("every row",
+ "SELECT log_id, server_id, collector_name, collection_time, status, rows_collected FROM collection_log ORDER BY log_id",
+ Array.Empty()),
+ ("one collector, 5 days",
+ "SELECT collection_time, status, rows_collected FROM collection_log WHERE server_id = $1 AND collector_name = 'wait_stats' AND collection_time >= $2 - INTERVAL '5 days' ORDER BY collection_time, log_id",
+ new object[] { ServerA, asOf }),
+ ("newest run per collector",
+ "SELECT DISTINCT ON (collector_name) collector_name, collection_time, status FROM collection_log WHERE server_id = $1 ORDER BY collector_name, collection_time DESC, log_id DESC",
+ new object[] { ServerA }),
+ ("fleet aggregate",
+ "SELECT server_id, collector_name, count(*), min(collection_time), max(collection_time), sum(rows_collected), count(*) FILTER (WHERE status = 'SUCCESS') FROM collection_log WHERE collection_time <= $1 GROUP BY server_id, collector_name ORDER BY server_id, collector_name",
+ new object[] { asOf }),
+ ("any earlier success",
+ "SELECT EXISTS (SELECT 1 FROM collection_log WHERE server_id = $1 AND collector_name = 'cpu_utilization' AND status = 'SUCCESS' AND collection_time < $2 - INTERVAL '4 days')",
+ new object[] { ServerA, asOf }),
+ };
+
+ var results = new Dictionary(StringComparer.Ordinal);
+ foreach (var (name, sql, args) in reads)
+ {
+ using var command = new NpgsqlCommand(sql, connection);
+ foreach (var arg in args)
+ {
+ command.Parameters.AddWithValue(arg);
+ }
+
+ var text = new StringBuilder();
+ await using var reader = await command.ExecuteReaderAsync(ct);
+ while (await reader.ReadAsync(ct))
+ {
+ for (var i = 0; i < reader.FieldCount; i++)
+ {
+ text.Append(reader.IsDBNull(i) ? "NULL" : Convert.ToString(reader.GetValue(i), System.Globalization.CultureInfo.InvariantCulture)).Append('|');
+ }
+
+ text.Append('\n');
+ }
+
+ results[name] = text.ToString();
+ }
+
+ return results;
+ }
+
+ /// The blocking baseline as the provider runs it, through the coverage-read plan class's own EXPLAIN helper
+ /// (one copy of the six bound parameters, not two).
+ private static Task ExplainBlockingBaselineAsync(NpgsqlConnection connection, int serverId, CancellationToken ct) =>
+ DarlingEventBaselineCoverageReadPlanShapeLiveTests.ExplainAsync(
+ connection, PgBaselineProvider.GetBaselineQuery(MetricNames.Blocking)!, serverId, ct);
+
+ /// Every scan of a chunk's compressed relation, by the coverage-read plan class's own relation test.
+ private static List CompressedScans(JsonElement plan)
+ {
+ var found = new List();
+
+ void Visit(JsonElement node)
+ {
+ if (node.TryGetProperty("Relation Name", out var relation)
+ && DarlingEventBaselineCoverageReadPlanShapeLiveTests.IsCompressedRelation(relation.GetString()!))
+ {
+ found.Add(node);
+ }
+
+ if (node.TryGetProperty("Plans", out var children))
+ {
+ foreach (var child in children.EnumerateArray())
+ {
+ Visit(child);
+ }
+ }
+ }
+
+ Visit(plan);
+ return found;
+ }
+
+ /// A scan's own conditions and its descendants', by the coverage-read plan class's helper. On the old layout
+ /// the compressed scan's only collector condition is a bloom filter on a metadata column, which never contains
+ /// "(collector_name = ".
+ private static string Conditions(JsonElement node) => DarlingEventBaselineCoverageReadPlanShapeLiveTests.Conditions(node);
+
+ /// Warning lines saying the settings change failed and the table kept its settings.
+ private static int CountSettingsFailures(CapturingTestLogger logger) =>
+ logger.Lines.Count(line =>
+ line.StartsWith("Warning:", StringComparison.Ordinal)
+ && line.Contains("collection_log's compression settings", StringComparison.Ordinal)
+ && line.Contains("keep their current settings", StringComparison.Ordinal)
+ && line.Contains("next hourly pass tries again", StringComparison.Ordinal));
+
+ private static async Task ExecAsync(NpgsqlConnection connection, string sql, CancellationToken ct, NpgsqlTransaction? transaction = null)
+ {
+ using var command = new NpgsqlCommand(sql, connection, transaction);
+ await command.ExecuteNonQueryAsync(ct);
+ }
+
+ private static async Task ScalarAsync(NpgsqlConnection connection, string sql, CancellationToken ct)
+ {
+ using var command = new NpgsqlCommand(sql, connection);
+ var value = await command.ExecuteScalarAsync(ct);
+ return (T)Convert.ChangeType(value!, typeof(T), System.Globalization.CultureInfo.InvariantCulture);
+ }
+}
diff --git a/Darling/Darling.Tests/DarlingEventBaselineCoverageReadPlanShapeLiveTests.cs b/Darling/Darling.Tests/DarlingEventBaselineCoverageReadPlanShapeLiveTests.cs
index 3274cbdd13..a7da154edd 100644
--- a/Darling/Darling.Tests/DarlingEventBaselineCoverageReadPlanShapeLiveTests.cs
+++ b/Darling/Darling.Tests/DarlingEventBaselineCoverageReadPlanShapeLiveTests.cs
@@ -30,7 +30,9 @@ namespace Darling.Tests;
/// same statement without the gate is the control: its log scans do run.
///
/// (b) A server with events reads only its own segment of a compressed chunk. The log is compressed segmented by
-/// server_id, so the server_id = $1 condition has to reach the scan of the compressed chunk itself (the
+/// server_id (and, since #4951, by collector_name as well; CollectionLogSegmentByLiveTests pins that
+/// the collector condition reaches the same scan, through this class's plan helpers), so the server_id = $1
+/// condition has to reach the scan of the compressed chunk itself (the
/// _hyper_N_M_chunk_compressed relation on a fresh store, the old compress_hyper_N_M_chunk one on an
/// upgraded store), not be applied to rows already decompressed for every server.
///
@@ -49,10 +51,11 @@ public sealed class DarlingEventBaselineCoverageReadPlanShapeLiveTests
/// Well below any id the generator hands out, so a seeded row can never collide with a real one.
private const long LogIdBase = -473_100_000_000L;
- private static readonly DateTime AnalysisTime = new(2026, 3, 4, 14, 0, 0, DateTimeKind.Unspecified);
+ /// The analysis hour reads the window at; a class that reuses it seeds around this.
+ internal static readonly DateTime AnalysisTime = new(2026, 3, 4, 14, 0, 0, DateTimeKind.Unspecified);
/// Inside the window, on the busy server only.
- private static readonly DateTime EventTime = new(2026, 2, 8, 9, 10, 0, DateTimeKind.Unspecified);
+ internal static readonly DateTime EventTime = new(2026, 2, 8, 9, 10, 0, DateTimeKind.Unspecified);
[Fact]
public async Task TheBlockingArm_NeverReadsTheLogForAServerWithoutEvents_AndKeepsTheServerFilterOnTheCompressedScan()
@@ -168,7 +171,7 @@ private static bool IsLogRelation(string relation) =>
/// A chunk's compressed relation: _hyper_N_M_chunk_compressed on a fresh 2.30.1 store, the old
/// compress_hyper_N_M_chunk name on an upgraded one (see StoreSelfMetrics).
- private static bool IsCompressedRelation(string relation) =>
+ internal static bool IsCompressedRelation(string relation) =>
relation.StartsWith("compress_hyper_", StringComparison.Ordinal)
|| relation.EndsWith("_chunk_compressed", StringComparison.Ordinal);
@@ -176,7 +179,7 @@ private static bool IsCompressedRelation(string relation) =>
private static double Loops(JsonElement node) => node.GetProperty("Actual Loops").GetDouble();
/// The node's own conditions and its descendants' (a bitmap scan carries its index condition one node down).
- private static string Conditions(JsonElement node)
+ internal static string Conditions(JsonElement node)
{
var text = new List();
@@ -204,7 +207,7 @@ void Collect(JsonElement current)
}
/// The statement as the provider runs it: the six bound parameters of an unkeyed arm, over the day-grain window.
- private static async Task ExplainAsync(NpgsqlConnection connection, string sql, int serverId, CancellationToken ct)
+ internal static async Task ExplainAsync(NpgsqlConnection connection, string sql, int serverId, CancellationToken ct)
{
var windowEnd = PgBaselineProvider.RoundedDay(AnalysisTime);
var windowStart = windowEnd.AddDays(-BaselineMath.BaselineWindowDays);
diff --git a/Darling/Darling.Tests/DarlingObservabilityTests.cs b/Darling/Darling.Tests/DarlingObservabilityTests.cs
index 26a6405115..2e98f6d7da 100644
--- a/Darling/Darling.Tests/DarlingObservabilityTests.cs
+++ b/Darling/Darling.Tests/DarlingObservabilityTests.cs
@@ -492,7 +492,9 @@ runtime path heals it (pinned in TimescaleSupportTests). collection_log is NOT i
Assert.Contains("create_hypertable('collect.collection_log', by_range('collection_time', INTERVAL '1 days')", v23, StringComparison.Ordinal);
Assert.Contains("migrate_data => true", v23, StringComparison.Ordinal);
Assert.Contains("if_not_exists => true", v23, StringComparison.Ordinal);
- /* Compression mirrors TimescaleSupport for this one table: segment by server_id, 1-day compress-after. */
+ /* V23's own compression text: segment by server_id, 1-day compress-after. Since #4951 the runtime path moves
+ collection_log to server_id and collector_name at first start; this text stays as written, because a
+ migration is never edited. */
Assert.Contains("ALTER TABLE collect.collection_log SET (timescaledb.compress, timescaledb.compress_segmentby = 'server_id')", v23, StringComparison.Ordinal);
Assert.Contains("add_compression_policy('collect.collection_log', compress_after => INTERVAL '1 days', if_not_exists => true)", v23, StringComparison.Ordinal);
diff --git a/Darling/Darling.Tests/StoreObjectConvergenceTests.cs b/Darling/Darling.Tests/StoreObjectConvergenceTests.cs
index b7c4da166f..068d32256e 100644
--- a/Darling/Darling.Tests/StoreObjectConvergenceTests.cs
+++ b/Darling/Darling.Tests/StoreObjectConvergenceTests.cs
@@ -617,6 +617,12 @@ public void TheGuardsRead_AsksForEnabledAndSegmentBy_FromTheLongStableView_Scope
Assert.Contains("cs.segmentby_column_index IS NOT NULL", sql, StringComparison.Ordinal);
Assert.DoesNotContain("hypertable_compression_settings", sql, StringComparison.Ordinal);
+ /* The columns are joined with the separator the statement writes them with (#4951: collection_log's
+ segmentby has two columns), so a converged two-column setting reads back exactly as it was written.
+ Any other separator would read every converged pass as "not converged" and re-issue the ALTER, and its
+ ACCESS EXCLUSIVE lock, every hour. */
+ Assert.Contains("string_agg(cs.attname, ', ' ORDER BY cs.segmentby_column_index)", sql, StringComparison.Ordinal);
+
/* Scoped, for the reason CompressionPolicyStateSql gives: a bring-your-own store may carry its own
wait_stats hypertable in another schema, and this product must not read — let alone ALTER — it. */
Assert.Contains("h.hypertable_schema = 'collect'", sql, StringComparison.Ordinal);
@@ -642,6 +648,12 @@ public void TheStatementAndTheComparison_ShareOneSegmentByConstant()
TimescaleSupport.EnableCompressionSql("query_stats"),
StringComparison.Ordinal);
+ /* #4951: collection_log alone segments by collector as well, and every other table keeps server_id. */
+ Assert.Contains(
+ "timescaledb.compress_segmentby = 'server_id, collector_name'",
+ TimescaleSupport.EnableCompressionSql("collection_log"),
+ StringComparison.Ordinal);
+
/* The two halves are tied THROUGH the constant, not through a shared spelling: the statement
interpolates it, and the guard compares against it. Both are asserted on the SOURCE, because a
rendered statement cannot tell you whether the name came from the constant or from a literal that
@@ -652,11 +664,13 @@ happens to agree with it today — which is the whole failure mode. A file-wide
var raw = RepoFile.ReadRepoFile("Darling", "PerformanceMonitor.Darling.Storage", "TimescaleSupport.cs");
/* The statement's half is read off the RAW source: the interpolation lives inside a string literal,
which StripCommentsAndStrings blanks by design. */
- Assert.Contains("timescaledb.compress_segmentby = '{CompressionSegmentByColumn}'", raw, StringComparison.Ordinal);
+ /* #4951: the value is per table now (collection_log has its own), so both halves go through the one
+ per-table lookup rather than the shared column constant. */
+ Assert.Contains("timescaledb.compress_segmentby = '{CompressionSegmentByFor(table)}'", raw, StringComparison.Ordinal);
/* The guard's half is code, so it is read off the stripped source — prose about the comparison is not
the comparison. */
Assert.Contains(
- "string.Equals(segmentBy, CompressionSegmentByColumn, StringComparison.Ordinal)",
+ "string.Equals(segmentBy, CompressionSegmentByFor(table), StringComparison.Ordinal)",
CSharpSourceWalker.StripCommentsAndStrings(raw),
StringComparison.Ordinal);
}
@@ -706,6 +720,25 @@ applies to it as much as to the collector tables. */
Assert.Contains("ReadTablesNeedingCompressionEnableAsync(connection, logger, cancellationToken);", collectionLog, StringComparison.Ordinal);
Assert.Contains("if (converged is null || !converged.Contains(CollectionLogTable))", collectionLog, StringComparison.Ordinal);
+ /* #4951: and collection_log's ALTER goes only through the bounded helper, which runs it inside its own
+ transaction and treats a lock it could not get as "try next pass" (the live
+ CollectionLogSegmentByLiveTests prove the wait is bounded; this keeps the shape in the unit tier). */
+ Assert.Contains("TrySetCollectionLogCompressionAsync(connection, logger, cancellationToken)", collectionLog, StringComparison.Ordinal);
+ Assert.DoesNotContain("EnableCompressionSql(CollectionLogTable)", collectionLog, StringComparison.Ordinal);
+
+ /* Below TimescaleDB 2.14 the change cannot run while compressed chunks exist, so the version check comes
+ first and the ALTER is not attempted there. No live tier here runs a TimescaleDB that old, so the order
+ is pinned in this one. */
+ var versionCheckAt = collectionLog.IndexOf("CollectionLogSettingsChangeBlockedAsync(connection, logger, cancellationToken)", StringComparison.Ordinal);
+ var alterAt = collectionLog.IndexOf("TrySetCollectionLogCompressionAsync(connection, logger, cancellationToken)", StringComparison.Ordinal);
+ Assert.True(versionCheckAt >= 0 && versionCheckAt < alterAt,
+ "EnsureCollectionLogHypertableAsync must check the TimescaleDB version and compressed chunks before it attempts the settings change");
+ var bounded = MethodBody(storage, "private static async Task TrySetCollectionLogCompressionAsync(");
+ Assert.False(string.IsNullOrEmpty(bounded), "could not locate TrySetCollectionLogCompressionAsync — this pin cannot silently pass on a parse miss");
+ Assert.Contains("BeginTransactionAsync(cancellationToken)", bounded, StringComparison.Ordinal);
+ Assert.Contains("EnableCompressionSql(CollectionLogTable), connection, transaction)", bounded, StringComparison.Ordinal);
+ Assert.Contains("PostgresErrorCodes.LockNotAvailable", bounded, StringComparison.Ordinal);
+
/* A read failure must issue every ALTER rather than skip every ALTER: the conservative direction,
because a needless ALTER costs one lock and a skipped one costs a table that never compresses.
Both call sites treat null as "not converged", which is what the two conditions above encode. */
diff --git a/Darling/Darling.Tests/TimescaleSupportTests.cs b/Darling/Darling.Tests/TimescaleSupportTests.cs
index 5431eb06f3..a804add080 100644
--- a/Darling/Darling.Tests/TimescaleSupportTests.cs
+++ b/Darling/Darling.Tests/TimescaleSupportTests.cs
@@ -150,10 +150,29 @@ other pin in this file still expects. CompressAfterFor is the single source both
TimescaleSupport.AddCompressionPolicySql(schema), StringComparison.Ordinal);
}
- /* collection_log gets the identical compression via the raw-name overloads (the runtime path). */
+ /* collection_log gets its compression via the raw-name overloads (the runtime path), segmented by
+ collector as well as by server (#4951): its reads ask for one collector on one server, so a
+ server_id-only segment made every such read decompress every collector's runs on that server. */
Assert.Equal(
- "ALTER TABLE collection_log SET (timescaledb.compress, timescaledb.compress_segmentby = 'server_id')",
+ "ALTER TABLE collection_log SET (timescaledb.compress, timescaledb.compress_segmentby = 'server_id, collector_name')",
TimescaleSupport.EnableCompressionSql(TimescaleSupport.CollectionLogTable));
+
+ /* The one lookup behind both the statement and the convergence read: collection_log alone gets the
+ collector column, every collector table keeps server_id, and collection_log's ALTER waits a bounded
+ time for its lock. */
+ Assert.Equal("server_id, collector_name", TimescaleSupport.CollectionLogSegmentBy);
+ Assert.Equal(TimescaleSupport.CollectionLogSegmentBy, TimescaleSupport.CompressionSegmentByFor(TimescaleSupport.CollectionLogTable));
+ Assert.All(CollectorCatalog.All, schema => Assert.Equal("server_id", TimescaleSupport.CompressionSegmentByFor(schema.TargetTable)));
+
+ /* Callers spell tables both ways ("collect.x" and bare "x"). A schema-qualified collection_log must get the
+ same value as the bare name the convergence read compares against, or the read never sees the table as
+ converged and every hourly pass issues the ALTER (#3817's divergence, reached through a spelling). */
+ Assert.Equal(TimescaleSupport.CollectionLogSegmentBy, TimescaleSupport.CompressionSegmentByFor("collect." + TimescaleSupport.CollectionLogTable));
+ Assert.Equal(
+ "ALTER TABLE collect.collection_log SET (timescaledb.compress, timescaledb.compress_segmentby = 'server_id, collector_name')",
+ TimescaleSupport.EnableCompressionSql("collect." + TimescaleSupport.CollectionLogTable));
+ Assert.All(CollectorCatalog.All, schema => Assert.Equal("server_id", TimescaleSupport.CompressionSegmentByFor("collect." + schema.TargetTable)));
+ Assert.Equal("3s", TimescaleSupport.CollectionLogSettingsLockTimeout);
Assert.True(TimescaleSupport.TryCompressionPhaseMinutesFor(TimescaleSupport.CollectionLogTable, out var logPhase));
Assert.Equal(
"SELECT add_compression_policy('collection_log', compress_after => INTERVAL '1 days', schedule_interval => INTERVAL '1 hour', if_not_exists => true, "
@@ -289,6 +308,40 @@ the point is that last_run_started_at is never read raw. */
TimescaleSupport.StuckPolicyJobsSql, StringComparison.Ordinal);
}
+ /* ---------------- collection_log's settings change, by TimescaleDB version (#4951) ---------------- */
+
+ [Theory]
+ [InlineData("2.13.0")]
+ [InlineData("2.13.1")]
+ [InlineData("2.13.1-dev")]
+ public void CollectionLogSettingsChange_BelowTheRelease_WaitsWhileAChunkIsCompressed(string extversion)
+ {
+ /* Before 2.14 TimescaleDB refuses a compression-settings change while any chunk is compressed, so the
+ ensure skips it there; with no compressed chunk, any 2.x takes the ALTER. */
+ var version = TimescaleSupport.ParseTimescaleVersion(extversion);
+ Assert.NotNull(version);
+ Assert.True(TimescaleSupport.CompressionSettingsChangeBlocked(version, hasCompressedChunks: true));
+ Assert.False(TimescaleSupport.CompressionSettingsChangeBlocked(version, hasCompressedChunks: false));
+ }
+
+ [Theory]
+ [InlineData("2.14")]
+ [InlineData("2.14.0")]
+ [InlineData("2.17.2")]
+ [InlineData("2.30.1")]
+ [InlineData(null)]
+ [InlineData("not-a-version")]
+ public void CollectionLogSettingsChange_FromTheRelease_OrUnknown_IsAttempted(string? extversion)
+ {
+ /* From 2.14 the change applies to chunks compressed after it. An unknown version is attempted too: skipping
+ it would keep a new store on the old setting forever, and a refused ALTER only rolls back and logs. A
+ two-part "2.14" must not rank below the floor, which is why the floor has two parts. */
+ Assert.Equal(new Version(2, 14), TimescaleSupport.CompressionSettingsChangeWithCompressedChunksFrom);
+ var version = TimescaleSupport.ParseTimescaleVersion(extversion);
+ Assert.False(TimescaleSupport.CompressionSettingsChangeBlocked(version, hasCompressedChunks: true));
+ Assert.False(TimescaleSupport.CompressionSettingsChangeBlocked(version, hasCompressedChunks: false));
+ }
+
/* ---------------- the -infinity arm's sentence, by TimescaleDB version (#3591) ---------------- */
[Theory]
diff --git a/Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs b/Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs
index 0ecfc46f5c..24f8b12036 100644
--- a/Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs
+++ b/Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs
@@ -331,10 +331,11 @@ internal sealed record StoreObjectConvergenceStep(
/// are on the steps. The two that are worth an operator's attention: the compression ENABLE statements
/// (ALTER TABLE ... SET (timescaledb.compress ...), inside
/// and
- /// ) are re-executed on every pass rather
- /// than skipped under a catalog check, so those two steps are the pass's only unconditional DDL; they take
- /// a brief lock on the hypertable's parent and nothing else. That is measured in the PR body rather than
- /// asserted here, and it is why the summary line carries an elapsed.
+ /// ) take an ACCESS EXCLUSIVE lock even
+ /// when they change nothing, so since #3817 each is issued only for a table whose compression settings
+ /// differ from what the product wants, and a converged store's pass issues none. collection_log's waits at
+ /// most for its lock (#4951). The summary
+ /// line carries an elapsed so a slow pass shows.
///
private static readonly StoreObjectConvergenceStep[] s_storeObjectConvergence =
{
@@ -343,13 +344,13 @@ already a hypertable. A catalog check per table on a converged store. */
new("hypertable conversion", StoreObjectConvergenceStage.Timescale, StoreObjectChangeSignal.InPlace,
(connection, logger, ct) => TimescaleSupport.ConvertToHypertablesAsync(connection, logger, ct)),
- /* The compression ENABLE is unconditional DDL (see the class remark); add_compression_policy's
- if_not_exists returns -1 for a policy that exists. */
+ /* The compression ENABLE runs only for a table whose settings differ (#3817, see the class remark);
+ add_compression_policy's if_not_exists returns -1 for a policy that exists. */
new("compression policies", StoreObjectConvergenceStage.Timescale, StoreObjectChangeSignal.InPlace,
(connection, logger, ct) => TimescaleSupport.ApplyCompressionPolicyAsync(connection, logger, ct)),
- /* collection_log is outside the collector catalog, so the two steps above never reach it; same three
- idempotent statements. */
+ /* collection_log is outside the collector catalog, so the two steps above never reach it; the same three
+ idempotent statements, with its own segmentby and a bounded lock wait on its ALTER (#4951). */
new("collection_log hypertable", StoreObjectConvergenceStage.Timescale, StoreObjectChangeSignal.InPlace,
async (connection, logger, ct) => await TimescaleSupport.EnsureCollectionLogHypertableAsync(connection, logger, ct) ? 1 : 0),
diff --git a/Darling/PerformanceMonitor.Darling.Storage/TimescaleSupport.cs b/Darling/PerformanceMonitor.Darling.Storage/TimescaleSupport.cs
index 3f06dd26a3..334c47d4b0 100644
--- a/Darling/PerformanceMonitor.Darling.Storage/TimescaleSupport.cs
+++ b/Darling/PerformanceMonitor.Darling.Storage/TimescaleSupport.cs
@@ -12,7 +12,6 @@
using System.Diagnostics;
using System.Globalization;
using System.Linq;
-using System.Runtime.CompilerServices;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
@@ -295,17 +294,38 @@ public static string EnableCompressionSql(ICollectorSchemaInfo schema)
/// The raw-name compression-enable overload — the collection_log path (see
/// ).
public static string EnableCompressionSql(string table)
- => $"ALTER TABLE {table} SET (timescaledb.compress, timescaledb.compress_segmentby = '{CompressionSegmentByColumn}')";
+ => $"ALTER TABLE {table} SET (timescaledb.compress, timescaledb.compress_segmentby = '{CompressionSegmentByFor(table)}')";
///
/// The segmentby column every collector hypertable compresses on. Promoted to a constant by #3817 so the
/// statement above and the catalog comparison that decides whether to ISSUE it
/// () read the same name: a guard that skipped the
/// ALTER by comparing against a second spelling of this would be exactly as wrong as no guard, and
- /// silently so.
+ /// silently so. Both halves now read it through , because
+ /// collection_log has its own value (#4951).
///
public const string CompressionSegmentByColumn = "server_id";
+ ///
+ /// collection_log's segmentby (#4951): by collector as well as by server. Its reads ask for one collector's
+ /// runs on one server (the event baselines' coverage reads, the per-collector health and history reads), and
+ /// with server_id alone every such read decompressed all of that server's collectors' runs to keep one
+ /// collector's. Spelled with ", " because joins the columns with the
+ /// same separator, so a converged store's setting reads back exactly as this statement wrote it.
+ ///
+ public const string CollectionLogSegmentBy = CompressionSegmentByColumn + ", collector_name";
+
+ ///
+ /// The segmentby the enable statement sets for and the convergence read compares
+ /// against: for collection_log,
+ /// for every other table. Keyed by the bare name, which is how both and the
+ /// read's hypertable_name spell it; a schema-qualified name (collect.collection_log) is matched by
+ /// its last part, so a statement built from that spelling sets the value the read compares against. One lookup
+ /// for both halves, for the reason gives.
+ ///
+ public static string CompressionSegmentByFor(string table)
+ => table.AsSpan(table.LastIndexOf('.') + 1).Equals(CollectionLogTable, StringComparison.Ordinal) ? CollectionLogSegmentBy : CompressionSegmentByColumn;
+
///
/// One collector table's background compression policy — chunks older than
/// compress automatically, checked every
@@ -9530,8 +9550,9 @@ internal static RollupFloorPlan PlanRollupFloorMeasurements(
/// measured this cycle gets its new entry, or none when it came back empty; a view whose rollup is gone is
/// dropped; every other view is left as the cache holds it NOW. The cycle's result
/// was computed from a snapshot taken before the (slow) measure, so overwriting the whole cache with it would
- /// let a cycle that started earlier undo a newer measurement made meanwhile by another data source or by the
- /// background re-measure.
+ /// let a cycle that started earlier undo a newer measurement of a view this cycle did not measure, made meanwhile
+ /// by another data source or by the background re-measure. For a view both cycles measured, the last writer still
+ /// wins.
///
internal static void ApplyRollupFloorMeasurements(
Dictionary cache,
@@ -9608,7 +9629,8 @@ private static void StartRollupRemeasure(
/// The returned NewCacheEntries is the COMPLETE replacement for the snapshot cached was
/// taken from. #4957: the cache itself is written through , which
/// applies it for the views this cycle measured (an evicted view is removed, not left to survive) and leaves
- /// the rest as the cache holds them now, so a slower cycle cannot undo a newer measurement.
+ /// the rest as the cache holds them now, so a slower cycle cannot undo a newer measurement of a view it did not
+ /// measure.
///
internal static (IReadOnlyDictionary Floors, IReadOnlyDictionary NewCacheEntries) MergeRollupFloors(
IReadOnlyDictionary measuredThisCycle,
@@ -10037,7 +10059,7 @@ idempotent in cost as well as in effect. Only the ALTER needed guarding. */
h.hypertable_name,
h.compression_enabled,
(
- SELECT string_agg(cs.attname, ',' ORDER BY cs.segmentby_column_index)
+ SELECT string_agg(cs.attname, ', ' ORDER BY cs.segmentby_column_index)
FROM timescaledb_information.compression_settings AS cs
WHERE cs.hypertable_schema = h.hypertable_schema
AND cs.hypertable_name = h.hypertable_name
@@ -10047,11 +10069,12 @@ FROM timescaledb_information.hypertables AS h
WHERE h.hypertable_schema = 'collect'";
///
- /// Every collect hypertable that does NOT already have compression enabled with exactly
- /// as its segmentby — the set the enable ALTER must be issued
- /// for, and nothing else (#3817). A table absent from the read (not a hypertable yet, or a catalog too
- /// old for the view) is treated as NEEDING the ALTER: the conservative direction, because the cost of a
- /// needless ALTER is one lock and the cost of a skipped one is a table that never compresses.
+ /// Every collect hypertable that ALREADY has compression enabled with exactly the segmentby
+ /// gives it — the CONVERGED set, whatever the name says: the enable
+ /// ALTER is issued for every table NOT in it, and for nothing else (#3817). A table absent from the read
+ /// (not a hypertable yet, or a catalog too old for the view) is therefore treated as NEEDING the ALTER: the
+ /// conservative direction, because the cost of a needless ALTER is one lock and the cost of a skipped one is
+ /// a table that never compresses.
///
/// Failure-isolated to the same conservative answer: if the read throws, this returns null
/// and the caller issues every ALTER exactly as it did before this guard existed. A store that cannot
@@ -10079,7 +10102,7 @@ FROM timescaledb_information.hypertables AS h
/* Both halves, or the ALTER still has to run: a store whose segmentby was changed out from
under this product (or created by an older build with a different one) must converge, which
is the whole reason this is a settings comparison and not a boolean. */
- if (enabled && string.Equals(segmentBy, CompressionSegmentByColumn, StringComparison.Ordinal))
+ if (enabled && string.Equals(segmentBy, CompressionSegmentByFor(table), StringComparison.Ordinal))
{
converged.Add(table);
}
@@ -11007,10 +11030,32 @@ JOIN timescaledb_information.dimensions AS d
/// conversion, and this heals it. Same three statements the collector tables get, via the raw-name overloads
/// (: migrate_data moves any existing rows into
/// chunks — the proven non-transactional path, so no migration-transaction risk; compression segments by
- /// server_id at ). Idempotent (if_not_exists), so it re-converges
- /// every restart and no-ops a store the V23 migration already converted. Failure-isolated: a failure warns and
- /// collection_log stays a plain table — its DELETE-based retention (DarlingRetention) still honors the horizon.
- /// The long command timeout covers a large first migrate_data.
+ /// at ). Idempotent (if_not_exists),
+ /// so it re-converges every pass; on a table the V23 migration already converted, the conversion and the policy
+ /// are no-ops and only the settings change below can act. The long
+ /// command timeout covers a large first migrate_data.
+ ///
+ /// The settings change (#4951). A store converted before #4951 compresses by server_id
+ /// alone, and so does a table V23 converts, because V23's text keeps server_id (a migration is never
+ /// edited). V23's policy has no start time, so the scheduler runs it as soon as it is added. On a new store
+ /// that run finds an empty table, and this runs at the first start, before any row is old enough to compress.
+ /// On an upgrade where V23 converts existing rows, that run compresses the older chunks by server_id
+ /// before this runs, and they are old chunks like any upgraded store's.
+ /// The ALTER moves the hypertable to ; every chunk already
+ /// compressed keeps the settings it was compressed with, and only chunks compressed from then on use the new
+ /// one, so no chunk is rewritten and the mix ages out with retention. The ALTER runs only when the settings
+ /// differ, in its own transaction, waiting at most for its
+ /// lock (). Changing the settings while compressed chunks
+ /// exist needs TimescaleDB ; on an older store
+ /// with compressed chunks the ALTER is not attempted, and the table keeps compressing by server_id until
+ /// the extension is upgraded ().
+ ///
+ /// Failure-isolated step by step: a step that fails logs what it left unchanged, the pass returns
+ /// false, and the next pass tries again. A failed conversion leaves a plain table as it was, and its
+ /// DELETE-based retention (DarlingRetention) still honors the horizon. A settings change that fails for any
+ /// reason but a busy lock still lets the policy step run, because the table stays compressible under its current
+ /// setting and no other step adds a missing compression policy. A busy lock ends the pass before the policy
+ /// step, which could queue behind the same lock; the next pass does both.
///
public static async Task EnsureCollectionLogHypertableAsync(NpgsqlConnection connection, ILogger? logger, CancellationToken cancellationToken = default)
{
@@ -11021,38 +11066,199 @@ public static async Task EnsureCollectionLogHypertableAsync(NpgsqlConnecti
try
{
- using (var convert = new NpgsqlCommand(CreateHypertableSql(CollectionLogTable, CollectionLogTimeColumn), connection) { CommandTimeout = SetupTimeoutSeconds })
+ using var convert = new NpgsqlCommand(CreateHypertableSql(CollectionLogTable, CollectionLogTimeColumn), connection) { CommandTimeout = SetupTimeoutSeconds };
+ await convert.ExecuteNonQueryAsync(cancellationToken);
+ }
+ catch (Exception ex) when (ex is not OperationCanceledException)
+ {
+ logger?.LogWarning(
+ "collection_log could not be converted to a hypertable, so it keeps its current form (as a plain table, its DELETE-based retention still honors its horizon); the next pass tries again: {Message}",
+ ex.Message);
+ return false;
+ }
+
+ /* #3817: same guard as the collector sweep's, same reason — the enable ALTER takes an
+ AccessExclusiveLock even when it changes nothing (measured; CompressionEnabledStateSql carries
+ the measurement), and this method is now on the hourly tick rather than only the start path.
+ collection_log is written by every collector cycle, so the convoy applies to it as much as to
+ the collector tables. Its own read because this method takes no set from its caller; one row
+ back, and a null answer issues the ALTER exactly as before. */
+ var converged = await ReadTablesNeedingCompressionEnableAsync(connection, logger, cancellationToken);
+ var settingsFailed = false;
+ if (converged is null || !converged.Contains(CollectionLogTable))
+ {
+ if (!await CollectionLogSettingsChangeBlockedAsync(connection, logger, cancellationToken))
+ {
+ switch (await TrySetCollectionLogCompressionAsync(connection, logger, cancellationToken))
+ {
+ /* The table is busy, and the policy step could queue behind the same lock: the next pass does both. */
+ case CollectionLogSettingsChange.LockBusy:
+ return false;
+
+ /* The table keeps its current settings and stays compressible under them, so the policy step
+ still runs; the pass reports the failure after it. */
+ case CollectionLogSettingsChange.Failed:
+ settingsFailed = true;
+ break;
+ }
+ }
+ }
+
+ try
+ {
+ using var policy = new NpgsqlCommand(AddCompressionPolicySql(CollectionLogTable), connection) { CommandTimeout = SetupTimeoutSeconds };
+ await policy.ExecuteNonQueryAsync(cancellationToken);
+ }
+ catch (Exception ex) when (ex is not OperationCanceledException)
+ {
+ logger?.LogWarning(
+ "collection_log's compression policy could not be added; a policy that already exists keeps running, and the next pass tries again: {Message}",
+ ex.Message);
+ return false;
+ }
+
+ if (settingsFailed)
+ {
+ return false;
+ }
+
+ logger?.LogInformation("TimescaleDB: collection_log is a hypertable with a {Days}d compression policy", CompressAfterDays);
+ return true;
+ }
+
+ ///
+ /// The TimescaleDB release from which a hypertable's compression settings can change while compressed chunks
+ /// exist (#4951). The 2.14.0 release notes (2024-02-08, upstream #6513 and #6545): "Ability to change compression
+ /// settings on existing compressed hypertables at any time. New compression settings take effect on any new
+ /// chunks that are compressed after the change." Before it the ALTER is refused while any chunk is compressed.
+ /// Two parts on purpose: an extversion of 2.14 parses as 2.14, which a three-part
+ /// 2.14.0 would rank below.
+ ///
+ public static readonly Version CompressionSettingsChangeWithCompressedChunksFrom = new(2, 14);
+
+ ///
+ /// Whether the store cannot take a compression-settings change now (#4951): only below
+ /// , and only while a chunk is compressed (with
+ /// none, any 2.x takes the ALTER). An unknown version is NOT blocked, the opposite of
+ /// 's choice and for the mirror reason: here, treating a new store
+ /// as old would keep it on the old setting forever, while trying the ALTER on an old one costs a rolled-back
+ /// statement and a log line. Pure, so the arms pin.
+ ///
+ public static bool CompressionSettingsChangeBlocked(Version? timescaleVersion, bool hasCompressedChunks)
+ => hasCompressedChunks && timescaleVersion is not null && timescaleVersion < CompressionSettingsChangeWithCompressedChunksFrom;
+
+ /// The TimescaleDB version and whether collection_log has a compressed chunk, in one round trip.
+ private const string CollectionLogSettingsChangeStateSql = @"
+SELECT (SELECT e.extversion FROM pg_extension AS e WHERE e.extname = 'timescaledb'),
+ EXISTS (SELECT 1
+ FROM timescaledb_information.chunks AS c
+ WHERE c.hypertable_schema = 'collect'
+ AND c.hypertable_name = 'collection_log'
+ AND c.is_compressed)";
+
+ ///
+ /// Whether collection_log's settings change must wait for a newer TimescaleDB (#4951, see
+ /// ). When it must, one Information line says so on each pass that
+ /// finds the settings differ, which is every hourly pass on such a store, so the line says what changes it. A read
+ /// that fails is not a block: the ALTER is attempted, and its own failure line covers a store that refuses it.
+ ///
+ private static async Task CollectionLogSettingsChangeBlockedAsync(NpgsqlConnection connection, ILogger? logger, CancellationToken cancellationToken)
+ {
+ Version? version;
+ bool hasCompressedChunks;
+ try
+ {
+ using var command = new NpgsqlCommand(CollectionLogSettingsChangeStateSql, connection) { CommandTimeout = JobCatalogReadTimeoutSeconds };
+ await using var reader = await command.ExecuteReaderAsync(cancellationToken);
+ if (!await reader.ReadAsync(cancellationToken))
{
- await convert.ExecuteNonQueryAsync(cancellationToken);
+ return false;
}
- /* #3817: same guard as the collector sweep's, same reason — the enable ALTER takes an
- AccessExclusiveLock even when it changes nothing (measured; CompressionEnabledStateSql carries
- the measurement), and this method is now on the hourly tick rather than only the start path.
- collection_log is written by every collector cycle, so the convoy applies to it as much as to
- the collector tables. Its own read because this method takes no set from its caller; one row
- back, and a null answer issues the ALTER exactly as before. */
- var converged = await ReadTablesNeedingCompressionEnableAsync(connection, logger, cancellationToken);
- if (converged is null || !converged.Contains(CollectionLogTable))
+ version = ParseTimescaleVersion(reader.IsDBNull(0) ? null : reader.GetString(0));
+ hasCompressedChunks = !reader.IsDBNull(1) && reader.GetBoolean(1);
+ }
+ catch (Exception ex) when (ex is not OperationCanceledException)
+ {
+ logger?.LogDebug("Could not read the TimescaleDB version or collection_log's compressed chunks, so the compression settings change is attempted (#4951): {Message}", ex.Message);
+ return false;
+ }
+
+ if (!CompressionSettingsChangeBlocked(version, hasCompressedChunks))
+ {
+ return false;
+ }
+
+ logger?.LogInformation(
+ "TimescaleDB: collection_log keeps compressing by server_id: TimescaleDB {Version} cannot change compression settings while compressed chunks exist, and {Floor} and later can. Each hourly pass checks again, so the change applies after the extension is upgraded (#4951)",
+ version, CompressionSettingsChangeWithCompressedChunksFrom);
+ return true;
+ }
+
+ ///
+ /// The lock wait collection_log's compression-settings ALTER tolerates (#4951), as a PostgreSQL
+ /// lock_timeout literal. The ALTER takes AccessExclusiveLock on the hypertable, and every
+ /// collector cycle writes a row to collection_log, so an ALTER queued behind one long reader would not merely
+ /// wait: every collector's write would queue behind it for as long as that reader runs (the convoy
+ /// documents). Three seconds is long enough for the lock to be free
+ /// whenever no long reader holds the table, and short enough that one costs the collectors' writes at most
+ /// that much and this pass nothing but a log line; the next hourly pass tries again. Set with
+ /// SET LOCAL inside the ALTER's own transaction, so it never outlives the statement it guards.
+ ///
+ public const string CollectionLogSettingsLockTimeout = "3s";
+
+ /// What did (#4951).
+ private enum CollectionLogSettingsChange
+ {
+ /// The ALTER committed.
+ Applied,
+
+ /// Another session held the table past ; nothing changed.
+ LockBusy,
+
+ /// The ALTER failed for another reason and rolled back; the table keeps its current settings.
+ Failed,
+ }
+
+ ///
+ /// collection_log's compression-settings ALTER, bounded by (#4951).
+ /// An explicit transaction, because SET LOCAL outside one is a no-op with a warning, and the one-command
+ /// BEGIN; ...; COMMIT; form leaves the connection inside a failed transaction when a statement in it
+ /// fails. Any failure rolls the whole change back, so the table and its compressed chunks keep exactly the
+ /// settings they had, and the next pass tries again. The result tells a busy lock from any other failure,
+ /// because the caller runs the policy step after the second and not after the first.
+ ///
+ private static async Task TrySetCollectionLogCompressionAsync(NpgsqlConnection connection, ILogger? logger, CancellationToken cancellationToken)
+ {
+ try
+ {
+ await using var transaction = await connection.BeginTransactionAsync(cancellationToken);
+ using (var timeout = new NpgsqlCommand($"SET LOCAL lock_timeout = '{CollectionLogSettingsLockTimeout}'", connection, transaction) { CommandTimeout = SetupTimeoutSeconds })
{
- using var enable = new NpgsqlCommand(EnableCompressionSql(CollectionLogTable), connection) { CommandTimeout = SetupTimeoutSeconds };
- await enable.ExecuteNonQueryAsync(cancellationToken);
+ await timeout.ExecuteNonQueryAsync(cancellationToken);
}
- using (var policy = new NpgsqlCommand(AddCompressionPolicySql(CollectionLogTable), connection) { CommandTimeout = SetupTimeoutSeconds })
+ using (var enable = new NpgsqlCommand(EnableCompressionSql(CollectionLogTable), connection, transaction) { CommandTimeout = SetupTimeoutSeconds })
{
- await policy.ExecuteNonQueryAsync(cancellationToken);
+ await enable.ExecuteNonQueryAsync(cancellationToken);
}
- logger?.LogInformation("TimescaleDB: collection_log is a hypertable with a {Days}d compression policy", CompressAfterDays);
- return true;
+ await transaction.CommitAsync(cancellationToken);
+ return CollectionLogSettingsChange.Applied;
+ }
+ catch (PostgresException ex) when (string.Equals(ex.SqlState, PostgresErrorCodes.LockNotAvailable, StringComparison.Ordinal))
+ {
+ logger?.LogInformation(
+ "TimescaleDB: collection_log's compression settings were not changed this pass: another session held the table for {Timeout}, and waiting longer would hold up every collector's writes to it. The table keeps its current settings, and the next hourly pass tries again (#4951)",
+ CollectionLogSettingsLockTimeout);
+ return CollectionLogSettingsChange.LockBusy;
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
logger?.LogWarning(
- "collection_log hypertable setup failed — it stays a plain table (DELETE-based retention still honors its horizon): {Message}",
+ "TimescaleDB: collection_log's compression settings could not be changed, so the change was rolled back: the table and its compressed chunks keep their current settings, and the next hourly pass tries again (#4951): {Message}",
ex.Message);
- return false;
+ return CollectionLogSettingsChange.Failed;
}
}
From f95e574ddca20f5baca51359274e6c992e9a6c94 Mon Sep 17 00:00:00 2001
From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com>
Date: Fri, 2 Oct 2026 21:26:30 -0400
Subject: [PATCH 014/399] Order the latest collector outcome read by
collection_time first (#4975)
The read that tells an empty MCP answer apart from a collector problem ordered a server's collection_log rows by log_id, which no index serves, so it sorted the server's whole retained history. It now orders by collection_time, then log_id, and the existing (server_id, collector_name, collection_time DESC) index serves it from the newest chunk. Fixes #4974.
---
.../LatestCollectorOutcomeOrderTests.cs | 123 ++++++++++++++++
.../LatestCollectorOutcomePlanTests.cs | 136 ++++++++++++++++++
.../Mcp/DarlingRuntimePrecondition.cs | 26 +++-
.../LocalDataService.RuntimePrecondition.cs | 2 +-
4 files changed, 281 insertions(+), 6 deletions(-)
create mode 100644 Darling/Darling.Tests/LatestCollectorOutcomeOrderTests.cs
create mode 100644 Darling/Darling.Tests/LatestCollectorOutcomePlanTests.cs
diff --git a/Darling/Darling.Tests/LatestCollectorOutcomeOrderTests.cs b/Darling/Darling.Tests/LatestCollectorOutcomeOrderTests.cs
new file mode 100644
index 0000000000..566c671812
--- /dev/null
+++ b/Darling/Darling.Tests/LatestCollectorOutcomeOrderTests.cs
@@ -0,0 +1,123 @@
+/*
+ * 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.RegularExpressions;
+using System.Threading.Tasks;
+using Npgsql;
+using PerformanceMonitor.Darling.Service.Mcp;
+using PerformanceMonitor.Darling.Storage;
+using Xunit;
+
+namespace Darling.Tests;
+
+///
+/// #4974: the latest-collector-outcome read must order by collection_time DESC first so the
+/// idx_collection_log_watermark index serves it from the newest chunk, with log_id DESC as the
+/// tie-break for two runs that share a timestamp. collection_log has no index on log_id, so a
+/// log_id-first order sorts every retained row for the pair. A revert returns the same row on a small
+/// store, so the order is pinned at the source, through the product's own read path, and in the plan
+/// ().
+///
+public sealed class LatestCollectorOutcomeOrderShapeTests
+{
+ [Fact]
+ public void LatestCollectorOutcomeSql_OrdersByCollectionTimeFirst_ThenLogId()
+ {
+ var sql = Regex.Replace(DarlingRuntimePrecondition.LatestCollectorOutcomeSql, @"\s+", " ");
+
+ Assert.Contains("ORDER BY collection_time DESC, log_id DESC LIMIT 1", sql, StringComparison.Ordinal);
+ Assert.DoesNotContain("ORDER BY log_id", sql, StringComparison.Ordinal);
+ }
+}
+
+[Collection("live-postgres")]
+public sealed class LatestCollectorOutcomeOrderLiveTests
+{
+ private const int ServerId = -497400;
+ private const string ServerName = "latest-outcome-4974";
+ private const string Collector = "running_jobs";
+
+ private static string? ConnectionString => Environment.GetEnvironmentVariable("DARLING_TEST_PG");
+
+ [Fact]
+ public async Task NewestRunByTimeWins_EvenWhenAnOlderChunkRowHasTheHigherLogId()
+ {
+ var cs = ConnectionString;
+ Assert.SkipWhen(string.IsNullOrEmpty(cs), "Set DARLING_TEST_PG to run the live latest-outcome test.");
+ var ct = TestContext.Current.CancellationToken;
+ using var connection = new NpgsqlConnection(cs);
+ await connection.OpenAsync(ct);
+ await PgMigrations.MigrateAsync(connection, ct);
+ await DeleteAsync(connection, ct);
+ var ok = false;
+ try
+ {
+ var now = DarlingMcpTestData.TruncateToSeconds(DateTime.UtcNow);
+ /* The newest run succeeded and holds the LOWER id; two older-chunk rows hold higher ids and say
+ PERMISSIONS. Time order answers "succeeded" (no precondition); id order would not. */
+ await SeedAsync(connection, ct, 100, now.AddMinutes(-1), "SUCCESS");
+ await SeedAsync(connection, ct, 200, now.AddDays(-60), "PERMISSIONS");
+ await SeedAsync(connection, ct, 300, now.AddDays(-120), "PERMISSIONS");
+
+ await using var postgres = NpgsqlDataSource.Create(cs!);
+ Assert.Null(await DarlingRuntimePrecondition.StatusAsync(postgres, ServerId, ServerName, Collector, ct));
+ ok = true;
+ }
+ finally
+ {
+ await LiveStoreCleanup.RunAsync(cs!, ok, DeleteAsync);
+ }
+ }
+
+ [Fact]
+ public async Task TwoRunsSharingATimestamp_TheHigherLogIdWins()
+ {
+ var cs = ConnectionString;
+ Assert.SkipWhen(string.IsNullOrEmpty(cs), "Set DARLING_TEST_PG to run the live latest-outcome test.");
+ var ct = TestContext.Current.CancellationToken;
+ using var connection = new NpgsqlConnection(cs);
+ await connection.OpenAsync(ct);
+ await PgMigrations.MigrateAsync(connection, ct);
+ await DeleteAsync(connection, ct);
+ var ok = false;
+ try
+ {
+ var tied = DarlingMcpTestData.TruncateToSeconds(DateTime.UtcNow.AddMinutes(-2));
+ await SeedAsync(connection, ct, 100, tied.AddDays(-60), "SUCCESS");
+ await SeedAsync(connection, ct, 200, tied, "SUCCESS");
+ await SeedAsync(connection, ct, 300, tied, "PERMISSIONS");
+
+ await using var postgres = NpgsqlDataSource.Create(cs!);
+ var message = await DarlingRuntimePrecondition.StatusAsync(postgres, ServerId, ServerName, Collector, ct);
+ Assert.NotNull(message);
+
+ /* Swap the ids' insertion order, not just the statuses: here the higher id (300) is inserted FIRST,
+ so one half expects the tied row inserted first and the other the one inserted last, and no
+ physical order can pass both without the log_id tiebreak. */
+ await DeleteAsync(connection, ct);
+ await SeedAsync(connection, ct, 300, tied, "SUCCESS");
+ await SeedAsync(connection, ct, 200, tied, "PERMISSIONS");
+ Assert.Null(await DarlingRuntimePrecondition.StatusAsync(postgres, ServerId, ServerName, Collector, ct));
+ ok = true;
+ }
+ finally
+ {
+ await LiveStoreCleanup.RunAsync(cs!, ok, DeleteAsync);
+ }
+ }
+
+ private static Task SeedAsync(NpgsqlConnection connection, System.Threading.CancellationToken ct, long logId, DateTime timeUtc, string status) =>
+ DarlingMcpTestData.ExecAsync(connection, ct, @"
+INSERT INTO collection_log (log_id, server_id, server_name, collector_name, collection_time, status)
+VALUES ($1, $2, $3, $4, $5, $6)",
+ logId, ServerId, ServerName, Collector, DarlingMcpTestData.Naive(timeUtc), status);
+
+ private static Task DeleteAsync(NpgsqlConnection connection, System.Threading.CancellationToken ct) =>
+ DarlingMcpTestData.ExecAsync(connection, ct, "DELETE FROM collection_log WHERE server_id = $1", ServerId);
+}
diff --git a/Darling/Darling.Tests/LatestCollectorOutcomePlanTests.cs b/Darling/Darling.Tests/LatestCollectorOutcomePlanTests.cs
new file mode 100644
index 0000000000..c470165843
--- /dev/null
+++ b/Darling/Darling.Tests/LatestCollectorOutcomePlanTests.cs
@@ -0,0 +1,136 @@
+/*
+ * 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;
+using System.Text.RegularExpressions;
+using System.Threading;
+using System.Threading.Tasks;
+using Npgsql;
+using PerformanceMonitor.Darling.Service.Mcp;
+using PerformanceMonitor.Darling.Storage;
+using Xunit;
+
+namespace Darling.Tests;
+
+///
+/// #4974: the latest-collector-outcome read is a chunk-orderable LIMIT 1. On a hypertable with several
+/// 1-day chunks it executes only the newest chunk for a collector that has a row there. A revert to
+/// ORDER BY log_id DESC (no index on log_id) executes every chunk.
+///
+/* #1776 own-store: the test mints its own scratch database, so no other class's chunks shape the plan. */
+[Collection("live-postgres")]
+public sealed class LatestCollectorOutcomePlanTests
+{
+ private const int TestServerId = -497401;
+ private const string TestServerName = "latest-outcome-plan-4974";
+ private const string Collector = "running_jobs";
+
+ [Fact]
+ public async Task TheShippedRead_ExecutesOnlyTheNewestChunk_AgainstDevPostgres()
+ {
+ var baseConnectionString = Environment.GetEnvironmentVariable("DARLING_TEST_PG");
+ Assert.SkipWhen(string.IsNullOrEmpty(baseConnectionString),
+ "Set DARLING_TEST_PG to a Postgres connection string to run the live latest-outcome access-path test (it mints its own scratch database).");
+
+ var ct = TestContext.Current.CancellationToken;
+ await using var scratch = await ScratchPostgres.CreateAsync(baseConnectionString!, ct);
+ var connectionString = scratch.ConnectionString;
+
+ using var connection = new NpgsqlConnection(connectionString);
+ await connection.OpenAsync(ct);
+ await PgMigrations.MigrateAsync(connection, ct);
+
+ var timescaleEnabled = await LiveTimescaleProbe.TryEnableAsync(connectionString!, ct);
+ Assert.SkipUnless(timescaleEnabled, "TimescaleDB is not available on this instance; the chunk walk cannot be asserted.");
+ Assert.True(await TimescaleSupport.EnsureCollectionLogHypertableAsync(connection, null, ct));
+
+ /* No background policy job reshapes the chunks under the plan assertion. */
+ await using (var stop = new NpgsqlCommand("SELECT _timescaledb_functions.stop_background_workers()", connection))
+ {
+ await stop.ExecuteNonQueryAsync(ct);
+ }
+
+ var utcNow = DateTime.SpecifyKind(DateTime.UtcNow, DateTimeKind.Unspecified);
+ await SeedHistoryAsync(connection, utcNow, ct);
+
+ using (var analyze = new NpgsqlCommand("ANALYZE collect.collection_log", connection))
+ {
+ await analyze.ExecuteNonQueryAsync(ct);
+ }
+
+ var plan = await ExplainShippedReadAsync(connection, ct);
+ using (var chunkCount = new NpgsqlCommand("SELECT count(*) FROM show_chunks('collect.collection_log')", connection))
+ {
+ var chunks = Convert.ToInt32(await chunkCount.ExecuteScalarAsync(ct), System.Globalization.CultureInfo.InvariantCulture);
+ Assert.True(chunks == 8, "expected the seed to make exactly eight 1-day chunks, got " + chunks + ":\n" + plan);
+ }
+
+ var lines = plan.Split('\n');
+ var chunkScans = lines.Where(PlanChunkScans.IsChunkScan).ToList();
+ var visited = lines
+ .Select(l => Regex.Match(l, @"Chunks Visited: (\d+)"))
+ .Where(m => m.Success)
+ .Select(m => int.Parse(m.Groups[1].Value, System.Globalization.CultureInfo.InvariantCulture))
+ .ToList();
+ if (visited.Count > 0)
+ {
+ /* TimescaleDB 2.30+: DeferredChunkAppend lists only the chunks it visited and counts them. */
+ Assert.True(visited.Count == 1 && visited[0] == 1,
+ "expected the deferred chunk append to visit exactly the newest chunk:\n" + plan);
+ }
+ else
+ {
+ Assert.True(chunkScans.Count >= 8,
+ "expected the plan to carry a chunk scan per seeded day:\n" + plan);
+ }
+
+ var executed = chunkScans.Where(l => !l.Contains("never executed", StringComparison.Ordinal)).ToList();
+ Assert.True(executed.Count <= 1,
+ "more than one chunk executed: the read is walking history again:\n" + plan);
+
+ /* And the product's own read answers: the newest run succeeded, so no precondition. */
+ await using var postgres = NpgsqlDataSource.Create(connectionString!);
+ Assert.Null(await DarlingRuntimePrecondition.StatusAsync(postgres, TestServerId, TestServerName, Collector, ct));
+ }
+
+ ///
+ /// Eight 1-day chunks aligned to the UTC day: one collector every five minutes from 00:00 eight days ago to 23:55
+ /// yesterday. Every chunk is in the past, the newest is yesterday's with 288 rows, at any time of day. The ids
+ /// follow time order.
+ ///
+ private static async Task SeedHistoryAsync(NpgsqlConnection connection, DateTime utcNow, CancellationToken ct)
+ {
+ using var insert = new NpgsqlCommand(
+ "INSERT INTO collect.collection_log (log_id, server_id, server_name, collector_name, collection_time, duration_ms, status, rows_collected) " +
+ "SELECT 9_000_000_000 + EXTRACT(EPOCH FROM t)::bigint, $1, $2, $3, t, 20, 'SUCCESS', 0 " +
+ "FROM generate_series($4::timestamp, $4::timestamp + interval '8 days' - interval '5 minutes', interval '5 minutes') AS t", connection);
+ insert.Parameters.AddWithValue(TestServerId);
+ insert.Parameters.AddWithValue(TestServerName);
+ insert.Parameters.AddWithValue(Collector);
+ insert.Parameters.AddWithValue(utcNow.Date.AddDays(-8));
+ await insert.ExecuteNonQueryAsync(ct);
+ }
+
+ private static async Task ExplainShippedReadAsync(NpgsqlConnection connection, CancellationToken ct)
+ {
+ using var explain = new NpgsqlCommand(
+ "EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF) " + DarlingRuntimePrecondition.LatestCollectorOutcomeSql, connection);
+ explain.Parameters.AddWithValue(TestServerId);
+ explain.Parameters.AddWithValue(Collector);
+ var plan = new StringBuilder();
+ using var reader = await explain.ExecuteReaderAsync(ct);
+ while (await reader.ReadAsync(ct))
+ {
+ plan.AppendLine(reader.GetString(0));
+ }
+
+ return plan.ToString();
+ }
+}
diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingRuntimePrecondition.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingRuntimePrecondition.cs
index 1cf05281f1..3bc34e5de5 100644
--- a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingRuntimePrecondition.cs
+++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingRuntimePrecondition.cs
@@ -60,7 +60,7 @@ internal static class DarlingRuntimePrecondition
/// answers first and a collector that ran recently is found there at once. Ordering by log_id instead would read
/// the server's whole retained history on every call, because collection_log has no primary key and no index on
/// log_id. Only the timestamp is returned, so two runs that share one give the same answer whichever comes first;
- /// that is why this read does not need the id order uses, where the row's status
+ /// that is why this read does not need the log_id tiebreak that keeps, where the row's status
/// is the answer. The server half uses idx_collection_log_time (server_id, collection_time). The collector half
/// filters with idx_collection_log_watermark (server_id, collector_name, collection_time DESC). A collector that
/// has never run has no row to find, so for it the read still goes through the server's whole retained history.
@@ -89,16 +89,32 @@ LIMIT 1
) AS server_first_collected";
///
- /// The most recent run of one collector for one server. Ordered by log_id rather than
- /// collection_time because the id is monotonic per insert while two runs inside the same cycle can
- /// share a timestamp — and "the latest run" is the whole claim this makes. $1 server_id, $2 collector.
+ /// The most recent run of one collector for one server. Ordered by collection_time DESC first (#4974):
+ /// idx_collection_log_watermark (server_id, collector_name, collection_time DESC) serves that first key,
+ /// so ChunkAppend walks the chunks newest-first and stops at the first row the walk returns, where a
+ /// log_id-first order (no index on log_id) read every retained row of the pair. The trade: after
+ /// the wall clock steps back, this read can return a run from before the step, for as long as the step lasts.
+ ///
+ /// , log_id DESC only makes the order deterministic; two runs of one server and collector do not
+ /// share a microsecond in practice. No index carries log_id, so the tiebreak is a sort. In the plans a
+ /// test store gets, it sits inside each chunk the walk visits: a Sort of the pair's rows in that chunk, or an
+ /// Incremental Sort presorted on collection_time that reads the rows at the newest timestamp plus one
+ /// more to close that group. An Incremental Sort above the walk would instead take that extra row from the
+ /// next older chunk whenever the newest chunk holds a single row for the pair. The newest chunk answers a
+ /// collector that ran recently. A collector with no row in the newest chunk (a daily collector, or one that
+ /// never ran) walks back through older chunks, which are compressed.
+ ///
+ /// , a one-collector read of the same
+ /// table, leaves this tiebreak out: there it would decide nothing, and its (server_id, collection_time)
+ /// index cannot serve a second key, so the sort would cover the server's whole newest chunk (1,158 buffers
+ /// against 5). $1 server_id, $2 collector.
///
public const string LatestCollectorOutcomeSql = @"
SELECT status, error_message, collection_time
FROM collection_log
WHERE server_id = $1
AND collector_name = $2
-ORDER BY log_id DESC
+ORDER BY collection_time DESC, log_id DESC
LIMIT 1";
///
diff --git a/Lite/Services/LocalDataService.RuntimePrecondition.cs b/Lite/Services/LocalDataService.RuntimePrecondition.cs
index c1cea1ee36..507a070dd3 100644
--- a/Lite/Services/LocalDataService.RuntimePrecondition.cs
+++ b/Lite/Services/LocalDataService.RuntimePrecondition.cs
@@ -25,7 +25,7 @@ public partial class LocalDataService
/// SESSION_MISSING from three days ago on a collector that has succeeded since describes something
/// somebody already fixed. Deliberately takes no asOfUtc — a precondition is a property of the
/// server now, not of the window the caller asked about, and accepting an anchor here would invite a
- /// caller to believe otherwise. Mirrors Darling's DarlingRuntimePrecondition read.
+ /// caller to believe otherwise. Darling's DarlingRuntimePrecondition read orders by time first (#4974); Lite keeps the id order because it stamps collection_time with the run's START time but takes the id when the run is logged, so here the id order is the completion order.
///
public async Task<(string? Status, string? ErrorMessage, DateTime? ObservedUtc)> GetLatestCollectorOutcomeAsync(
int serverId, string collectorName)
From 1f7c081ed6263d1235ef8d1ea00abef8c52e2cb9 Mon Sep 17 00:00:00 2001
From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com>
Date: Fri, 2 Oct 2026 21:33:14 -0400
Subject: [PATCH 015/399] Lite and Darling stop dropping Extended Events
sessions that another install reads (#4961) (#4967)
* Tests: the install id is exactly eight lowercase hex digits (#4961)
The tests for the shared install id helper, against a placeholder that
compiles and answers wrongly, so they fail first. Failing at this commit:
IsValid_AcceptsExactlyEightLowercaseHexDigits (all four rows, Assert.True
got false), NewId_IsAlwaysValid (Assert.True got false) and
NewId_IsRandom (0 distinct ids of 64). The rejecting rows pass against a
placeholder that rejects everything; they guard the real check.
* The install id helper: eight lowercase hex digits, checked and made in one place (#4961)
InstallId.IsValid accepts exactly eight lowercase hex digits and nothing
else (no padding, no line ending, no digit or letter from another script).
InstallId.NewId makes a random id from the cryptographic generator. It
touches no Windows-only API, so Lite and Darling share it. The tests from
the previous commit pass: 19 of 19.
* Tests: Lite's install id is made once, rebuilt when it does not fit, and never imported (#4961)
The tests for Lite's install id file and for Import Settings, against
placeholders that compile and answer wrongly, so they fail first. 28 of the
29 fail at this commit, all as assertion failures (the id is not valid, the
file is not written, the count is wrong) apart from one read of a file the
placeholder never wrote. The one that passes is the guard that a collection
service built without a store has no id.
Failing: parallel first resolves on one store and two stores racing on one
empty folder (one id, one file, one resolve); a lost create race adopts the
winner's id; a loser waits out a winner still writing; a bad id, an
unreadable file, a machine mismatch and a user mismatch each make a new id
with exactly one Warning naming both; a matching file keeps its id quietly;
the id is cached after the first resolve; a folder that cannot be written
still gives one stable id; the real machine and user are bound; the
collection service resolves the id lazily; MainWindow hands the service a
store; the import lists name the settings files and never the id file; the
import copies the settings files and never the id file; the import handler
copies through the named lists.
* Lite keeps one install id in its data folder, and Import Settings never copies it (#4961)
InstallIdStore reads or makes install-id.json at the data root: the id, the
machine name and the Windows user SID it was made for. The first resolve runs
under a process-wide lock and is cached, so parallel first sweeps share one
resolve. Across processes the file is created with CreateNew, a loser re-reads
the winner's file, and a reader that finds the file still being written waits
for the writer instead of reading the lock as a damaged file.
An id that is not eight lowercase hex digits, an unreadable file, and a file
made for another machine or Windows user each make a new id and one Warning
that names the old id (or "invalid") and the new one. The rewrite goes through
a temp file and a replace, and sessions made under the old id are left alone.
If the new id cannot be saved, the run still gets one stable id and the Warning
says so.
The collection service takes the store and reads the id lazily; MainWindow
builds it from the data root, the machine name and the current user's SID.
Nothing uses the id yet.
Import Settings copies through named lists in SettingsImport, which never name
install-id.json, so two installs cannot end up sharing an id. The copy
behaves as before: a file this install already has is kept.
The tests from the previous commit pass: 48 of 48 with the helper's.
* A long-query trace session that cannot be created logs its first failure at Warning and the repeats at Debug (#4964)
Both apps retry the create on every cycle, and each attempt records the fault again, so collection health keeps reading SESSION_MISSING. Only the log level of the repeats changes: the first failed pass logs at Warning, and the passes after it log at Debug until a create succeeds. Darling also resets it when the server reconnects; Lite creates on every cycle, so its reconnect is the first create that succeeds.
Darling covers the listing failure, each refusing database, the all-refused summary and the worker's general catch. Turning the trace off ends the run, and the drop side keeps its own cap.
* Tests: write the other-script digits in the install id tests as escapes (#4961)
The two strings are now spelled with \u escapes, so the test file stays plain
ASCII and the characters under test are visible in review. No change to what
the tests check.
* Tests: the install id's store table, its make and its read (#4961)
Pins the install id rung (one single-row table with the id format CHECK and the binding), the viewer probe arm, a make that is safe to run twice at once and replaces an id whose binding or format is wrong with one Warning naming both ids, a read that never writes, and that only the service worker makes the row. The store class is a placeholder at this commit, so these fail until the next one.
* Darling: the store holds an install id, made by the service at start and replaced when it does not belong to the store (#4961)
A one-row config table (V158) holds the install id under a CHECK for eight lowercase hex digits, with the cluster system_identifier and the store database's OID as its binding. The service makes the row after the migrations and before any worker (INSERT ... ON CONFLICT DO NOTHING, then a read), keeps the id on the worker, and replaces an id whose binding or format is wrong with one Warning that names both ids; the replacement is guarded by the id it read, so concurrent starts agree. The CLI and the Viewer get a read-only TryReadAsync. The viewer's schema probe carries the new table as its newest sentinel, and the previous rung's tests hand off the top-of-ladder checks.
* Tests: the install id replacement is guarded by the id the start read, pinned at the statement (#4961)
Two starts that read the same row both try to replace it and only the first finds the id it read. The fact runs the replacement statement twice against the same stale id and expects 1 row, then 0; it fails when the guard is removed from the statement. The timing-dependent concurrent fact can pass when one start finishes before the others begin, so it is kept as a smoke check and this one carries the rule.
* Tests: Lite's long-query create refused in every Azure database logs its Warning set and Error once (#4964)
The shared Azure ensure takes a per-database test replacement, and the long-query create goes through it, so the refusals reach the same per-database catch and all-refused throw as in production. Red before the level change: two cycles of a create refused in all three databases log six per-database Warnings where three are expected, and a listing that fails with a SQL error logs three Errors where one is expected.
* Lite's long-query create logs its per-database refusals and the all-refused summary at Debug after the first Warning (#4964)
The shared Azure ensure takes the level from its caller. The long-query trace passes the repeat flag its reconcile already keeps, so a create refused in every database warns once per run of failures instead of on every cycle, and the create's own listing failure follows it. The deadlock and blocked-process ensures keep today's Warning and Error.
* Tests: Lite's server-scoped long-query drop that fails on every cycle follows the cap the Azure arm has (#4964)
The server-scoped create and drop take the same per-database test replacement the Azure arm has, called with no database name. Red before the change: after the fifth failed drop on a server with no per-database sessions the reconcile is still not marked done (expected False, actual null), and after the cap gave up, turning the trace on and off again does not start the count again (expected False, actual null).
* Lite's server-scoped long-query drop that fails goes through the same cap as the Azure arm's (#4964)
The drop of the server's own session now throws the drop exception, with a server-scoped form that names no databases and a give-up warning that says the session may remain on the server. With the trace off, a drop that failed on every cycle used to reach the general catch and warn every cycle without end; it now warns four times, gives up once, and tries again once an hour at Debug. Turning the trace on ends the run of failed drops. The general catch's comment says why every drop failure reaches the catch above it and why it needs no clock move.
* Tests: Darling's server-scoped long-query drop that fails on every sweep follows the cap the Azure arm has (#4964)
The server-scoped create and drop take the same per-database test replacement the Azure arm has, called with no database name. Red before the change: after the fifth failed drop on a server with no per-database sessions the sweep is still not marked applied (expected False, actual null), and after the cap gave up, turning the trace on and off again does not start the count again (expected False, actual null).
* Tests: the Lite lifecycle test file keeps one kind of line ending again (#4964)
The server-scoped drop tests had been added with doubled carriage returns, which made git store the whole file with its working line endings and made the doc comment hygiene test count its doc runs from the wrong lines. The file is plain CRLF in the working tree again, so git normalizes it like the rest.
* Darling's server-scoped long-query drop that fails goes through the same cap as the Azure arm's (#4964)
The drop of the server's own session, its connection included, now throws the drop exception in its server-scoped form, and the worker's give-up warning says the session may remain on the server. With the trace off, a drop that failed on every sweep used to reach the general catch and warn every sweep without end; it now warns four times, gives up once, and tries again once an hour at Debug.
* Tests: the live schema rewind drops the install id table when it simulates an older store (#4961)
The helper that builds a store at an earlier version removes each newer rung's probe sentinel. The install id rung's sentinel is its table, so a store simulated below that rung drops it; without this the probe still reported the newest version for stores meant to be at V150 and V151.
* Tests: the deadlock and blocked-process Azure ensures that are refused on every cycle log their Warning set and Error once (#4964)
The shared Azure ensure takes a test replacement for its database listing, and the always-on sessions' entry point takes one for the open and ensure in each database, so the refusals reach the same per-database catch and all-refused throw as in production. Red before the level change: three cycles of a create refused in all three databases log nine per-database Warnings where three are expected, a refusal after a success logs six where three are expected, and a listing that fails with a SQL error logs three Errors where one is expected. Each failing cycle still throws the exception the collector records its failure from.
* The collector definition interface declares whether its read skips separately monitored databases, with no default (#4961)
* The deadlock and blocked-process Azure ensures that are refused on every cycle log their first failure at Warning and Error and the repeats at Debug (#4964)
Both sessions' ensure runs on every collector cycle in every monitored database, so a server that refuses the create refused it on every cycle, and each cycle logged a Warning per database and an Error for all of them without end. The first failing cycle now logs those lines at today's levels, and the cycles after it log the same lines at Debug, including a listing that fails with a SQL error, until a cycle of that session on that server succeeds. The state is kept per server and per session, next to the long-query trace's, and a restart warns again. The retry on every cycle and the exception each failing cycle throws for the collector to record do not change.
The test count of the databases tried now reads the ensure's session name rather than its log label.
* The two notes a per-database run carries when its database list came back empty, and the rule that picks one (#4961)
* Tests: Lite's per-database run with no database to read carries a note that says why (#4961)
* Lite's per-database run with no database to read records SUCCESS with a note that says why (#4961)
* Tests: Darling's per-database run with no database to read carries a note that says why (#4961)
* Darling's per-database run with no database to read records SUCCESS with a note that says why (#4961)
* Tests: the install id store on a server that refuses a login pg_control_system() (#4961)
A managed or hardened server may revoke EXECUTE on pg_control_system(), and the
install id's binding read then fails on every start. These facts pin what the
store must do instead, run as a login without superuser rights that has only
what the store's runtime role has on the install id table:
- a start stores a row with a NULL cluster id and the database OID, and logs
one Information line saying the id is bound to the database alone
- the read path (TryReadAsync) reads a row that has no cluster id and writes
nothing
- a later start keeps the same id when the cluster id was refused and is then
allowed, and when it was allowed and is then refused
- a different database OID still makes a new id, with one Warning naming both
- a failure of the cluster id read other than a refusal still fails the start
The migration rung pin now expects system_identifier to be nullable.
At this base the six refusal facts fail with SQLSTATE 42501 (permission denied
for function pg_control_system) or 23502 (the NOT NULL column), and the rung
pin fails on "system_identifier bigint,". The last fact (a failure other than a
refusal is not swallowed) passes at the base, because the base lets every
failure through; it guards the fix against a catch that is too wide.
* Tests: the Lite empty database list tests name their fixture plainly (#4961)
* The install id's binding falls back to the database alone when the server refuses a login pg_control_system() (#4961)
The install id is bound to the cluster's system_identifier, read from
pg_control_system(), plus the store database's OID. A plain login may call that
function by default, but a managed or hardened server can revoke it. The
binding read inside the service's start-up retry then failed on every attempt
and the service never started.
- A refusal of that read (SQLSTATE 42501, insufficient_privilege, and only
that) now falls back to the database's OID alone. The row is stored with a
NULL cluster id, and one Information line says the install id is bound to the
database alone because the cluster's identifier can't be read. Any other
failure of the read still fails the start, and a refusal of the OID read
itself is not answered with a fallback.
- The comparison counts the OID always, and the cluster id only when both the
stored row and the current read have one, so a grant that changes later, in
either direction, never makes a new id on its own. A different OID still
makes a new id and logs one Warning naming both ids. The Warning words a
store without a cluster id as a database OID alone.
- V158 (not shipped yet) now declares system_identifier nullable, edited in
place with no new rung. database_oid stays NOT NULL.
- The read path (TryReadAsync) never read the binding, so a refused server
changes nothing for it; it now reads a row stored without a cluster id, and
still runs one SELECT and writes nothing.
* The long-query session is one per install: named from the product and the install id, created stopped, and never created without an id (#4961)
Each install now creates, starts, reads and drops only PerformanceMonitor___LongQueryCompletions, on both engines and on both arms (server-scoped, and Azure SQL Database's database-scoped). The old shared name is LegacyXeSessionName: nothing creates, starts or reads it, and the statement builders refuse it for create and start.
The per-install DDL says STARTUP_STATE = OFF, so an orphan stops at the next server restart; the deadlock and blocked-process DDL is unchanged. Each ensure starts a session it finds stopped (Lite on each cycle, Darling on the Full, CreateOnly and after-cap passes).
A host with no install id creates nothing and never falls back to the legacy name: an enabled trace records the reason as a fault (Lite's fault slot, Darling's long-query fault and its worker text); a disabled one has nothing of its own to drop. The ring-buffer read takes the session name from the collector context and refuses a context with none.
The clean-up verb still lists the legacy name, on purpose, with a comment.
* Tests: the deadlock and blocked-process collectors' own line for a failed ensure, and the server-scoped ensures, log their first failing cycle at today's level and the repeats at Debug (#4964)
The two sessions' server-scoped ensure (on-premises, Managed Instance, RDS) is now one shared method with a test replacement for its connection and ensure, so a refusal reaches the same catch and the same exception as in production. Both sessions' ensure arms call it; its logging and the exception it throws are unchanged.
Red before the level change: three failing cycles of the server-scoped ensure log three Errors where one is expected (a permission refusal logs three Warnings where one is expected), three failing collector runs log the collector's own line at Error three times where once is expected, and a failure after a success logs four lines at Error where two are expected. A permission-refused collector logs its line at Warning on a second run where Debug is expected. Every failing cycle still throws the exception the run records its failure from, and the run row, its PERMISSIONS or ERROR classification and the collector's consecutive errors are the same on every cycle.
* The deadlock and blocked-process collectors' own line for a failed ensure, and the server-scoped ensures, log their first failing cycle at today's level and the repeats at Debug (#4964)
A deadlock or blocked-process session that cannot be ensured fails on every collector cycle, and two more lines repeated without end: the collector's own line for the failure, at Warning or Error, and the server-scoped ensure's one line (on-premises, Managed Instance, RDS), at Warning for a permission refusal and at Error for any other SQL error. Both now follow the rule the Azure ensure already follows, from the same per-server, per-session state: the first failing cycle logs at today's level, and the cycles after it log at Debug until a cycle of that session on that server succeeds. A session the engine reports as already present counts as a success, and a cancellation changes nothing. A restart warns again.
The ensure stamps the exception it raises with whether the cycle repeated, and the collector reads that stamp, so its line cannot disagree with the ensure's lines. Reads that fail, and the long-query trace, raise it unstamped and keep their levels. The retry on every cycle, the exception, the run row, its PERMISSIONS or ERROR classification and the collector's health record do not change.
* Tests: the long-query read of a logical server that lists master beside user databases monitored as their own servers records the note and reads no database (#4961)
* The long-query read of a logical server leaves out master as well as the databases monitored as their own servers, so a run with nothing left to read says why (#4961)
* Tests: the Lite empty database list fixture has an install id, so a run that reads a database can name the long-query session (#4961)
* Tests: the long-query collector's own line for a failed create logs the first failing cycle at today's level and the repeats at Debug, and a failed read keeps its level (#4964)
The long-query collector rethrows the failure the reconcile kept on every cycle, so its own line for a create that cannot succeed repeats at Warning or Error without end, beside create lines that already log the repeats at Debug. These tests drive the reconcile and the collector's run together for each shape the kept failure takes: an ensure failure (an error and a permission refusal), a create that fails again after it succeeded, an install with no id, and a server-scoped create that fails with a SQL error. They expect the first failing cycle's line at today's level and the repeats at Debug, with the run row, its classification and the retry the same on every cycle. A ring-buffer read that fails while the session exists keeps its level on every cycle, and a test pins that.
* Darling: a moved instance start time clears the long-query latch, so the next sweep starts the stopped session (#4961)
A restart stops the per-install long-query session, which is created with STARTUP_STATE = OFF, and a stopped session reads as a quiet one. When a collector run measures an identity change (the start time moved, or the name after a failover), the worker now clears the long-query latch and its hourly create clock. The next sweep then runs the whole reconcile and starts the session, instead of waiting up to an hour for the create pass.
* Shared: the on-premises co-owner guard compares the instances' last-known @@SERVERNAME (#4961)
KeptOnInstance says whether another registration of this install keeps the session on the same instance: it is monitored, has the trace on, and its last-reported name equals this registration's, ignoring case. A name that is not known matches nothing. ServerEpoch.LastKnownName reads that name from a carrier's persisted identity row, and IdentityCarrierCollectors lists the carriers. Both apps wire it in next; nothing calls it yet.
* The long-query collector's own line for a failed create logs the first failing cycle at today's level and the repeats at Debug until the trace is created (#4964)
A long-query trace that cannot be created fails on every cycle, and the collector's run rethrew the failure the reconcile kept on every one of them: its own lines for it repeated at Warning or Error without end, beside create lines that already log the repeats at Debug.
The first run to rethrow a kept failure logs its lines at their levels. The runs after it log the same lines at Debug, until the create succeeds or the trace is turned off, the same two events that end the create's own run of failures. That holds for every shape the kept failure takes: an ensure failure, a SQL error from a server-scoped create, and a failure that is no SQL error, such as an install with no id. The decision is made where the kept failure is rethrown and goes to the run on its own telemetry, so the stored exception is never changed, and the type, the message, the run row and its classification are the same on every run. A ring-buffer read that fails while the session exists is not a kept failure and keeps its levels.
* Tests: the long-query collector line tests' helper comment names the test's server (#4964)
* Tests: the deadlock and blocked-process Azure ensure keeps a shared session it did not make, starts a stopped one by name, falls back to the install's own, switches back, and the read names the chosen session (#4961)
The decisions, the install's own session names for the two captures and the host seams come with the tests; the ensure and the read do not use them yet.
* Tests: Lite drops the long-query session that earlier versions shared between installs once per registration and database, records it, and leaves it alone afterwards (#4961)
* Tests: a missing-session collector's line logs the first failing sweep at Warning and its repeats at Debug until a run succeeds (#4964)
The deadlock, blocked-process and long-query collectors fail on every sweep while their Extended Events session cannot be ensured. The state that tells the first failing sweep from the repeats is a stub that answers Warning every time for now, so these tests fail until the next commit tracks the repeats.
* The deadlock and blocked-process Azure ensure never drops a shared session: it starts a stopped one by name, falls back to the install's own session only when the shared one stays unusable, switches back, and the read names the chosen session (#4961)
Both products make the same decisions from one shared routine. The deadlock and blocked-process reads take the session name from the ensure's choice for each database. Darling ensures the two sessions once an hour as well as at connect. A failed Azure create or start carries a sentence that names the documented session caps.
* Tests: Lite's long-query trace leaves the session another registration of the same instance keeps, and a removed server drops only its own (#4961)
The on-premises trace-off drop and a server's removal both check whether another registration of this install keeps the session on the same instance. The shared guard holds a registration's last-known name beside the other registrations that have the trace on, and a name read tries the identity carriers in order. The removal's drop is a stub here, so the new removal tests fail until it is filled in.
* Lite drops the long-query session that earlier versions shared between installs once per registration and database, records the drop in collector_state, and leaves that session alone afterwards (#4961)
* A missing Extended Events session's per-sweep line logs the first failing sweep at Warning and the repeats at Debug until a run of that collector succeeds (#4964)
The deadlock, blocked-process and long-query collectors fail on every sweep while their Extended Events session cannot be ensured, and each failed run logged its 'XE session missing (capture down)' line at Warning, sweep after sweep. The first failing run of a collector on a server logs it at Warning. The runs after it log the same line at Debug, until a run of that collector on that server succeeds. The state is kept per server and per collector, in memory, so a restart warns again. Every failing run is still classified and recorded as SESSION_MISSING, so collection health reads the same.
* Darling drops the long-query session older versions shared between installs once for each registration and database, records the drop in collector_state, and never touches that session again (#4961)
* Tests: the restart reset's long-query tests keep the legacy drop's record in memory and count only the per-install calls (#4961)
* Lite: the long-query trace leaves the session another registration of the same instance keeps, and a removed server drops its own (#4961)
A registration whose trace is off no longer drops the on-premises session when another registration of this install, monitored with its trace on, reports the same last-known instance name: the drop would stop that trace. A name that is not known still drops, as before.
Removing a server now drops this install's long-query session before its state is cleared, in one attempt within 15 seconds. On Azure SQL Database it skips databases another registration keeps. On premises it skips a session another registration of the same instance keeps, and leaves it in place, logging why, when the removed server's name is not known and another registration could keep it. A failure or the timeout is logged and never stops the removal. A server whose last reconcile already dropped the session is not contacted, and a reconcile that runs after the removal begins does not create the session again.
* Tests: the long-query trace's create opens its connection by read-only intent, and a read-only database gets one message and no retry loop (#4961)
Adds a test hook below the per-database override, in Lite and Darling: it replaces
one open-and-act step of the create and hands over the connection string the step
would have opened, so a test can see whether each step uses read-only intent. The
existing per-database override still replaces the whole step and wins when both
are set. Lite builds the per-database connection string in one place that the
open and the hook both use.
Tests, in both apps:
- Azure SQL Database, read-only intent: the definition is created over a
connection without the intent and the session is started over the
registration's own read-only connection.
- Azure SQL Database without the intent, and on-premises with it: one step over
the registration's own connection.
- A registration that lands on a read-only database without the intent (error
3906): one message that says why and what to change, the run keeps the fault,
and no attempt in a loop.
* Long-query trace: a read-only-intent registration on Azure SQL Database creates the session's definition over a connection without the intent and starts it over its own, and a read-only database gets one message and no retry loop (#4961)
Azure SQL Database, a registration with read-only intent (Lite and Darling): a
session cannot be created on a read-only replica, and the definition replicates
from the primary, so the registration creates its install's definition over a
connection without the intent and does not start it there. It then starts the
session over its own read-only connection; run state is per replica. A
registration without the intent, and every registration on another engine, keeps
its one connection and its create-and-start, as before.
A create that a read-only database refuses (error 3906, a registration that lands
on an Azure geo-secondary without the intent) is detected. It logs one message
that says why and what to change, the database's own line, and the other lines of
that failure log at Debug. The run still records the fault. The create is not
tried again for an hour: Lite holds it per server, and Darling latches the
reconcile so the hourly create pass is the next attempt. A reconnect or a change
of the trace's settings tries again at once.
* Lite: a removed server also drops the deadlock and blocked-process sessions this install chose in its databases (#4961)
On Azure SQL Database, removing a server drops the install's own deadlock and blocked-process sessions in each database where the install chose them, by their own names, unless another registration of this install reads its own session there. The shared sessions are never dropped, and the server's choices are forgotten afterwards.
Tests: the repo-file census declares the test file that reads source with the line-feed reader.
* Tests: a removed server's session stays when a registration that keeps the trace on has no known instance name (#4961)
The removal checks once and never again, so it leaves the session whenever another registration that keeps the trace on could be on the same instance and the match cannot be ruled out. The guard's removal reason covers this registration's own unknown name, but not the name of a registration that keeps the trace on. These pin the reason for each case, and that the trace-off drop, which asks for a positive match, is unchanged.
* Test: Lite's legacy long-query drop on Azure SQL Database leaves a database monitored as its own server (#4961)
The legacy drop must cover the same databases as the trace-off path and Darling's drop: every listed database except master and the
databases monitored as their own servers, whose own registration runs its own legacy drop under its own record. This test pins that
for the trace on and off, and that no record is written for the left-out database.
* A removed server's long-query session also stays when a registration that keeps the trace on has no known instance name (#4961)
The removal checks once, so it leaves the session whenever another registration that keeps the trace on could be on the same instance and the match cannot be ruled out. The shared guard returned a reason only when this registration's own name was unknown or a name matched; a keeper with no known name now gives one too. The trace-off drop, which needs a positive match, is unchanged.
* Tests: Lite's removal drops a database's fallback sessions unless a registration of the same server reads its own there (#4961)
A database of the same name on another logical server is another database. The test gives another server an own-session choice in a database of the same name as the removed server's and expects the drop there, while a registration of the removed server's own logical server, named with another case and padding, still holds its database back.
* Lite: the legacy long-query drop on Azure SQL Database skips the databases monitored as their own servers, as the trace-off path and Darling do (#4961)
The one-time drop of the session older versions shared between installs ran in every listed database except master. A database
monitored as its own server runs its own legacy drop under its own record, so this registration now asks the same plan the
trace-off path asks (LongQueryTraceDatabases.Plan with the separately monitored databases, no exclusion and no other
registration's session applied). The listed-database comment says so.
* Lite: a removed server's fallback sessions are held back only by a registration of the same logical server (#4961)
The removal built the databases other registrations keep from every other server's own-session choices by database name, so a database of the same name on another logical server held the removed server's fallback session back. It now counts only the other registrations on the same host, ignoring case and padding, then the database.
* Lite: the drop before a recreate takes only this install's own long-query session, and the comments around it say so (#4961)
The Azure ensure driver's drop-and-recreate now builds its statement through a guard that refuses every name but this install's own long-query session (the shared deadlock, blocked-process and long-query names, another install's session and an install with no id are refused). The driver's summary, the Azure comments in the deadlock and blocked-process ensures, the give-up comment and the per-database routine parameter (renamed ensureInDatabase) no longer say a deadlock or blocked-process session is dropped or self-healed.
* Test: a failed long-query create or start on Azure SQL Database carries the caps sentence in both apps, and a read-only refusal, an on-prem refusal and an 'already there' do not (#4961)
Each app drives its real reconcile with the Azure step replaced by a refusal. A refused create and a refused start must put the
caps sentence in the failure line and the recorded fault. The three answers that no cap explains must leave it out.
* Tests: --drop-xe-sessions drops this install's sessions with the shared ones and lists other installs' sessions without dropping them (#4961)
The signatures the new tests call are in place with the old behavior, so the project builds and the new tests fail on what they check.
* Tests: the deadlock and blocked-process sessions' ensure uses a connection without read-only intent for a create and a drop, and a read-only database gets one explanation (#4961)
Adds a test hook below the per-database override, in Lite and Darling: it replaces
each connection the always-on sessions' ensure opens to one Azure SQL Database and
hands over the connection string the open would use, so a test sees whether each
statement goes over a connection with read-only intent. The existing override
still replaces the whole database and wins when both are set. Lite's removed-server
drop of the install's own sessions is reachable from a test.
Tests, in both apps, against a scripted primary and read-only replica:
- Read-only intent: a missing shared session and an own fallback are created over a
connection without the intent and started over the registration's own.
- Read-only intent: switching back to the shared session, and (Lite) a removed
server, stop the own session over the registration's own connection when it runs
there, then drop it over a connection without the intent.
- Read-only intent, healthy sessions: no connection without the intent is opened.
- No intent: every statement stays on the registration's own connection, as before.
- Darling: a read-only database reached without the intent gets the read-only
explanation once, without the caps sentence.
* Tests: the startup-state pin follows the shared deadlock and blocked-process DDL to AlwaysOnXeSessions, and both kinds pin OFF for this install's own fallback sessions (#4961)
* Tests first: a read-only-intent registration stops a session over its own connection and then drops it over one without the intent, and a cycle that needs nothing opens only its own connection (#4961)
* A failed long-query create or start on Azure SQL Database carries the Azure caps sentence in Lite and Darling, and a read-only refusal never does (#4961)
The deadlock and blocked-process sessions already put AzureCapsSentence on a failed create or start in an Azure SQL Database. The
long-query session now does too, in both apps: each Azure create and start statement runs through one helper that marks its failure,
and the failure line, the all-refused exception's message (Lite) and the recorded fault (both) read it through DescribeFailure.
Nothing is marked on any other engine, and the engine's 'already there' (25631, 25705) is not a failure.
A refusal because the database is read-only (error 3906, as a geo-secondary answers) is left unmarked by the shared
MarkAzureCapsFailure, which now takes the error numbers the host reads off its own exception, so the deadlock and blocked-process
ensure gets the same exclusion through IAlwaysOnXeDatabase.ErrorNumbers.
* Long-query trace text names this install's own session: the banner, the schedule descriptions, the tool comments and the Darling README say the session is per install and that another registration of the install can keep it (#4961)
* --drop-xe-sessions drops this install's long-query session and Azure fallbacks with the shared sessions, and lists other installs' sessions with a guarded DROP for the operator (#4961)
The verb reads this install's id from the store. With an id it also drops the sessions named for it; without one it says so and leaves every session named for an install alone. It lists the sessions that carry another install's id, on the server or in each database it searched, and never runs their statements. --print-sql prints a query for the sessions named for an install, with each DROP statement as a column.
* Tests: Darling's server removal drops this install's sessions on the server, and a removed server's sweep already in flight creates none (#4961)
The removal's capture and drop are stubs here, so these tests state what the removal must do: capture each removed server before the registry forgets it, drop the long-query session and the deadlock and blocked-process fallbacks this install chose (on Azure SQL Database in each database no other registration keeps, on premises unless the instance guard leaves it), one attempt within the timeout that never stops the removal, never the legacy session, and no create by a retired server's reconcile or always-on ensure.
* Tests: an on-premises server with its long-query trace off leaves the session another registration of this install keeps on the same instance (#4961)
The guard builder and the instance guard parameter are in place but inert, so the cases that need them fail until the next commit.
* Darling: a guard builder tells a server's long-query drop whether another registration of this install keeps the session on the same instance (#4961)
The builder reads the live registry's other SQL Server registrations whose long-query trace is on, then this server's last-known instance name and each candidate's, in that order: no candidate reads nothing, and an unknown own name reads no candidate's.
* The deadlock and blocked-process sessions on Azure SQL Database create and drop over a connection without read-only intent, and start and read over the registration's own (#4961)
A session cannot be created or dropped over a connection to a read-only replica,
and a definition made on the primary replicates to it, so for a registration with
read-only intent every create in the ensure used to be refused. Now, in Lite and
Darling:
- A create of the shared session or of this install's own fallback sends the
definition (no start) over a connection with the intent forced off, then starts
the session by name over the registration's own connection. The probes and the
read stay on the own connection. The connection without the intent is opened on
the first create or drop that needs it and closed with the pass, so a healthy
database never opens one.
- Dropping the own fallback, on the switch back to a usable shared session and for
a removed server in Lite, follows the documented order: stop it over the
registration's own connection when it runs there, then drop it over a connection
without the intent. One routine holds the order for both.
- A registration without the intent, and on-premises, are unchanged: the create
is still one batch with its start, and the drop is one statement, over its own
connection.
- Darling: a create that a read-only database refuses with error 3906, reached
without the intent as an Azure geo-secondary is, logs one message that says why
and what to change, at Warning once and at Debug on the passes after it, in
place of the caps sentence. Passes stay hourly.
The connection interface gains a read-only-intent flag and a without-intent
execute, with defaults that keep every existing implementation unchanged; the
builders gain a definition-only create and an own-name-only stop.
* Darling's server removal drops this install's sessions on the server, and a removed server's sweep already in flight creates none (#4961)
Removing a server captures its definition, runtime and long-query latch inside the servers' lock, before the reconcile forgets it, then awaits the drop once the lock is released: the long-query session (on Azure SQL Database in each database no other registration keeps, on premises unless the instance guard leaves it, with the reason logged), then this install's own deadlock and blocked-process fallbacks in each database where it chose them, unless a registration of the same logical server reads its own session there. One attempt for the whole step within 15 seconds; a failure or the timeout logs a warning and never stops the removal. A server whose trace was confirmed dropped is not contacted for the long-query session, and the removal never runs the one-time legacy drop (a new pass value skips it).
The reconcile and the always-on ensure now return for a retired server, so a sweep that was already running when the removal dropped the sessions does not create them again.
* Tests: the one-time legacy long-query drop record is kept per server in both apps, so one server's record never stops another server's drop (#4961)
Darling's live test and Lite's lifecycle test now hold two servers in one store, with the same database names. The first server's record, for server scope and for Azure database names, leaves the second server's drop to run; the second server's own record then stops its second drop after a restart, and each server holds exactly its own keys.
* Darling: an on-premises server with its long-query trace off leaves the session another registration of this install keeps on the same instance (#4961)
The sweep hands the reconcile a lazy guard for a SQL Server target that is not an Azure SQL Database. After the one-time legacy drop, a positive match skips the per-install drop and logs that the session was left in place; the reconcile counts as done, so no later sweep reconnects for it. An unknown name drops as before, and a trace that is on never resolves the guard.
* Darling's server removal asks the instance guard builder on premises, and a test pins the drop's timeout and that wiring (#4961)
* Tests: plain wording in two comments of the on-premises guard tests (#4961)
* Darling: a removed server's own deadlock and blocked-process sessions on Azure SQL Database are stopped over the registration's own connection, then dropped over a connection without read-only intent (#4961)
The removal drops the install's own fallback sessions through the one routine the
switch back uses: for a registration with read-only intent the session is stopped
over its own connection when it runs there, then dropped over a connection without
the intent. A registration without the intent drops it in one statement over its own
connection, as before.
* A read-only-intent registration stops the long-query session over its own connection before dropping it over one without the intent, and a cycle that needs nothing opens only its own connection (#4961)
A session cannot be created over a read-only connection, a definition made on the primary replicates to the replicas, and a session that runs on a replica is stopped over that replica and then dropped over the primary. Every drop of the session (trace off, removal, the one-time legacy drop) now takes Stop over the registration's own connection and Drop over one with the intent forced off for a read-only-intent registration on Azure SQL Database, and one Drop over its own connection for everyone else. The ensure reads one check batch over its own connection (definition, whether it runs, legacy probe), opens the connection without the intent only when the definition is missing, and starts the session over its own connection when it does not run. A start the replica refuses with error 15151 right after this cycle created the definition is logged at Debug with no fault, and Darling leaves its latch unset so the next sweep retries. Not tested against a live replica.
* Tests: the drop verb's co-owner guard reads each registration's effective long-query setting, and leaves the session another registration keeps on the same instance (#4961)
The tests fail on the current verb, which counts every other registration on Azure SQL Database as keeping the session and never checks the instance on-premises. The server-scope search result moves into a method of its own so a test drives it without a server.
* Darling: the drop verb counts another registration as keeping the long-query session only when its effective setting is on, and leaves the session another registration keeps on the same instance (#4961)
On Azure SQL Database the verb used to count every other registration of the logical server as keeping the session. It now reads each one's long-query setting from the store's schedule rows (its own override, else the install's default), and counts them all only when those rows could not be read. On an instance, it asks the service's own guard whether another registration of this install keeps the session there, from the instance names the verb already read, and leaves the session in place with a note that says why.
* Both READMEs describe the Extended Events sessions each install creates, and a missing capture session is reported as created again within the hour (#4961)
Lite and Darling each get one README section on the sessions the app creates: the install id and where it lives, the long-query session name, the old shared session and its one-time drop, the shared deadlock and blocked-process sessions and the Azure fallbacks, what removing a server drops, read-only intent on Azure SQL Database, the Azure caps, the accepted limits, and the clone step. Lite's section also gives the guarded DROP EVENT SESSION statements for server and database scope, because Lite has no cleanup command.
The SESSION_MISSING message no longer says a dropped session stays missing until the next connect. It says the app creates a missing session and starts a stopped one within the hour, and it ends on the general epilogue, so it no longer asks for a reconnect. The collection docs row says the same, and the test pins the wording and the one-hour interval together.
* Tests: Lite's deadlock and blocked-process ensure tells a read-only database's refusal once, without the caps sentence, and holds the create for an hour (#4961)
Three tests against a scripted database that is read-only itself, reached without
read-only intent: the refusal is one line a capture at Warning, saying why and what to
change, with no caps sentence, and the tries after it stay below Warning; a pass inside
the hour sends no create, reads nothing and opens no connection, and the pass an hour
after the refusal tries again; a database that became writable is created at that try,
and the next refusal is told at Warning again. The scripted connection reads error
numbers off the exception as the app's own connection does.
* Lite: a read-only database's refusal of the deadlock and blocked-process create is told once, in the one message that says why, and the create is held for an hour (#4961)
On Azure SQL Database, a registration that lands on a read-only database without
read-only intent, as an Azure geo-secondary does, cannot create a session. The ensure
logged the server's own refusal at Warning and tried the create again on every
collection cycle. Now, as in Darling:
- The refusal logs the read-only message of the shared builder, which says why and
what to change and carries no caps sentence: at Warning the first time for a
database and capture, at Debug on the tries after it.
- The create is not sent again for an hour per registration, database and capture:
a cycle inside the hour returns before it opens a connection, and the cycle an hour
after the refusal tries again. A pass that gets through forgets the hold and the
first-time flag, and so does a server's removal.
- The database counts as ensured, so the ensure raises nothing for it; Darling does
the same.
* Tests: Darling's removal of a server drops its own deadlock and blocked-process fallback over a connection without read-only intent, after a stop over the own connection when it runs there (#4961)
Two tests, against a scripted primary and read-only replica, drive the removal's drop
of a registration with read-only intent: a fallback that runs on the replica is stopped
over the registration's own connection, then dropped over a connection without the
intent; one that does not run there is only dropped, without the intent. Routing the
drop over the own connection fails both. The scripted connection now accepts the
database name in upper case, which is how the removal reads it back from the choices.
* The SESSION_MISSING message and the collection docs row say the app ensures its sessions at least once an hour (#4961)
* Darling: the drop verb's help, README, class summary and the remove_server answer say that removing a server drops this install's own sessions (#4961)
The text used to say the service never drops these sessions when a server is removed. It now says removal drops this install's own sessions, one attempt within 15 seconds, and leaves the shared ones and a session another registration keeps. The verb is for the shared sessions, the old shared long-query session, and listing other installs' sessions. The pins move with the text.
* Tests: Darling's note for a partial failure of the long-query create on Azure SQL Database carries the caps sentence, and a read-only refusal does not (#4961)
One of two databases refuses the create: the run's note names the first failure, and
a create refused with a cap-type error must carry the caps sentence, as the
per-database line does; error 3906 must not. The source pin of the note's composer
follows it to the worded first failure.
* The state prune keeps the long-query drop record, the drop verb's store reads carry the CLI deadline, and device-code registrations sign in over their database connections (#4961)
- The record that the shared long-query session was dropped is listed as not keyed by database name. On server scope its key is the bare
prefix, so a prune that rebuilds keys as prefix + database name would delete it every cycle and the one-time drop would run again each
time. The prune test pins the list whole.
- The two store reads --drop-xe-sessions makes before it connects to a server take the CLI store-read deadline their neighbours take, and
the per-file census counts them.
- OpenAzureDatabaseConnectionAsync claims the device-code sign-in slot behind the lock CreateConnectionAsync takes, so the connection the
long-query trace creates its session's definition over (read-only intent forced off) shows the sign-in window, and a declined sign-in
stops the opens queued behind it. Every other authentication mode opens as it did. The registration's connection string is resolved in
RemoteCollectorService.cs, which the long-query trace's test seam now reads it through.
- The Darling README pin follows the per-install session name the README already states.
- The long-query read in the stored-event-copies and XE time-filter sweep tests gets the install's session name on its context, which the
read requires of a host, so both reach it again.
* Darling: the note for a partial failure of the long-query create on Azure SQL Database words the first failure as the per-database line does, so it carries the caps sentence (#4961)
The run's note for a create that some databases refused named the first failure by its
raw message, while the line logged for each database added the caps sentence. The note
now takes the same wording through the shared describer, so a create or start refused
on Azure carries the per-database session caps, and a read-only refusal still does not.
* The legacy-session and long-query drop seams read the registration's connection string through RemoteCollectorService.cs, where the device-code sign-in is handled (#4961)
* Tests first: the drop verb stops a session over a read-only-intent registration's own connection when it runs there, then drops it over a connection without the intent (#4961)
The verb's target drops over the registration's own connection, which an Azure SQL Database refuses when it carries read-only intent. These tests pin the new order: a check, a stop over the own connection for a session that runs on the replica, then the drop over a connection without the intent; one drop over the own connection for everyone else. The stop accepts exactly the names the drop does. The target's seam and a stop that is not built yet are here so the tests run and fail on the order, not on a missing name.
* Tests: Lite leaves only a not-visible start right after a create for the next cycle; the same refusal after a visible definition, and an out-of-space or read-only refusal right after a create, are faults (#4961)
The Lite long-query ensure already behaves this way, so these pass at the base; they pin the other half of the rule next to the existing start-after-create test, which Darling's class mirrors.
* Darling: the drop verb stops a session over a read-only-intent registration's own connection when it runs there, then drops it over a connection without the intent (#4961)
On Azure SQL Database a session cannot be dropped over a read-only connection. For a registration with read-only intent, each drop now checks whether the session runs on the replica the own connection reaches, stops it there if it does, and drops it over a connection with the intent forced off, through the wrapper the services use. Everyone else, and every server-scoped target, still sends one DROP over its own connection.
The stop takes exactly the names the drop takes. Long-query names use the long-query stop builder, an install's own deadlock and blocked-process names use the always-on one, and the two shared names get the same guard on the sessions that run in the database.
* README: the by-hand cleanup's server-scope block drops only the old shared session and this install's own long-query session (#4961)
Lite makes the deadlock and blocked-process fallbacks in Azure SQL Database only, so a server-scoped instance has none to drop. The sentence before the block now counts the two statements it holds, and the database-scope block, which keeps its four, says 'these four' rather than 'the same four'.
* Darling README: the drop verb's section says a read-only-intent registration stops a session over its own connection before it drops it over one without the intent (#4961)
* Read-only-intent tests assert on every line at Warning or above and name the expected line, so an extra Warning in the path they drive fails them (#4961)
The long-query trace tests in both apps and the Darling deadlock and blocked-process tests picked the lines they checked by the word read-only or session, so a second Warning without that word, such as a failed stop or a failed drop of the legacy session, passed unseen. They now check every Warning-or-above line, and the read-only message is compared to the exact line the product logs: registration, database and the message text.
* Darling README: the drop verb's read-only paragraph is four short sentences and no longer claims an order the engine documents (#4961)
For a registration with read-only intent the verb stops the session over the registration's own connection when it runs there, then drops it over a connection without the intent.
* Lite tests: a per-database open for an Entra MFA registration waits behind the sign-in lock and refuses a declined server without connecting; a SQL login takes no lock (#4961)
The tests drive the open against a local server that accepts a socket and never answers, so whether an open reached the network is read from the listener. They fail until the open asks the interactive-mode question CreateConnectionAsync asks.
* Lite: a per-database open takes the sign-in lock and checks for a declined sign-in for every interactive mode, as CreateConnectionAsync does (#4961)
The open asked the connection string whether it signs in by device code, so an Entra MFA registration's opens skipped the lock and the declined-sign-in check and could raise a second window. It now asks AuthenticationTypes.RequiresInteractiveSignIn, the question CreateConnectionAsync asks. Begin and the decline flag are unchanged.
* Lite tests: the silent-server helper builds its address from the loopback constant and its names say what they hold (#4961)
* Lite tests: both collector opens decide a declined sign-in through one shared helper, so an Entra MFA sign-in the broker reports as cancelled is a decline in the per-database open too (#4961)
The decision is read from the helper's body, each open is pinned to call it without a copy of its conditions, and the helper itself is driven for a broker-cancelled message, an unrelated failure, a prompt's own cancel and a shutdown.
* Lite: an Entra MFA sign-in cancelled in the browser during a per-database open is flagged as declined, so the opens queued behind the sign-in lock stop instead of each raising a prompt (#4961)
Both opens that can raise a prompt, CreateConnectionAsync and the per-database open, now ask one helper whether the user declined: the broker's cancelled message, or the device-code prompt's own token cancelled while the caller's token was not. A shutdown is still never a decline.
---
.../AlwaysOnXeReadOnlyIntentTests.cs | 423 ++++
.../Darling.Tests/AlwaysOnXeSessionsTests.cs | 337 +++
.../AzureForeignStatePruneTests.cs | 7 +-
.../CaggGroupIndexDropLiveTests.cs | 8 +
.../Darling.Tests/DarlingCliCommandsTests.cs | 6 +-
.../DarlingCliUnusableStoreConnectionTests.cs | 8 +-
.../DropXeSessionsHelpGuidanceTests.cs | 7 +
.../DropXeSessionsPerInstallTests.cs | 411 ++++
.../DropXeSessionsReadOnlyIntentTests.cs | 292 +++
.../Darling.Tests/DropXeSessionsVerbTests.cs | 286 ++-
.../EmptyDatabaseListNoteLiveTests.cs | 187 ++
.../EmptyDatabaseListNoteTests.cs | 106 +
Darling/Darling.Tests/InstallIdRungTests.cs | 134 ++
.../LegacyLongQueryRecordLiveTests.cs | 218 ++
.../LongQueryBannerSessionScopeTests.cs | 16 +-
.../LongQueryTraceInstanceGuardTests.cs | 317 +++
.../LongQueryTraceLifecycleTests.cs | 1177 +++++++++-
.../LongQueryTraceReadOnlyIntentTests.cs | 581 +++++
.../LongQueryTraceRemovalTests.cs | 575 +++++
.../LongQueryTraceRestartTests.cs | 177 ++
.../MuteRuleServerIdRungTests.cs | 16 +-
.../PostgresEngineGateBehaviorTests.cs | 11 +-
.../Darling.Tests/RepoFileAdoptionTests.cs | 8 +
.../RuntimePreconditionMissTests.cs | 23 +-
.../Darling.Tests/StoreInstallIdLiveTests.cs | 388 ++++
.../StoreInstallIdRefusedClusterLiveTests.cs | 339 +++
.../StoreInstallIdSourcePinTests.cs | 126 +
.../StragglerCommandTimeoutTests.cs | 9 +-
.../SwallowedItemFailureTests.cs | 5 +-
.../XeSessionMissingWarningTests.cs | 168 ++
.../DarlingAlwaysOnXeSessions.cs | 199 ++
.../DarlingCliCommands.cs | 134 +-
.../DarlingCollectorRunner.cs | 147 +-
.../DarlingLegacyLongQuerySession.cs | 341 +++
.../DarlingRemovedServerSessions.cs | 267 +++
.../DarlingWorker.cs | 305 ++-
.../DarlingXeSessionCleanup.cs | 649 +++++-
.../DarlingXeSessions.cs | 1127 ++++++---
.../Mcp/DarlingMcpLongQueryTools.cs | 4 +-
.../Mcp/DarlingMcpServerAdminTools.cs | 6 +-
.../ServiceCommandDeadlines.cs | 4 +-
.../XeSessionMissingWarnings.cs | 41 +
.../PgMigrations.cs | 40 +
.../StorageVersion.cs | 2 +-
.../StoreInstallId.cs | 288 +++
.../ViewerDataService.cs | 19 +-
.../ViewerServerTab.xaml | 5 +-
Darling/README.md | 70 +-
.../AlwaysOnXeReadOnlyIntentLiteTests.cs | 523 +++++
Lite.Tests/AlwaysOnXeSessionsLiteTests.cs | 432 ++++
...ndBlockedProcessEnsureLogLevelLiteTests.cs | 680 ++++++
Lite.Tests/EmptyDatabaseListNoteLiteTests.cs | 322 +++
Lite.Tests/EntraDeviceCodeTests.cs | 449 +++-
Lite.Tests/InstallIdStoreTests.cs | 459 ++++
Lite.Tests/InstallIdTests.cs | 91 +
.../LongQueryBannerSessionScopeTests.cs | 16 +-
...ueryCompletionsCollectorDefinitionTests.cs | 225 +-
.../LongQueryTraceLifecycleLiteTests.cs | 2045 ++++++++++++++++-
.../LongQueryTraceReadOnlyIntentLiteTests.cs | 560 +++++
Lite.Tests/SettingsImportTests.cs | 137 ++
Lite.Tests/StoredEventCopiesTests.cs | 5 +
Lite.Tests/SwallowedItemFailureTests.cs | 7 +-
Lite.Tests/XeTimeFilterSweepTests.cs | 6 +
Lite/Controls/ServerTab.xaml | 4 +-
Lite/MainWindow.xaml.cs | 43 +-
Lite/Mcp/McpLongQueryTools.cs | 2 +-
Lite/Services/InstallIdStore.cs | 333 +++
...moteCollectorService.AlwaysOnXeSessions.cs | 266 +++
...teCollectorService.BlockedProcessReport.cs | 362 ++-
.../RemoteCollectorService.Deadlocks.cs | 150 +-
...RemoteCollectorService.DefinitionRunner.cs | 44 +-
...CollectorService.LegacyLongQuerySession.cs | 359 +++
...teCollectorService.LongQueryCompletions.cs | 951 +++++++-
Lite/Services/RemoteCollectorService.cs | 241 +-
Lite/Services/ScheduleManager.cs | 2 +-
Lite/Services/SettingsImport.cs | 52 +
.../AlwaysOnXeSessions.cs | 826 +++++++
.../BlockedProcessReportCollector.cs | 8 +
.../CollectorContext.cs | 17 +
.../CollectorRuntimePrecondition.cs | 19 +-
.../CollectorScheduleDefaults.cs | 6 +-
.../DeadlocksCollector.cs | 8 +
.../EmptyDatabaseListNote.cs | 62 +
.../ICollectorDefinition.cs | 10 +-
PerformanceMonitor.Collectors/InstallId.cs | 60 +
.../LegacyLongQuerySession.cs | 25 +
.../LongQueryCompletionsCollector.cs | 184 +-
.../LongQueryTraceDatabases.cs | 174 +-
.../LongQueryTraceStep.cs | 64 +
.../QueryStorePerDatabaseState.cs | 16 +-
PerformanceMonitor.Collectors/ServerEpoch.cs | 45 +
README.md | 87 +
docs/how-collection-works.md | 2 +-
93 files changed, 19265 insertions(+), 1128 deletions(-)
create mode 100644 Darling/Darling.Tests/AlwaysOnXeReadOnlyIntentTests.cs
create mode 100644 Darling/Darling.Tests/AlwaysOnXeSessionsTests.cs
create mode 100644 Darling/Darling.Tests/DropXeSessionsPerInstallTests.cs
create mode 100644 Darling/Darling.Tests/DropXeSessionsReadOnlyIntentTests.cs
create mode 100644 Darling/Darling.Tests/EmptyDatabaseListNoteLiveTests.cs
create mode 100644 Darling/Darling.Tests/EmptyDatabaseListNoteTests.cs
create mode 100644 Darling/Darling.Tests/InstallIdRungTests.cs
create mode 100644 Darling/Darling.Tests/LegacyLongQueryRecordLiveTests.cs
create mode 100644 Darling/Darling.Tests/LongQueryTraceInstanceGuardTests.cs
create mode 100644 Darling/Darling.Tests/LongQueryTraceReadOnlyIntentTests.cs
create mode 100644 Darling/Darling.Tests/LongQueryTraceRemovalTests.cs
create mode 100644 Darling/Darling.Tests/LongQueryTraceRestartTests.cs
create mode 100644 Darling/Darling.Tests/StoreInstallIdLiveTests.cs
create mode 100644 Darling/Darling.Tests/StoreInstallIdRefusedClusterLiveTests.cs
create mode 100644 Darling/Darling.Tests/StoreInstallIdSourcePinTests.cs
create mode 100644 Darling/Darling.Tests/XeSessionMissingWarningTests.cs
create mode 100644 Darling/PerformanceMonitor.Darling.Service/DarlingAlwaysOnXeSessions.cs
create mode 100644 Darling/PerformanceMonitor.Darling.Service/DarlingLegacyLongQuerySession.cs
create mode 100644 Darling/PerformanceMonitor.Darling.Service/DarlingRemovedServerSessions.cs
create mode 100644 Darling/PerformanceMonitor.Darling.Service/XeSessionMissingWarnings.cs
create mode 100644 Darling/PerformanceMonitor.Darling.Storage/StoreInstallId.cs
create mode 100644 Lite.Tests/AlwaysOnXeReadOnlyIntentLiteTests.cs
create mode 100644 Lite.Tests/AlwaysOnXeSessionsLiteTests.cs
create mode 100644 Lite.Tests/DeadlockAndBlockedProcessEnsureLogLevelLiteTests.cs
create mode 100644 Lite.Tests/EmptyDatabaseListNoteLiteTests.cs
create mode 100644 Lite.Tests/InstallIdStoreTests.cs
create mode 100644 Lite.Tests/InstallIdTests.cs
create mode 100644 Lite.Tests/LongQueryTraceReadOnlyIntentLiteTests.cs
create mode 100644 Lite.Tests/SettingsImportTests.cs
create mode 100644 Lite/Services/InstallIdStore.cs
create mode 100644 Lite/Services/RemoteCollectorService.AlwaysOnXeSessions.cs
create mode 100644 Lite/Services/RemoteCollectorService.LegacyLongQuerySession.cs
create mode 100644 Lite/Services/SettingsImport.cs
create mode 100644 PerformanceMonitor.Collectors/AlwaysOnXeSessions.cs
create mode 100644 PerformanceMonitor.Collectors/EmptyDatabaseListNote.cs
create mode 100644 PerformanceMonitor.Collectors/InstallId.cs
create mode 100644 PerformanceMonitor.Collectors/LegacyLongQuerySession.cs
create mode 100644 PerformanceMonitor.Collectors/LongQueryTraceStep.cs
diff --git a/Darling/Darling.Tests/AlwaysOnXeReadOnlyIntentTests.cs b/Darling/Darling.Tests/AlwaysOnXeReadOnlyIntentTests.cs
new file mode 100644
index 0000000000..af1e901050
--- /dev/null
+++ b/Darling/Darling.Tests/AlwaysOnXeReadOnlyIntentTests.cs
@@ -0,0 +1,423 @@
+/*
+ * 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.Threading;
+using System.Threading.Tasks;
+using Microsoft.Data.SqlClient;
+using Microsoft.Extensions.Logging;
+using Npgsql;
+using PerformanceMonitor.Collectors;
+using PerformanceMonitor.Darling.Service;
+using Xunit;
+
+namespace Darling.Tests;
+
+///
+/// Which connection each step of the deadlock and blocked-process sessions' ensure uses on Azure SQL Database, for a
+/// registration with read-only intent and for one without (#4961). A session cannot be created or dropped over a connection
+/// to a read-only replica, and a definition made on the primary replicates to it, so a registration with the intent creates
+/// over a connection without it. The start, the probes and the read stay on the registration's own connection, because run
+/// state is per replica. A session that runs on the replica is stopped there before it is dropped on the primary, on the
+/// switch back and when the server is removed. Each test drives the real ensure against a scripted primary and replica, which
+/// see the connection string each open would use.
+///
+public sealed class AlwaysOnXeReadOnlyIntentTests : IAsyncDisposable
+{
+ private const string Host = "intent.database.windows.net";
+ private const string InstallIdValue = "0a1b2c3d";
+
+ /* Never opened: the runner reaches the store only to write collected rows, and these tests collect nothing. */
+ private readonly NpgsqlDataSource _store = NpgsqlDataSource.Create("Host=127.0.0.1;Port=1;Database=unused;Username=unused");
+
+ public ValueTask DisposeAsync() => _store.DisposeAsync();
+
+ private static string Own(AlwaysOnXeSessionKind kind) =>
+ AlwaysOnXeSessions.OwnNameFor(LongQueryCompletionsCollector.DarlingProduct, InstallIdValue, kind);
+
+ private static string Shared(AlwaysOnXeSessionKind kind) => AlwaysOnXeSessions.SharedNameFor(kind);
+
+ private sealed class AlreadyThere : Exception
+ {
+ public AlreadyThere() : base("already there") { }
+ }
+
+ ///
+ /// One Azure SQL Database as its primary and its read-only replica see it. A definition is made on the primary and
+ /// replicates at once; a session runs on one side or the other, and is started and stopped on its own. The replica
+ /// refuses a create or a drop with error 3906, and the primary refuses a drop while the session still runs on the replica
+ /// (the documented order: stop it there, then drop it on the primary).
+ ///
+ private sealed class Pair
+ {
+ public HashSet Definitions { get; } = new(StringComparer.Ordinal);
+ public HashSet WrongEvent { get; } = new(StringComparer.Ordinal);
+ public HashSet RunningOnPrimary { get; } = new(StringComparer.Ordinal);
+ public HashSet RunningOnReplica { get; } = new(StringComparer.Ordinal);
+
+ /* Every open, and every call over a connection: its intent, then what it was. */
+ public List Opens { get; } = new();
+ public List<(ApplicationIntent Intent, string What)> Log { get; } = new();
+
+ /* A database that is read-only itself, reached without the intent: the primary refuses creates too. */
+ public bool PrimaryIsReadOnly { get; set; }
+
+ /// The (intent, verb) of each statement that names one session, in the order sent.
+ public List<(ApplicationIntent Intent, string Verb)> Statements(string name) =>
+ Log.Where(e => e.What.StartsWith("DDL ", StringComparison.Ordinal) && e.What.Contains("[" + name + "]", StringComparison.Ordinal))
+ .Select(e => (e.Intent, Verb: e.What["DDL ".Length..e.What.IndexOf(' ', "DDL ".Length)]))
+ .ToList();
+
+ public List ReadsOf(string prefix) =>
+ Log.Where(e => e.What.StartsWith(prefix, StringComparison.Ordinal)).Select(e => e.Intent).ToList();
+ }
+
+ private sealed class Channel : IAlwaysOnXeDatabase
+ {
+ private readonly Pair _pair;
+ private readonly ApplicationIntent _intent;
+
+ public Channel(Pair pair, ApplicationIntent intent)
+ {
+ _pair = pair;
+ _intent = intent;
+ }
+
+ public Task ReadCatalogAsync(AlwaysOnXeSessionKind kind, string sessionName, CancellationToken cancellationToken)
+ {
+ _pair.Log.Add((_intent, "catalog " + sessionName));
+ return Task.FromResult(
+ !_pair.Definitions.Contains(sessionName) ? AlwaysOnXeCatalog.Missing
+ : _pair.WrongEvent.Contains(sessionName) && kind == AlwaysOnXeSessionKind.Deadlock ? AlwaysOnXeCatalog.WrongEvent
+ : AlwaysOnXeCatalog.Present);
+ }
+
+ public Task IsStartedAsync(string sessionName, CancellationToken cancellationToken)
+ {
+ _pair.Log.Add((_intent, "started " + sessionName));
+ return Task.FromResult(Running.Contains(sessionName));
+ }
+
+ private HashSet Running => _intent == ApplicationIntent.ReadOnly ? _pair.RunningOnReplica : _pair.RunningOnPrimary;
+
+ public Task ExecuteAsync(string statement, CancellationToken cancellationToken)
+ {
+ var text = statement.TrimStart();
+ var name = text[(text.IndexOf('[') + 1)..text.IndexOf(']')];
+ var creates = text.StartsWith("CREATE EVENT SESSION", StringComparison.Ordinal);
+ var starts = text.Contains("STATE = START", StringComparison.Ordinal);
+ var stops = text.Contains("STATE = STOP", StringComparison.Ordinal);
+ var drops = text.StartsWith("DROP EVENT SESSION", StringComparison.Ordinal);
+ var verb = creates && starts ? "CREATE+START" : creates ? "CREATE" : starts ? "START" : stops ? "STOP" : "DROP";
+ _pair.Log.Add((_intent, "DDL " + verb + " [" + name + "]"));
+
+ if ((creates || drops) && (_intent == ApplicationIntent.ReadOnly || _pair.PrimaryIsReadOnly))
+ {
+ throw ReadOnlyRefusal();
+ }
+
+ if (creates)
+ {
+ if (_pair.Definitions.Contains(name))
+ {
+ throw new AlreadyThere();
+ }
+
+ _pair.Definitions.Add(name);
+ if (starts)
+ {
+ Running.Add(name);
+ }
+ }
+ else if (drops)
+ {
+ if (_pair.RunningOnReplica.Contains(name))
+ {
+ throw new InvalidOperationException("The session still runs on a read-only replica: stop it there first.");
+ }
+
+ _pair.Definitions.Remove(name);
+ _pair.RunningOnPrimary.Remove(name);
+ }
+ else if (starts)
+ {
+ if (!_pair.Definitions.Contains(name))
+ {
+ throw new InvalidOperationException("The session does not exist.");
+ }
+
+ if (!Running.Add(name))
+ {
+ throw new AlreadyThere();
+ }
+ }
+ else if (stops && !Running.Remove(name))
+ {
+ throw new InvalidOperationException("The session is not running here.");
+ }
+
+ return Task.CompletedTask;
+ }
+
+ public bool IsAlreadyPresent(Exception exception) => exception is AlreadyThere;
+ }
+
+ private sealed class Rig
+ {
+ public required DarlingCollectorRunner Runner { get; init; }
+ public required ServerRuntime Runtime { get; init; }
+ public Pair Alpha { get; } = new();
+ public DarlingSelfAlertTests.CapturingLogger Logger { get; } = new();
+
+ /* The connection string of every open, in order. */
+ public List OpenedStrings { get; } = new();
+
+ public Task EnsureAsync() => DarlingXeSessions.EnsureAllAsync(Runtime, Runner, Logger, CancellationToken.None);
+
+ ///
+ /// The server's removal: its long-query trace was never on (nothing to drop there), and no other registration
+ /// reads a session of its own, so every own fallback the choices hold is dropped.
+ ///
+ public Task RemoveAsync() =>
+ DarlingRemovedServerSessions.DropAsync(
+ new RemovedLongQueryServer(Runtime.Config, Runtime, LongQueryTraceApplied: false),
+ Runner, remaining: null, traceOn: _ => false,
+ instanceGuard: _ => Task.FromResult(LongQueryTraceInstanceGuard.NoKeepers), Logger, CancellationToken.None);
+
+ ///
+ /// Every line at Warning or above that this routine wrote, whatever it says. Nothing narrows it by a word, so a Warning a
+ /// test does not expect, such as a failed stop or a failed drop, is among them.
+ ///
+ public List Loud() => Logger.Entries
+ .Where(e => e.Level >= LogLevel.Warning)
+ .Select(e => e.Message)
+ .ToList();
+
+ ///
+ /// The one line a create that a read-only database refused logs at Warning for a capture: the registration, the database
+ /// and the message that session's kind is worded with.
+ ///
+ public string ReadOnlyLine(string database, AlwaysOnXeSessionKind kind) =>
+ $"[{Runtime.Config.DisplayName}] [{database}] {AlwaysOnXeSessions.ReadOnlyDatabaseMessage(kind)}";
+ }
+
+ private Rig BuildRig(bool readOnlyIntent)
+ {
+ var config = new MonitoredServer { Name = "alwayson-intent", Host = Host, ReadOnlyIntent = readOnlyIntent };
+ var runtime = new ServerRuntime
+ {
+ Config = config,
+ ConnectionString = $"Server=tcp:{Host},1433;Initial Catalog=master;Encrypt=True;ApplicationIntent={(readOnlyIntent ? "ReadOnly" : "ReadWrite")}",
+ Target = new CollectorTargetInfo { IsAzureSqlDb = true },
+ StorageName = Host,
+ ServerId = 4961,
+ };
+ var runner = new DarlingCollectorRunner(
+ _store,
+ new CollectorDeltaCalculator(),
+ databaseScope: (_, _) => new List(),
+ separatelyMonitoredDatabases: _ => new List(),
+ installId: () => InstallIdValue);
+
+ var rig = new Rig { Runner = runner, Runtime = runtime };
+ runner.AzureDatabaseListOverrideForTests = (_, _) => Task.FromResult(new List { "master", "alpha" });
+ runner.AlwaysOnXeConnectionForTests = (_, database, connectionString, _) =>
+ {
+ /* The removal reads the database name back from the choice's key, which keeps it in upper case. */
+ Assert.Equal("alpha", database, ignoreCase: true);
+ Assert.Equal("alpha", new SqlConnectionStringBuilder(connectionString).InitialCatalog, ignoreCase: true);
+ var intent = new SqlConnectionStringBuilder(connectionString).ApplicationIntent;
+ rig.OpenedStrings.Add(connectionString);
+ rig.Alpha.Opens.Add(intent);
+ return Task.FromResult(new Channel(rig.Alpha, intent));
+ };
+
+ return rig;
+ }
+
+ private static readonly AlwaysOnXeSessionKind[] Kinds = { AlwaysOnXeSessionKind.Deadlock, AlwaysOnXeSessionKind.BlockedProcess };
+
+ /* ── A registration with read-only intent ── */
+
+ [Fact]
+ public async Task ReadOnlyIntent_ASharedSessionThatIsMissing_IsCreatedOverAConnectionWithoutTheIntent_AndStartedOverTheOwnOne()
+ {
+ var rig = BuildRig(readOnlyIntent: true);
+
+ await rig.EnsureAsync();
+
+ foreach (var kind in Kinds)
+ {
+ var shared = Shared(kind);
+ Assert.Equal(
+ new[] { (ApplicationIntent.ReadWrite, "CREATE"), (ApplicationIntent.ReadOnly, "START") },
+ rig.Alpha.Statements(shared));
+ Assert.Contains(shared, rig.Alpha.RunningOnReplica);
+ Assert.DoesNotContain(shared, rig.Alpha.RunningOnPrimary);
+ }
+
+ /* The probes ran over the registration's own connection, and the read-write one was opened once, for the creates. */
+ Assert.All(rig.Alpha.ReadsOf("catalog "), intent => Assert.Equal(ApplicationIntent.ReadOnly, intent));
+ Assert.All(rig.Alpha.ReadsOf("started "), intent => Assert.Equal(ApplicationIntent.ReadOnly, intent));
+ Assert.Equal(new[] { ApplicationIntent.ReadOnly, ApplicationIntent.ReadWrite }, rig.Alpha.Opens);
+ Assert.Empty(rig.Loud());
+ }
+
+ [Fact]
+ public async Task ReadOnlyIntent_AnOwnFallback_IsCreatedOverAConnectionWithoutTheIntent_AndStartedOverTheOwnOne()
+ {
+ var rig = BuildRig(readOnlyIntent: true);
+ var shared = Shared(AlwaysOnXeSessionKind.Deadlock);
+ rig.Alpha.Definitions.Add(shared);
+ rig.Alpha.WrongEvent.Add(shared);
+ rig.Alpha.RunningOnReplica.Add(shared);
+
+ await rig.EnsureAsync();
+
+ var own = Own(AlwaysOnXeSessionKind.Deadlock);
+ Assert.Equal(
+ new[] { (ApplicationIntent.ReadWrite, "CREATE"), (ApplicationIntent.ReadOnly, "START") },
+ rig.Alpha.Statements(own));
+ Assert.Contains(own, rig.Alpha.RunningOnReplica);
+ Assert.Equal(own, rig.Runner.AlwaysOnReadSessionName(rig.Runtime, "alpha", AlwaysOnXeSessionKind.Deadlock));
+ Assert.Empty(rig.Alpha.Statements(shared));
+ }
+
+ [Fact]
+ public async Task ReadOnlyIntent_SwitchingBack_StopsTheOwnSessionOverTheOwnConnection_ThenDropsItOverAConnectionWithoutTheIntent()
+ {
+ var rig = BuildRig(readOnlyIntent: true);
+ var shared = Shared(AlwaysOnXeSessionKind.Deadlock);
+ var own = Own(AlwaysOnXeSessionKind.Deadlock);
+ rig.Alpha.Definitions.Add(shared);
+ rig.Alpha.WrongEvent.Add(shared);
+ rig.Alpha.RunningOnReplica.Add(shared);
+ await rig.EnsureAsync();
+ Assert.Contains(own, rig.Alpha.RunningOnReplica);
+ rig.Alpha.Log.Clear();
+
+ rig.Alpha.WrongEvent.Clear();
+ await rig.EnsureAsync();
+
+ Assert.Equal(
+ new[] { (ApplicationIntent.ReadOnly, "STOP"), (ApplicationIntent.ReadWrite, "DROP") },
+ rig.Alpha.Statements(own));
+ Assert.DoesNotContain(own, rig.Alpha.Definitions);
+ Assert.Equal(shared, rig.Runner.AlwaysOnReadSessionName(rig.Runtime, "alpha", AlwaysOnXeSessionKind.Deadlock));
+ Assert.DoesNotContain(rig.Alpha.Statements(shared), s => s.Verb is "CREATE" or "CREATE+START" or "DROP");
+ }
+
+ [Fact]
+ public async Task ReadOnlyIntent_ARemovedServer_StopsItsOwnSessionOverTheOwnConnection_ThenDropsItOverAConnectionWithoutTheIntent()
+ {
+ var rig = BuildRig(readOnlyIntent: true);
+ var own = Own(AlwaysOnXeSessionKind.Deadlock);
+ rig.Alpha.Definitions.Add(own);
+ rig.Alpha.RunningOnReplica.Add(own);
+ rig.Runner.AlwaysOnChoices.Set(DarlingAlwaysOnXeSessions.ServerKey(rig.Runtime), "alpha", AlwaysOnXeSessionKind.Deadlock, AlwaysOnXeChoice.Own);
+
+ await rig.RemoveAsync();
+
+ Assert.Equal(
+ new[] { (ApplicationIntent.ReadOnly, "STOP"), (ApplicationIntent.ReadWrite, "DROP") },
+ rig.Alpha.Statements(own));
+ Assert.DoesNotContain(own, rig.Alpha.Definitions);
+ Assert.Empty(rig.Loud());
+ }
+
+ [Fact]
+ public async Task ReadOnlyIntent_ARemovedServer_WhoseOwnSessionDoesNotRunOnTheReplica_DropsItWithoutAStop()
+ {
+ var rig = BuildRig(readOnlyIntent: true);
+ var own = Own(AlwaysOnXeSessionKind.Deadlock);
+ rig.Alpha.Definitions.Add(own);
+ rig.Runner.AlwaysOnChoices.Set(DarlingAlwaysOnXeSessions.ServerKey(rig.Runtime), "alpha", AlwaysOnXeSessionKind.Deadlock, AlwaysOnXeChoice.Own);
+
+ await rig.RemoveAsync();
+
+ Assert.Equal(new[] { (ApplicationIntent.ReadWrite, "DROP") }, rig.Alpha.Statements(own));
+ Assert.DoesNotContain(own, rig.Alpha.Definitions);
+ Assert.Empty(rig.Loud());
+ }
+
+ [Fact]
+ public async Task ReadOnlyIntent_AHealthyDatabase_OpensNoConnectionWithoutTheIntent()
+ {
+ var rig = BuildRig(readOnlyIntent: true);
+ foreach (var kind in Kinds)
+ {
+ rig.Alpha.Definitions.Add(Shared(kind));
+ rig.Alpha.RunningOnReplica.Add(Shared(kind));
+ }
+
+ await rig.EnsureAsync();
+
+ Assert.Equal(new[] { ApplicationIntent.ReadOnly }, rig.Alpha.Opens);
+ Assert.DoesNotContain(rig.Alpha.Log, e => e.What.StartsWith("DDL ", StringComparison.Ordinal));
+ }
+
+ /* ── A registration without it, and on-premises, are unchanged ── */
+
+ [Fact]
+ public async Task NoIntent_EverythingGoesOverTheOwnConnection_AsItAlwaysHas()
+ {
+ var rig = BuildRig(readOnlyIntent: false);
+ var shared = Shared(AlwaysOnXeSessionKind.Deadlock);
+ var own = Own(AlwaysOnXeSessionKind.Deadlock);
+
+ /* The shared create is one batch, with its start. */
+ await rig.EnsureAsync();
+ Assert.Equal(new[] { (ApplicationIntent.ReadWrite, "CREATE+START") }, rig.Alpha.Statements(shared));
+
+ /* A fallback is the same, and its drop on the switch back is the one statement, with no stop first. */
+ rig.Alpha.WrongEvent.Add(shared);
+ rig.Alpha.Log.Clear();
+ await rig.EnsureAsync();
+ Assert.Equal(new[] { (ApplicationIntent.ReadWrite, "CREATE+START") }, rig.Alpha.Statements(own));
+ rig.Alpha.WrongEvent.Clear();
+ rig.Alpha.Log.Clear();
+ await rig.EnsureAsync();
+ Assert.Equal(new[] { (ApplicationIntent.ReadWrite, "DROP") }, rig.Alpha.Statements(own));
+
+ Assert.All(rig.Alpha.Opens, intent => Assert.Equal(ApplicationIntent.ReadWrite, intent));
+ Assert.Equal(3, rig.Alpha.Opens.Count);
+ }
+
+ /* ── A read-only database reached without the intent (an Azure geo-secondary) ── */
+
+ [Fact]
+ public async Task AReadOnlyDatabaseWithoutTheIntent_LogsTheReadOnlyExplanationOnce_NotTheCapsSentence_AndTheRetriesStayQuiet()
+ {
+ var rig = BuildRig(readOnlyIntent: false);
+ rig.Alpha.PrimaryIsReadOnly = true;
+
+ await rig.EnsureAsync();
+
+ /* One line a capture at Warning or above, whatever it says, and each is the read-only message: why, and what to change. */
+ var expected = Kinds.Select(kind => rig.ReadOnlyLine("alpha", kind)).OrderBy(line => line, StringComparer.Ordinal).ToList();
+ Assert.Equal(expected, rig.Loud().OrderBy(line => line, StringComparer.Ordinal));
+ Assert.All(rig.Loud(), line =>
+ {
+ Assert.Contains("read-only", line, StringComparison.Ordinal);
+ Assert.Contains("3906", line, StringComparison.Ordinal);
+ Assert.Contains("Register the primary database instead", line, StringComparison.Ordinal);
+ Assert.DoesNotContain(AlwaysOnXeSessions.AzureCapsSentence, line, StringComparison.Ordinal);
+ });
+
+ /* The pass an hour later asks again, and says nothing new at Warning. */
+ await rig.EnsureAsync();
+ await rig.EnsureAsync();
+ Assert.Equal(expected, rig.Loud().OrderBy(line => line, StringComparer.Ordinal));
+ }
+
+ private static SqlException ReadOnlyRefusal() =>
+ LongQueryTraceReadOnlyIntentTests.SqlExceptionFactory.Create(
+ LongQueryTraceDatabases.ReadOnlyDatabaseErrorNumber, 16, "Failed to update database because the database is read-only.");
+}
diff --git a/Darling/Darling.Tests/AlwaysOnXeSessionsTests.cs b/Darling/Darling.Tests/AlwaysOnXeSessionsTests.cs
new file mode 100644
index 0000000000..f1dcd96f7c
--- /dev/null
+++ b/Darling/Darling.Tests/AlwaysOnXeSessionsTests.cs
@@ -0,0 +1,337 @@
+/*
+ * 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.Threading;
+using System.Threading.Tasks;
+using Microsoft.Extensions.Logging.Abstractions;
+using Npgsql;
+using PerformanceMonitor.Collectors;
+using PerformanceMonitor.Darling.Service;
+using Xunit;
+using static Darling.Tests.RepoFile;
+
+namespace Darling.Tests;
+
+///
+/// The deadlock and blocked-process sessions on Azure SQL Database (#4961), as the service ensures them: a stopped shared
+/// session gets a start by name, one that stays unusable sends the install to its own session, the install switches back
+/// once the shared one reads back usable, no path drops a shared-name session, and the read takes the name the ensure chose.
+/// The service ensures at connect and then once an hour, so a session dropped from outside comes back within the hour. Each
+/// test drives the real ensure against a scripted database, so no server or store is needed.
+///
+public sealed class AlwaysOnXeSessionsTests : IAsyncDisposable
+{
+ private const string Host = "alwayson.database.windows.net";
+ private const string InstallIdValue = "0a1b2c3d";
+
+ /* Never opened: the runner reaches the store only to write collected rows, and these tests collect nothing. */
+ private readonly NpgsqlDataSource _store = NpgsqlDataSource.Create("Host=127.0.0.1;Port=1;Database=unused;Username=unused");
+
+ public ValueTask DisposeAsync() => _store.DisposeAsync();
+
+ private static string Own(AlwaysOnXeSessionKind kind) =>
+ AlwaysOnXeSessions.OwnNameFor(LongQueryCompletionsCollector.DarlingProduct, InstallIdValue, kind);
+
+ [Theory]
+ [InlineData(AlwaysOnXeSessionKind.Deadlock)]
+ [InlineData(AlwaysOnXeSessionKind.BlockedProcess)]
+ public void TheSharedSessionStartsWithTheDatabase_AndThisInstallsOwnFallbackSessionDoesNot(AlwaysOnXeSessionKind kind)
+ {
+ var shared = AlwaysOnXeSessions.BuildAzureCreateSql(kind, AlwaysOnXeSessions.SharedNameFor(kind));
+ var own = AlwaysOnXeSessions.BuildAzureCreateSql(kind, Own(kind));
+
+ Assert.Contains("STARTUP_STATE = ON", shared, StringComparison.Ordinal);
+ Assert.DoesNotContain("STARTUP_STATE = OFF", shared, StringComparison.Ordinal);
+ Assert.Contains("STARTUP_STATE = OFF", own, StringComparison.Ordinal);
+ Assert.DoesNotContain("STARTUP_STATE = ON", own, StringComparison.Ordinal);
+ }
+
+ private sealed class AlreadyThere : Exception
+ {
+ public AlreadyThere() : base("already there") { }
+ }
+
+ /// One Azure SQL Database as the ensure sees it: a catalog, a started-sessions DMV, and the statements sent.
+ private sealed class ScriptedDatabase : IAlwaysOnXeDatabase
+ {
+ public Dictionary Started { get; } = new(StringComparer.Ordinal);
+ public HashSet WrongEvent { get; } = new(StringComparer.Ordinal);
+ public List Statements { get; } = new();
+ public Exception? CreateFailure { get; set; }
+
+ public Task ReadCatalogAsync(AlwaysOnXeSessionKind kind, string sessionName, CancellationToken cancellationToken) =>
+ Task.FromResult(
+ !Started.ContainsKey(sessionName) ? AlwaysOnXeCatalog.Missing
+ : WrongEvent.Contains(sessionName) && kind == AlwaysOnXeSessionKind.Deadlock ? AlwaysOnXeCatalog.WrongEvent
+ : AlwaysOnXeCatalog.Present);
+
+ public Task IsStartedAsync(string sessionName, CancellationToken cancellationToken) =>
+ Task.FromResult(Started.TryGetValue(sessionName, out var started) && started);
+
+ public Task ExecuteAsync(string statement, CancellationToken cancellationToken)
+ {
+ Statements.Add(statement);
+ var name = statement[(statement.IndexOf('[') + 1)..statement.IndexOf(']')];
+
+ if (statement.TrimStart().StartsWith("CREATE EVENT SESSION", StringComparison.Ordinal))
+ {
+ if (CreateFailure is not null)
+ {
+ throw CreateFailure;
+ }
+
+ if (Started.ContainsKey(name))
+ {
+ throw new AlreadyThere();
+ }
+
+ Started[name] = true;
+ }
+ else if (statement.StartsWith("ALTER EVENT SESSION", StringComparison.Ordinal))
+ {
+ if (Started.TryGetValue(name, out var already) && already)
+ {
+ throw new AlreadyThere();
+ }
+
+ Started[name] = true;
+ }
+ else if (statement.StartsWith("DROP EVENT SESSION", StringComparison.Ordinal))
+ {
+ Started.Remove(name);
+ }
+
+ return Task.CompletedTask;
+ }
+
+ public bool IsAlreadyPresent(Exception exception) => exception is AlreadyThere;
+
+ public int Created(string name) =>
+ Statements.Count(s => s.TrimStart().StartsWith("CREATE EVENT SESSION [" + name + "]", StringComparison.Ordinal));
+
+ public bool Sent(string prefix, string name) =>
+ Statements.Any(s => s.TrimStart().StartsWith(prefix + " [" + name + "]", StringComparison.Ordinal));
+ }
+
+ private sealed class Rig
+ {
+ public required DarlingCollectorRunner Runner { get; init; }
+ public required ServerRuntime Runtime { get; init; }
+ public required DarlingWorker.ServerLoopState State { get; init; }
+ public ScriptedDatabase Alpha { get; } = new();
+ public ScriptedDatabase Beta { get; } = new();
+ public int ServerScopedEnsures { get; set; }
+ }
+
+ private Rig BuildRig(bool azureSqlDatabase = true)
+ {
+ var config = new MonitoredServer { Name = "alwayson", Host = Host };
+ var runtime = new ServerRuntime
+ {
+ Config = config,
+ ConnectionString = $"Server=tcp:{Host},1433;Initial Catalog=master;Encrypt=True",
+ Target = new CollectorTargetInfo { IsAzureSqlDb = azureSqlDatabase },
+ StorageName = Host,
+ ServerId = 4242,
+ };
+ var runner = new DarlingCollectorRunner(
+ _store,
+ new CollectorDeltaCalculator(),
+ databaseScope: (_, _) => new List(),
+ separatelyMonitoredDatabases: _ => new List(),
+ installId: () => InstallIdValue);
+
+ var rig = new Rig
+ {
+ Runner = runner,
+ Runtime = runtime,
+ State = new DarlingWorker.ServerLoopState { Config = config, Runtime = runtime },
+ };
+
+ runner.AzureDatabaseListOverrideForTests = (_, _) => Task.FromResult(new List { "master", "alpha", "beta" });
+ runner.AlwaysOnXeDatabaseForTests = (_, database, _) =>
+ Task.FromResult(string.Equals(database, "alpha", StringComparison.OrdinalIgnoreCase) ? rig.Alpha : rig.Beta);
+ runner.XeEnsureOverrideForTests = (_, _) =>
+ {
+ rig.ServerScopedEnsures++;
+ return Task.CompletedTask;
+ };
+
+ return rig;
+ }
+
+ private static Task EnsureAsync(Rig rig) =>
+ DarlingXeSessions.EnsureAllAsync(rig.Runtime, rig.Runner, NullLogger.Instance, CancellationToken.None);
+
+ /* Test 16: a stopped shared session gets a START by name and no fallback. */
+ [Fact]
+ public async Task AStoppedSharedSession_GetsAStartByName_AndNoFallback()
+ {
+ var rig = BuildRig();
+ rig.Runner.XeEnsureOverrideForTests = null;
+ var deadlock = AlwaysOnXeSessions.SharedNameFor(AlwaysOnXeSessionKind.Deadlock);
+ var blocked = AlwaysOnXeSessions.SharedNameFor(AlwaysOnXeSessionKind.BlockedProcess);
+ rig.Alpha.Started[deadlock] = false;
+ rig.Alpha.Started[blocked] = false;
+
+ await EnsureAsync(rig);
+
+ Assert.True(rig.Alpha.Started[deadlock]);
+ Assert.True(rig.Alpha.Started[blocked]);
+ Assert.Equal(0, rig.Alpha.Created(Own(AlwaysOnXeSessionKind.Deadlock)));
+ Assert.Equal(deadlock, rig.Runner.AlwaysOnReadSessionName(rig.Runtime, "alpha", AlwaysOnXeSessionKind.Deadlock));
+ Assert.DoesNotContain(rig.Alpha.Statements, s => s.StartsWith("DROP", StringComparison.Ordinal));
+ }
+
+ /* Tests 15 and 17: a shared deadlock session without the deadlock event is never dropped, the install reads its own. */
+ [Fact]
+ public async Task ASharedDeadlockSessionWithoutTheDeadlockEvent_IsNeverDropped_AndTheReadTakesTheOwnName()
+ {
+ var rig = BuildRig();
+ rig.Runner.XeEnsureOverrideForTests = null;
+ var shared = AlwaysOnXeSessions.SharedNameFor(AlwaysOnXeSessionKind.Deadlock);
+ rig.Alpha.Started[shared] = true;
+ rig.Alpha.WrongEvent.Add(shared);
+
+ await EnsureAsync(rig);
+
+ Assert.False(rig.Alpha.Sent("DROP EVENT SESSION", shared));
+ Assert.Equal(Own(AlwaysOnXeSessionKind.Deadlock), rig.Runner.AlwaysOnReadSessionName(rig.Runtime, "alpha", AlwaysOnXeSessionKind.Deadlock));
+ Assert.Equal(shared, rig.Runner.AlwaysOnReadSessionName(rig.Runtime, "beta", AlwaysOnXeSessionKind.Deadlock));
+ Assert.Contains("STARTUP_STATE = OFF", rig.Alpha.Statements.Single(s => s.Contains("CREATE EVENT SESSION [" + Own(AlwaysOnXeSessionKind.Deadlock) + "]", StringComparison.Ordinal)), StringComparison.Ordinal);
+ }
+
+ /* Test 18: a shared session that reads back usable makes the install drop its fallback and switch back. */
+ [Fact]
+ public async Task TheInstallOnItsOwnSession_DropsItAndSwitchesBack_WhenTheSharedSessionReadsBackUsable()
+ {
+ var rig = BuildRig();
+ rig.Runner.XeEnsureOverrideForTests = null;
+ var shared = AlwaysOnXeSessions.SharedNameFor(AlwaysOnXeSessionKind.Deadlock);
+ rig.Alpha.Started[shared] = true;
+ rig.Alpha.WrongEvent.Add(shared);
+ await EnsureAsync(rig);
+ Assert.True(rig.Alpha.Started.ContainsKey(Own(AlwaysOnXeSessionKind.Deadlock)));
+
+ rig.Alpha.WrongEvent.Clear();
+ await EnsureAsync(rig);
+
+ Assert.False(rig.Alpha.Started.ContainsKey(Own(AlwaysOnXeSessionKind.Deadlock)));
+ Assert.True(rig.Alpha.Sent("DROP EVENT SESSION", Own(AlwaysOnXeSessionKind.Deadlock)));
+ Assert.False(rig.Alpha.Sent("DROP EVENT SESSION", shared));
+ Assert.Equal(shared, rig.Runner.AlwaysOnReadSessionName(rig.Runtime, "alpha", AlwaysOnXeSessionKind.Deadlock));
+ }
+
+ /* Test 19, Azure: sessions dropped from outside come back within the hour, and not before it. */
+ [Fact]
+ public async Task AzureSessionsDroppedFromOutside_ComeBackWithinTheHour()
+ {
+ var rig = BuildRig();
+ rig.Runner.XeEnsureOverrideForTests = null;
+ var start = new DateTime(2026, 1, 1, 12, 0, 0, DateTimeKind.Utc);
+ var deadlock = AlwaysOnXeSessions.SharedNameFor(AlwaysOnXeSessionKind.Deadlock);
+ var blocked = AlwaysOnXeSessions.SharedNameFor(AlwaysOnXeSessionKind.BlockedProcess);
+
+ await DarlingWorker.EnsureAlwaysOnXeSessionsAsync(rig.State, rig.Runner, start, NullLogger.Instance, CancellationToken.None);
+ Assert.True(rig.Alpha.Started[deadlock]);
+ Assert.True(rig.Beta.Started[blocked]);
+
+ rig.Alpha.Started.Clear();
+ rig.Beta.Started.Clear();
+ await DarlingWorker.EnsureAlwaysOnXeSessionsAsync(rig.State, rig.Runner, start.AddMinutes(30), NullLogger.Instance, CancellationToken.None);
+ Assert.Empty(rig.Alpha.Started);
+
+ await DarlingWorker.EnsureAlwaysOnXeSessionsAsync(rig.State, rig.Runner, start.AddMinutes(61), NullLogger.Instance, CancellationToken.None);
+ Assert.True(rig.Alpha.Started[deadlock]);
+ Assert.True(rig.Alpha.Started[blocked]);
+ Assert.True(rig.Beta.Started[deadlock]);
+ Assert.True(rig.Beta.Started[blocked]);
+ }
+
+ /* Test 19, server scope: the ensure runs once an hour as well as at connect. */
+ [Fact]
+ public async Task TheServerScopedEnsure_RunsAgainOnceTheHourIsUp()
+ {
+ var rig = BuildRig(azureSqlDatabase: false);
+ var start = new DateTime(2026, 1, 1, 12, 0, 0, DateTimeKind.Utc);
+
+ await DarlingWorker.EnsureAlwaysOnXeSessionsAsync(rig.State, rig.Runner, start, NullLogger.Instance, CancellationToken.None);
+ await DarlingWorker.EnsureAlwaysOnXeSessionsAsync(rig.State, rig.Runner, start.AddMinutes(59), NullLogger.Instance, CancellationToken.None);
+ Assert.Equal(1, rig.ServerScopedEnsures);
+
+ await DarlingWorker.EnsureAlwaysOnXeSessionsAsync(rig.State, rig.Runner, start.AddMinutes(60), NullLogger.Instance, CancellationToken.None);
+ Assert.Equal(2, rig.ServerScopedEnsures);
+ Assert.Equal(start.AddMinutes(60), rig.State.XeSessionsEnsuredAtUtc);
+ }
+
+ /* Test 27: an Azure create that fails carries the caps sentence in the line the service logs. */
+ [Fact]
+ public async Task AFailedAzureCreate_LogsTheCapsSentence()
+ {
+ var rig = BuildRig();
+ rig.Runner.XeEnsureOverrideForTests = null;
+ rig.Alpha.CreateFailure = new InvalidOperationException("The create was refused.");
+ var logger = new CapturingLogger();
+
+ await DarlingXeSessions.EnsureAllAsync(rig.Runtime, rig.Runner, logger, CancellationToken.None);
+
+ var line = Assert.Single(logger.Lines, l => l.Contains("alpha", StringComparison.Ordinal) && l.Contains("deadlock", StringComparison.Ordinal));
+ Assert.Contains("The create was refused.", line, StringComparison.Ordinal);
+ Assert.Contains(AlwaysOnXeSessions.AzureCapsSentence, line, StringComparison.Ordinal);
+ }
+
+ /* Test 15, pinned at the source: the Azure ensure in this service holds no drop of a shared name. */
+ [Fact]
+ public void TheAzureEnsure_HoldsNoDropOfASharedName()
+ {
+ var source = ReadRepoFileLf("Darling", "PerformanceMonitor.Darling.Service", "DarlingXeSessions.cs");
+
+ Assert.DoesNotContain("DROP EVENT SESSION [{DeadlocksCollector.XeSessionName}]", source, StringComparison.Ordinal);
+ Assert.DoesNotContain("DROP EVENT SESSION [{sessionName}] ON DATABASE;\", connection))\n {\n dropCmd.CommandTimeout = 60;", source, StringComparison.Ordinal);
+ }
+
+ /* Test 17: the read builders name the session the ensure chose. */
+ [Fact]
+ public void TheReadBuildersNameTheSessionTheEnsureChose()
+ {
+ var own = Own(AlwaysOnXeSessionKind.Deadlock);
+ var ownBlocked = Own(AlwaysOnXeSessionKind.BlockedProcess);
+
+ Assert.Contains($"N'{own}'", DeadlocksCollector.Instance.BuildQuery(AzureContext(own)).Text, StringComparison.Ordinal);
+ Assert.DoesNotContain("N'PerformanceMonitor_Deadlock'", DeadlocksCollector.Instance.BuildQuery(AzureContext(own)).Text, StringComparison.Ordinal);
+ Assert.Contains("N'PerformanceMonitor_Deadlock'", DeadlocksCollector.Instance.BuildQuery(AzureContext(null)).Text, StringComparison.Ordinal);
+ Assert.Contains($"N'{ownBlocked}'", BlockedProcessReportCollector.Instance.BuildQuery(AzureContext(ownBlocked)).Text, StringComparison.Ordinal);
+ Assert.DoesNotContain("N'PerformanceMonitor_BlockedProcess'", BlockedProcessReportCollector.Instance.BuildQuery(AzureContext(ownBlocked)).Text, StringComparison.Ordinal);
+ }
+
+ private static CollectorContext AzureContext(string? sessionName) => new()
+ {
+ ServerId = 1,
+ ServerName = "alwayson",
+ CollectionTime = new DateTime(2026, 1, 1, 12, 0, 0, DateTimeKind.Utc),
+ Deltas = new CollectorDeltaCalculator(),
+ Target = new CollectorTargetInfo { IsAzureSqlDb = true },
+ CurrentDatabaseName = "alpha",
+ AlwaysOnSessionName = sessionName,
+ };
+
+ private sealed class CapturingLogger : Microsoft.Extensions.Logging.ILogger
+ {
+ public List Lines { get; } = new();
+
+ public IDisposable? BeginScope(TState state) where TState : notnull => null;
+
+ public bool IsEnabled(Microsoft.Extensions.Logging.LogLevel logLevel) => true;
+
+ public void Log(Microsoft.Extensions.Logging.LogLevel logLevel, Microsoft.Extensions.Logging.EventId eventId, TState state, Exception? exception, Func formatter) =>
+ Lines.Add(formatter(state, exception));
+ }
+}
diff --git a/Darling/Darling.Tests/AzureForeignStatePruneTests.cs b/Darling/Darling.Tests/AzureForeignStatePruneTests.cs
index 5c0c33b460..8cc2cc760c 100644
--- a/Darling/Darling.Tests/AzureForeignStatePruneTests.cs
+++ b/Darling/Darling.Tests/AzureForeignStatePruneTests.cs
@@ -138,7 +138,10 @@ their orphaned rows wholesale — a dropped-database prune has nothing left to o
k => k.Prefix == QueryStoreOpenIntervalState.WatermarkKeyPrefix);
/* Nothing may sit outside both lists — a server-scoped key added to PrunableKeys by reflex would be
- deleted every cycle. */
- Assert.Empty(QueryStorePerDatabaseState.NotKeyedByDatabase);
+ deleted every cycle. The one member is the drop record of the long-query session older versions shared
+ between installs (#4961): its server-scope key is the bare prefix, which a prune that rebuilds keys as
+ prefix + database name would delete every cycle. A new member is a decision, so the list is pinned
+ whole rather than checked for containment. */
+ Assert.Equal(new[] { LegacyLongQuerySession.StateKeyPrefix }, QueryStorePerDatabaseState.NotKeyedByDatabase);
}
}
diff --git a/Darling/Darling.Tests/CaggGroupIndexDropLiveTests.cs b/Darling/Darling.Tests/CaggGroupIndexDropLiveTests.cs
index edf5582513..47ed681ba5 100644
--- a/Darling/Darling.Tests/CaggGroupIndexDropLiveTests.cs
+++ b/Darling/Darling.Tests/CaggGroupIndexDropLiveTests.cs
@@ -343,6 +343,14 @@ private static async Task DropArtifactsOfLaterRungsAsync(NpgsqlConnection connec
await dropEnd.ExecuteNonQueryAsync(ct);
}
+ if (simulatedVersion < InstallIdRungTests.RungVersion)
+ {
+ /* V158 (#4961) - the install id's one-row table; the table is the probe's sentinel. */
+ await using var dropInstallId = new NpgsqlCommand(
+ "DROP TABLE IF EXISTS config.config_install_id", connection);
+ await dropInstallId.ExecuteNonQueryAsync(ct);
+ }
+
if (simulatedVersion < MuteRuleServerIdRungTests.RungVersion)
{
/* V157 - the mute rule's store server id; the column is the probe's sentinel. */
diff --git a/Darling/Darling.Tests/DarlingCliCommandsTests.cs b/Darling/Darling.Tests/DarlingCliCommandsTests.cs
index 8aca1d2eda..f7c5f27b63 100644
--- a/Darling/Darling.Tests/DarlingCliCommandsTests.cs
+++ b/Darling/Darling.Tests/DarlingCliCommandsTests.cs
@@ -2121,7 +2121,11 @@ public void Readme_DocumentsBothVerbs_TheDefaultScope_AndTheXeSessionConsequence
Assert.Contains("--disable-collector long_query_completions", readme, StringComparison.Ordinal);
Assert.Contains("--enable-collector long_query_completions --server", readme, StringComparison.Ordinal);
Assert.Contains("fleet-wide**", readme, StringComparison.Ordinal);
- Assert.Contains("creates the `PerformanceMonitor_LongQueryCompletions` Extended Events session", readme, StringComparison.Ordinal);
+ /* The session names are per install since #4961, so the README says the verb creates THIS install's own session, under
+ the name the install id makes, and not the shared name older versions used. */
+ Assert.Contains("creates this install's own long-query Extended Events session", readme, StringComparison.Ordinal);
+ Assert.Contains("`PerformanceMonitor_Darling__LongQueryCompletions`", readme, StringComparison.Ordinal);
+ Assert.DoesNotContain("creates the `PerformanceMonitor_LongQueryCompletions` Extended Events session", readme, StringComparison.Ordinal);
Assert.Contains("disabling it drops that session", readme, StringComparison.Ordinal);
Assert.Contains("no MCP tool", readme, StringComparison.Ordinal);
}
diff --git a/Darling/Darling.Tests/DarlingCliUnusableStoreConnectionTests.cs b/Darling/Darling.Tests/DarlingCliUnusableStoreConnectionTests.cs
index cee62f91df..645a82390a 100644
--- a/Darling/Darling.Tests/DarlingCliUnusableStoreConnectionTests.cs
+++ b/Darling/Darling.Tests/DarlingCliUnusableStoreConnectionTests.cs
@@ -238,10 +238,12 @@ quotes the sentence from counting. */
/* Eight verbs open the store through it: --validate-config, --check-settings, the four endpoint toggles (one
shared body), --collapse-legacy-slices, --recompress-plan-dim, --add-server, --backfill-rollups, and the
- two collector toggles (one shared body). That is eight call sites; a ninth verb that opens the store
- adds a ninth here, and growing this count is the point. */
+ two collector toggles (one shared body). That is eight call sites. The ninth is --drop-xe-sessions reading
+ this install's id, the long-query schedule rows and the instance names (#4961), after it has resolved the
+ server through the first one's shared body. A tenth verb that opens the store adds a tenth here, and growing
+ this count is the point. */
var calls = Regex.Matches(code, @"(? Assert.True(ch < 128, $"usage text must be ASCII; found U+{(int)ch:X4}"));
}
}
diff --git a/Darling/Darling.Tests/DropXeSessionsPerInstallTests.cs b/Darling/Darling.Tests/DropXeSessionsPerInstallTests.cs
new file mode 100644
index 0000000000..c153bf59df
--- /dev/null
+++ b/Darling/Darling.Tests/DropXeSessionsPerInstallTests.cs
@@ -0,0 +1,411 @@
+/*
+ * 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.IO;
+using System.Linq;
+using System.Net;
+using System.Net.Sockets;
+using System.Text.Json;
+using System.Text.RegularExpressions;
+using System.Threading;
+using System.Threading.Tasks;
+using PerformanceMonitor.Collectors;
+using PerformanceMonitor.Darling.Service;
+using Xunit;
+
+namespace Darling.Tests;
+
+///
+/// #4961: each install makes its own long-query session, and on Azure SQL Database its own deadlock and blocked-process
+/// fallbacks, all named from the install's id. --drop-xe-sessions drops this install's sessions with the shared ones,
+/// and lists the sessions of other installs with a guarded DROP for the operator to run, which it never runs itself.
+///
+public sealed class DropXeSessionsPerInstallTests
+{
+ private const string Id = "1a2b3c4d";
+ private const string OwnLongQuery = "PerformanceMonitor_Darling_1a2b3c4d_LongQueryCompletions";
+ private const string OwnDeadlock = "PerformanceMonitor_Darling_1a2b3c4d_Deadlock";
+ private const string OwnBlocked = "PerformanceMonitor_Darling_1a2b3c4d_BlockedProcess";
+ private const string OtherLiteLongQuery = "PerformanceMonitor_Lite_ffeeddcc_LongQueryCompletions";
+ private const string OtherDarlingDeadlock = "PerformanceMonitor_Darling_ffeeddcc_Deadlock";
+ private const string OtherDarlingBlocked = "PerformanceMonitor_Darling_ffeeddcc_BlockedProcess";
+ private const string SharedDeadlock = "PerformanceMonitor_Deadlock";
+ private const string SharedBlocked = "PerformanceMonitor_BlockedProcess";
+ private const string Legacy = "PerformanceMonitor_LongQueryCompletions";
+
+ // ---- the names ------------------------------------------------------------------------------------------------------
+
+ [Fact]
+ public void TheInstallsNames_AreItsLongQuerySessionAndTheTwoAzureFallbacks()
+ {
+ Assert.Equal(new[] { OwnLongQuery, OwnDeadlock, OwnBlocked }, DarlingXeSessionCleanup.InstallSessionNames(Id));
+ Assert.Equal(
+ new[] { SharedDeadlock, SharedBlocked, Legacy, OwnLongQuery, OwnDeadlock, OwnBlocked },
+ DarlingXeSessionCleanup.NamesToDrop(Id));
+ }
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ [InlineData("1A2B3C4D")]
+ [InlineData("1a2b3c4")]
+ [InlineData("1a2b3c4d ")]
+ public void WithNoUsableId_TheVerbHandlesNoInstallName(string? id)
+ {
+ Assert.Empty(DarlingXeSessionCleanup.InstallSessionNames(id));
+ Assert.Equal(DarlingXeSessionCleanup.SessionNames.ToArray(), DarlingXeSessionCleanup.NamesToDrop(id));
+ Assert.False(DarlingXeSessionCleanup.IsOtherInstallSessionName(OtherLiteLongQuery, id));
+ }
+
+ [Fact]
+ public void PlanDrops_KeepsThisInstallsNamesAndTheSharedOnes_AndIgnoresAnotherInstalls()
+ {
+ var plan = DarlingXeSessionCleanup.PlanDrops(
+ [
+ new ExistingXeSession(OtherLiteLongQuery, XeSessionScope.Server),
+ new ExistingXeSession(OwnLongQuery, XeSessionScope.Server),
+ new ExistingXeSession(Legacy, XeSessionScope.Server),
+ new ExistingXeSession(SharedDeadlock, XeSessionScope.Server),
+ new ExistingXeSession(OtherDarlingDeadlock, XeSessionScope.Database, "alpha"),
+ new ExistingXeSession(OwnDeadlock, XeSessionScope.Database, "alpha"),
+ ],
+ Id);
+
+ Assert.Equal(
+ new[]
+ {
+ "DROP EVENT SESSION [PerformanceMonitor_Deadlock] ON SERVER;",
+ "DROP EVENT SESSION [PerformanceMonitor_LongQueryCompletions] ON SERVER;",
+ "DROP EVENT SESSION [PerformanceMonitor_Darling_1a2b3c4d_LongQueryCompletions] ON SERVER;",
+ "DROP EVENT SESSION [PerformanceMonitor_Darling_1a2b3c4d_Deadlock] ON DATABASE;",
+ },
+ plan.Select(d => d.Statement).ToArray());
+ }
+
+ [Fact]
+ public void PlanDrops_WithNoId_KeepsOnlyTheSharedNames()
+ {
+ var plan = DarlingXeSessionCleanup.PlanDrops(
+ [
+ new ExistingXeSession(OwnLongQuery, XeSessionScope.Server),
+ new ExistingXeSession(SharedBlocked, XeSessionScope.Server),
+ ]);
+
+ Assert.Equal(new[] { "DROP EVENT SESSION [PerformanceMonitor_BlockedProcess] ON SERVER;" }, plan.Select(d => d.Statement).ToArray());
+ }
+
+ [Theory]
+ [InlineData(XeSessionScope.Server, "SERVER")]
+ [InlineData(XeSessionScope.Database, "DATABASE")]
+ public void DropStatement_AcceptsThisInstallsNames_AndRefusesEveryOtherInstalls(XeSessionScope scope, string on)
+ {
+ foreach (var own in new[] { OwnLongQuery, OwnDeadlock, OwnBlocked })
+ {
+ Assert.Equal($"DROP EVENT SESSION [{own}] ON {on};", DarlingXeSessionCleanup.DropStatement(own, scope, Id));
+
+ /* With no id the same name is nobody's to drop, and a different id's name is another install's. */
+ Assert.Throws(() => DarlingXeSessionCleanup.DropStatement(own, scope));
+ Assert.Throws(() => DarlingXeSessionCleanup.DropStatement(own, scope, "ffeeddcc"));
+ Assert.Throws(() => DarlingXeSessionCleanup.DropStatement(own.ToUpperInvariant(), scope, Id));
+ }
+
+ foreach (var other in new[] { OtherLiteLongQuery, OtherDarlingDeadlock, OtherDarlingBlocked, "system_health" })
+ {
+ Assert.Throws(() => DarlingXeSessionCleanup.DropStatement(other, scope, Id));
+ }
+ }
+
+ // ---- the listing of other installs' sessions ------------------------------------------------------------------------
+
+ [Theory]
+ [InlineData(OtherLiteLongQuery, true)]
+ [InlineData(OtherDarlingDeadlock, true)]
+ [InlineData(OtherDarlingBlocked, true)]
+ [InlineData(OwnLongQuery, false)]
+ [InlineData(OwnDeadlock, false)]
+ [InlineData(OwnBlocked, false)]
+ [InlineData(SharedDeadlock, false)]
+ [InlineData(SharedBlocked, false)]
+ [InlineData(Legacy, false)]
+ [InlineData("PerformanceMonitor_Darling_notanid_Deadlock", false)]
+ [InlineData("PerformanceMonitor_Other_ffeeddcc_Deadlock", false)]
+ [InlineData("PerformanceMonitor_Darling_ffeeddcc_Deadlock]; DROP DATABASE x; --", false)]
+ [InlineData("system_health", false)]
+ public void AnotherInstallsSession_IsRecognizedByItsExactShape_AndNeverThisInstallsOwn(string name, bool expected) =>
+ Assert.Equal(expected, DarlingXeSessionCleanup.IsOtherInstallSessionName(name, Id));
+
+ [Theory]
+ [InlineData(XeSessionScope.Server, "FROM sys.server_event_sessions AS ses", "ses")]
+ [InlineData(XeSessionScope.Database, "FROM sys.database_event_sessions AS des", "des")]
+ public void TheFindOfOtherInstalls_MatchesTheThreePerInstallShapes_WithTheUnderscoresEscaped(XeSessionScope scope, string from, string alias)
+ {
+ var sql = DarlingXeSessionCleanup.ComposeFindOthersSql(scope);
+
+ Assert.Contains(from, sql, StringComparison.Ordinal);
+ Assert.StartsWith(Environment.NewLine + "SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;", sql.ReplaceLineEndings(Environment.NewLine), StringComparison.Ordinal);
+ Assert.Contains("SELECT /* PerformanceMonitorDarling */", sql, StringComparison.Ordinal);
+ var patterns = Regex.Matches(sql, "N'([^']*)'").Select(m => m.Groups[1].Value).ToArray();
+ Assert.Equal(
+ new[] { "PerformanceMonitor[_]%[_]LongQueryCompletions", "PerformanceMonitor[_]%[_]Deadlock", "PerformanceMonitor[_]%[_]BlockedProcess" },
+ patterns);
+ foreach (var pattern in patterns)
+ {
+ Assert.Contains($"{alias}.name LIKE N'{pattern}'", sql, StringComparison.Ordinal);
+ }
+
+ /* The shapes need a middle segment, so neither the shared names nor the old shared long-query name can match. */
+ var regexes = patterns.Select(p => new Regex("^" + Regex.Escape(p.Replace("[_]", "_", StringComparison.Ordinal)).Replace("%", ".*", StringComparison.Ordinal) + "$")).ToArray();
+ bool Matches(string name) => regexes.Any(r => r.IsMatch(name));
+ Assert.All(new[] { OtherLiteLongQuery, OtherDarlingDeadlock, OtherDarlingBlocked, OwnLongQuery }, name => Assert.True(Matches(name), name));
+ Assert.All(new[] { SharedDeadlock, SharedBlocked, Legacy }, name => Assert.False(Matches(name), name));
+ }
+
+ // ---- the verb: drops this install's sessions and lists the others' ---------------------------------------------------
+
+ private sealed class FakeTarget : IXeSessionCleanupTarget
+ {
+ public List Found { get; } = [];
+
+ public List Others { get; } = [];
+
+ public List Dropped { get; } = [];
+
+ public Task FindSessionsAsync(CancellationToken cancellationToken) =>
+ Task.FromResult(new XeSessionSearch(Found.ToList(), []) { Others = Others.ToList() });
+
+ public Task DropAsync(XeSessionDrop drop, CancellationToken cancellationToken)
+ {
+ Dropped.Add(drop);
+ return Task.CompletedTask;
+ }
+ }
+
+ private static async Task<(int Exit, string Output, string Error)> RunAsync(FakeTarget target, bool dryRun, string? installId = Id)
+ {
+ var output = new StringWriter();
+ var error = new StringWriter();
+ var exit = await DarlingXeSessionCleanup.RunAsync("sql01", dryRun, target, output, error, CancellationToken.None, installId);
+ return (exit, output.ToString(), error.ToString());
+ }
+
+ private static string GuardedDropText(string name, string view, string alias, string on) =>
+ string.Join(
+ Environment.NewLine,
+ " IF EXISTS",
+ " (",
+ " SELECT",
+ " 1/0",
+ $" FROM sys.{view} AS {alias}",
+ $" WHERE {alias}.name = N'{name}'",
+ " )",
+ " BEGIN",
+ $" DROP EVENT SESSION [{name}] ON {on};",
+ " END;");
+
+ [Fact]
+ public async Task TheVerb_DropsThisInstallsSessionsAndTheSharedOnes_AndListsOtherInstallsWithoutDroppingThem_OnServerScope()
+ {
+ var target = new FakeTarget();
+ target.Found.Add(new ExistingXeSession(OwnLongQuery, XeSessionScope.Server));
+ target.Found.Add(new ExistingXeSession(Legacy, XeSessionScope.Server));
+ target.Found.Add(new ExistingXeSession(SharedDeadlock, XeSessionScope.Server));
+ target.Others.Add(new ExistingXeSession(OtherLiteLongQuery, XeSessionScope.Server));
+ target.Others.Add(new ExistingXeSession(OtherDarlingDeadlock, XeSessionScope.Server));
+
+ var (exit, output, error) = await RunAsync(target, dryRun: false);
+
+ Assert.Equal(0, exit);
+ Assert.Equal(string.Empty, error);
+ Assert.Equal(
+ new[]
+ {
+ "DROP EVENT SESSION [PerformanceMonitor_Deadlock] ON SERVER;",
+ "DROP EVENT SESSION [PerformanceMonitor_LongQueryCompletions] ON SERVER;",
+ "DROP EVENT SESSION [PerformanceMonitor_Darling_1a2b3c4d_LongQueryCompletions] ON SERVER;",
+ },
+ target.Dropped.Select(d => d.Statement).ToArray());
+ Assert.Contains($"[DROPPED] {OwnLongQuery} (server scope)", output, StringComparison.Ordinal);
+
+ Assert.Contains(DarlingXeSessionCleanup.OtherInstallsHeading, output, StringComparison.Ordinal);
+ Assert.Contains($"{OtherLiteLongQuery} (server scope)", output, StringComparison.Ordinal);
+ Assert.Contains(GuardedDropText(OtherLiteLongQuery, "server_event_sessions", "ses", "SERVER"), output, StringComparison.Ordinal);
+ Assert.Contains(GuardedDropText(OtherDarlingDeadlock, "server_event_sessions", "ses", "SERVER"), output, StringComparison.Ordinal);
+ Assert.DoesNotContain("[DROPPED] " + OtherLiteLongQuery, output, StringComparison.Ordinal);
+ Assert.DoesNotContain("[DROPPED] " + OtherDarlingDeadlock, output, StringComparison.Ordinal);
+ Assert.Single(Regex.Matches(output, "^WARNING: ", RegexOptions.Multiline));
+ }
+
+ [Fact]
+ public async Task TheVerb_DropsAndListsTheSameWay_OnAzureSqlDatabase()
+ {
+ var target = new FakeTarget();
+ target.Found.Add(new ExistingXeSession(OwnLongQuery, XeSessionScope.Database, "alpha"));
+ target.Found.Add(new ExistingXeSession(OwnDeadlock, XeSessionScope.Database, "alpha"));
+ target.Found.Add(new ExistingXeSession(SharedBlocked, XeSessionScope.Database, "beta"));
+ target.Others.Add(new ExistingXeSession(OtherLiteLongQuery, XeSessionScope.Database, "alpha"));
+ target.Others.Add(new ExistingXeSession(OtherDarlingBlocked, XeSessionScope.Database, "beta"));
+
+ var (exit, output, error) = await RunAsync(target, dryRun: false);
+
+ Assert.Equal(0, exit);
+ Assert.Equal(string.Empty, error);
+ Assert.Equal(
+ new[]
+ {
+ "DROP EVENT SESSION [PerformanceMonitor_Darling_1a2b3c4d_LongQueryCompletions] ON DATABASE;",
+ "DROP EVENT SESSION [PerformanceMonitor_Darling_1a2b3c4d_Deadlock] ON DATABASE;",
+ "DROP EVENT SESSION [PerformanceMonitor_BlockedProcess] ON DATABASE;",
+ },
+ target.Dropped.Select(d => d.Statement).ToArray());
+ Assert.Contains($"[DROPPED] {OwnDeadlock} (database scope, in database alpha)", output, StringComparison.Ordinal);
+
+ Assert.Contains(DarlingXeSessionCleanup.OtherInstallsHeading, output, StringComparison.Ordinal);
+ Assert.Contains($"{OtherLiteLongQuery} (database scope, in database alpha)", output, StringComparison.Ordinal);
+ Assert.Contains(GuardedDropText(OtherLiteLongQuery, "database_event_sessions", "des", "DATABASE"), output, StringComparison.Ordinal);
+ Assert.Contains(GuardedDropText(OtherDarlingBlocked, "database_event_sessions", "des", "DATABASE"), output, StringComparison.Ordinal);
+ Assert.DoesNotContain("[DROPPED] " + OtherLiteLongQuery, output, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public async Task ADryRun_ListsThisInstallsDropsAndTheOthersSessions_AndDropsNothing()
+ {
+ var target = new FakeTarget();
+ target.Found.Add(new ExistingXeSession(OwnLongQuery, XeSessionScope.Server));
+ target.Others.Add(new ExistingXeSession(OtherLiteLongQuery, XeSessionScope.Server));
+
+ var (exit, output, _) = await RunAsync(target, dryRun: true);
+
+ Assert.Equal(0, exit);
+ Assert.Empty(target.Dropped);
+ Assert.Contains($"[WOULD DROP] DROP EVENT SESSION [{OwnLongQuery}] ON SERVER;", output, StringComparison.Ordinal);
+ Assert.Contains(DarlingXeSessionCleanup.OtherInstallsHeading, output, StringComparison.Ordinal);
+ Assert.Contains(GuardedDropText(OtherLiteLongQuery, "server_event_sessions", "ses", "SERVER"), output, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public async Task WithNothingOfAnotherInstalls_TheVerbPrintsNoListing()
+ {
+ var target = new FakeTarget();
+ target.Found.Add(new ExistingXeSession(OwnLongQuery, XeSessionScope.Server));
+
+ var (_, output, _) = await RunAsync(target, dryRun: false);
+
+ Assert.DoesNotContain(DarlingXeSessionCleanup.OtherInstallsHeading, output, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public async Task WithNoId_TheVerbSaysSo_AndHandlesNoInstallName()
+ {
+ var target = new FakeTarget();
+ target.Found.Add(new ExistingXeSession(OwnLongQuery, XeSessionScope.Server));
+ target.Found.Add(new ExistingXeSession(SharedDeadlock, XeSessionScope.Server));
+ target.Others.Add(new ExistingXeSession(OtherLiteLongQuery, XeSessionScope.Server));
+
+ var (exit, output, _) = await RunAsync(target, dryRun: false, installId: null);
+
+ Assert.Equal(0, exit);
+ Assert.Contains(DarlingXeSessionCleanup.NoInstallIdNote, output, StringComparison.Ordinal);
+ Assert.Equal(new[] { "DROP EVENT SESSION [PerformanceMonitor_Deadlock] ON SERVER;" }, target.Dropped.Select(d => d.Statement).ToArray());
+ Assert.DoesNotContain(DarlingXeSessionCleanup.OtherInstallsHeading, output, StringComparison.Ordinal);
+ Assert.DoesNotContain(OtherLiteLongQuery, output, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public async Task TheNoteAfterADrop_NamesTheCaptureOfAnInstallsSessionToo_OncePerCapture()
+ {
+ var target = new FakeTarget();
+ target.Found.Add(new ExistingXeSession(OwnLongQuery, XeSessionScope.Database, "alpha"));
+ target.Found.Add(new ExistingXeSession(Legacy, XeSessionScope.Database, "alpha"));
+ target.Found.Add(new ExistingXeSession(OwnDeadlock, XeSessionScope.Database, "alpha"));
+
+ var (_, output, _) = await RunAsync(target, dryRun: false);
+
+ Assert.Contains(
+ "NOTE: capture of deadlocks and long query completions on that server stops until this service reconnects to it, so remove the server next.",
+ output,
+ StringComparison.Ordinal);
+ }
+
+ // ---- the CLI hands the target this install's id ----------------------------------------------------------------------
+
+ private static string WriteConfig(DirectoryInfo root)
+ {
+ var listener = new TcpListener(IPAddress.Loopback, 0);
+ listener.Start();
+ var port = ((IPEndPoint)listener.LocalEndpoint).Port;
+ listener.Stop();
+
+ var path = Path.Combine(root.FullName, "darling.json");
+ File.WriteAllText(
+ path,
+ "{ \"postgres\": { \"connectionString\": "
+ + JsonSerializer.Serialize($"Host=127.0.0.1;Port={port};Username=darling;Password=x;Database=darlingtest;Timeout=2")
+ + " }, \"servers\": [ { \"name\": \"SQL2022\", \"host\": \"SQL2022\" } ] }");
+ return path;
+ }
+
+ private static async Task<(int Exit, string Output, string Error, XeCleanupStoreFacts? Seen)> RunVerbAsync(
+ FakeTarget target, XeCleanupStoreFacts facts, params string[] extra)
+ {
+ var root = Directory.CreateTempSubdirectory("drop-xe-install-");
+ try
+ {
+ var output = new StringWriter();
+ var error = new StringWriter();
+ XeCleanupStoreFacts? seen = null;
+ var exit = await DarlingCliCommands.DropXeSessionsAsync(
+ ["SQL2022", "--config", WriteConfig(root), .. extra],
+ (_, _, handed, _) =>
+ {
+ seen = handed;
+ return Task.FromResult(target);
+ },
+ output,
+ error,
+ CancellationToken.None,
+ readStoreFacts: (_, _) => Task.FromResult(facts));
+ return (exit, output.ToString(), error.ToString(), seen);
+ }
+ finally
+ {
+ root.Delete(recursive: true);
+ }
+ }
+
+ [Fact]
+ public async Task TheVerb_ReadsTheInstallIdFromTheStore_AndHandsItToTheTarget_AndDropsWithIt()
+ {
+ var target = new FakeTarget();
+ target.Found.Add(new ExistingXeSession(OwnLongQuery, XeSessionScope.Server));
+ target.Others.Add(new ExistingXeSession(OtherLiteLongQuery, XeSessionScope.Server));
+
+ var (exit, output, _, seen) = await RunVerbAsync(target, new XeCleanupStoreFacts(Id));
+
+ Assert.Equal(0, exit);
+ Assert.Equal(Id, seen?.InstallId);
+ Assert.Equal(new[] { $"DROP EVENT SESSION [{OwnLongQuery}] ON SERVER;" }, target.Dropped.Select(d => d.Statement).ToArray());
+ Assert.Contains(DarlingXeSessionCleanup.OtherInstallsHeading, output, StringComparison.Ordinal);
+ Assert.DoesNotContain(DarlingXeSessionCleanup.NoInstallIdNote, output, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public async Task TheVerb_WhenTheStoreHasNoId_SaysSo_AndTheTargetGetsNone()
+ {
+ var target = new FakeTarget();
+ target.Found.Add(new ExistingXeSession(OwnLongQuery, XeSessionScope.Server));
+
+ var (exit, output, _, seen) = await RunVerbAsync(target, XeCleanupStoreFacts.NoId);
+
+ Assert.Equal(0, exit);
+ Assert.Null(seen?.InstallId);
+ Assert.Empty(target.Dropped);
+ Assert.Contains(DarlingXeSessionCleanup.NoInstallIdNote, output, StringComparison.Ordinal);
+ }
+}
diff --git a/Darling/Darling.Tests/DropXeSessionsReadOnlyIntentTests.cs b/Darling/Darling.Tests/DropXeSessionsReadOnlyIntentTests.cs
new file mode 100644
index 0000000000..4c85a0f4ff
--- /dev/null
+++ b/Darling/Darling.Tests/DropXeSessionsReadOnlyIntentTests.cs
@@ -0,0 +1,292 @@
+/*
+ * 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.Threading;
+using System.Threading.Tasks;
+using Microsoft.Data.SqlClient;
+using PerformanceMonitor.Collectors;
+using PerformanceMonitor.Darling.Service;
+using Xunit;
+
+namespace Darling.Tests;
+
+///
+/// #4961: on Azure SQL Database a session cannot be dropped over a read-only connection. For a registration with read-only
+/// intent, --drop-xe-sessions stops each session over the registration's own connection when it runs there, then drops
+/// it over a connection with the intent forced off. Every other drop stays one statement over the registration's own
+/// connection. The target opens its connections through a test seam here, so the statements and the connection each one runs
+/// over pin without a SQL Server.
+///
+public sealed class DropXeSessionsReadOnlyIntentTests
+{
+ private const string Host = "dropxe-ro.database.windows.net";
+ private const string InstallId = "1a2b3c4d";
+ private const string Database = "beta";
+
+ /// One thing the target did: the connection it did it over, what it did, and the statement or the name.
+ private sealed record Call(string ConnectionString, string Kind, string Text);
+
+ /// A stand-in for one open connection: it records what it is asked and answers whether the session runs there.
+ private sealed class FakeDatabase : IAlwaysOnXeDatabase
+ {
+ private readonly string _connectionString;
+ private readonly List _calls;
+ private readonly bool _running;
+
+ public FakeDatabase(string connectionString, List calls, bool running)
+ {
+ _connectionString = connectionString;
+ _calls = calls;
+ _running = running;
+ }
+
+ public Task ReadCatalogAsync(AlwaysOnXeSessionKind kind, string sessionName, CancellationToken cancellationToken) =>
+ throw new NotSupportedException("A drop reads no catalog.");
+
+ public Task IsStartedAsync(string sessionName, CancellationToken cancellationToken)
+ {
+ _calls.Add(new Call(_connectionString, "Runs", sessionName));
+ return Task.FromResult(_running);
+ }
+
+ public Task ExecuteAsync(string statement, CancellationToken cancellationToken)
+ {
+ _calls.Add(new Call(_connectionString, "Execute", statement));
+ return Task.CompletedTask;
+ }
+
+ public bool IsAlreadyPresent(Exception exception) => false;
+ }
+
+ private static string AzureConnectionString(bool readOnlyIntent) =>
+ $"Server=tcp:{Host},1433;Initial Catalog=master;Encrypt=True" + (readOnlyIntent ? ";ApplicationIntent=ReadOnly" : string.Empty);
+
+ /// A product target (no names of its own) for a registration, with every connection it opens recorded.
+ private static (SqlServerXeSessionCleanupTarget Target, List Calls) Rig(string connectionString, bool azure, bool running)
+ {
+ var calls = new List();
+ var target = new SqlServerXeSessionCleanupTarget(
+ new ServerRuntime
+ {
+ Config = new MonitoredServer { Name = "dropxe", Host = Host },
+ ConnectionString = connectionString,
+ Target = new CollectorTargetInfo { IsAzureSqlDb = azure },
+ StorageName = Host,
+ ServerId = 1,
+ },
+ sessionNames: null,
+ registry: null,
+ facts: new XeCleanupStoreFacts(InstallId))
+ {
+ OpenDatabaseForTests = (opened, _) => Task.FromResult(new FakeDatabase(opened, calls, running)),
+ };
+ return (target, calls);
+ }
+
+ private static ApplicationIntent IntentOf(string connectionString) =>
+ new SqlConnectionStringBuilder(connectionString).ApplicationIntent;
+
+ private static string DatabaseOf(string connectionString) =>
+ new SqlConnectionStringBuilder(connectionString).InitialCatalog;
+
+ private static XeSessionDrop DatabaseDrop(string name) =>
+ new(new ExistingXeSession(name, XeSessionScope.Database, Database), InstallId);
+
+ /// Every name the verb may drop for this install, so each one is a name a stop must reach too.
+ public static TheoryData AllowedNames()
+ {
+ var names = new TheoryData();
+ foreach (var name in DarlingXeSessionCleanup.NamesToDrop(InstallId))
+ {
+ names.Add(name);
+ }
+
+ return names;
+ }
+
+ // ---- the drop itself ------------------------------------------------------------------------------------------------
+
+ [Theory]
+ [MemberData(nameof(AllowedNames))]
+ public async Task AReadOnlyIntentDrop_OfASessionThatRunsOnTheReplica_StopsOverTheOwnConnection_ThenDropsOverOneWithoutTheIntent(string name)
+ {
+ var (target, calls) = Rig(AzureConnectionString(readOnlyIntent: true), azure: true, running: true);
+ var drop = DatabaseDrop(name);
+
+ await target.DropAsync(drop, CancellationToken.None);
+
+ Assert.Equal(new[] { "Runs", "Execute", "Execute" }, calls.Select(c => c.Kind));
+ Assert.Equal(name, calls[0].Text);
+ Assert.Equal(DarlingXeSessionCleanup.StopStatement(name, InstallId), calls[1].Text);
+ Assert.Equal(drop.Statement, calls[2].Text);
+
+ /* The check and the stop go over the registration's own read-only connection, to the session's database. */
+ Assert.All(calls.Take(2), c =>
+ {
+ Assert.Equal(ApplicationIntent.ReadOnly, IntentOf(c.ConnectionString));
+ Assert.Equal(Database, DatabaseOf(c.ConnectionString));
+ });
+
+ /* The drop goes over the same database with the intent forced off. */
+ Assert.Equal(ApplicationIntent.ReadWrite, IntentOf(calls[2].ConnectionString));
+ Assert.Equal(Database, DatabaseOf(calls[2].ConnectionString));
+ }
+
+ [Theory]
+ [MemberData(nameof(AllowedNames))]
+ public async Task AReadOnlyIntentDrop_OfASessionThatDoesNotRunOnTheReplica_SendsOnlyTheDropOverOneWithoutTheIntent(string name)
+ {
+ var (target, calls) = Rig(AzureConnectionString(readOnlyIntent: true), azure: true, running: false);
+ var drop = DatabaseDrop(name);
+
+ await target.DropAsync(drop, CancellationToken.None);
+
+ Assert.Equal(new[] { "Runs", "Execute" }, calls.Select(c => c.Kind));
+ Assert.Equal(drop.Statement, calls[1].Text);
+ Assert.DoesNotContain(calls, c => c.Text.Contains("STATE = STOP", StringComparison.Ordinal));
+ Assert.Equal(ApplicationIntent.ReadWrite, IntentOf(calls[1].ConnectionString));
+ Assert.Equal(Database, DatabaseOf(calls[1].ConnectionString));
+ }
+
+ [Theory]
+ [MemberData(nameof(AllowedNames))]
+ public async Task ADropWithoutReadOnlyIntent_IsOneDropOverTheRegistrationsOwnConnection(string name)
+ {
+ var (target, calls) = Rig(AzureConnectionString(readOnlyIntent: false), azure: true, running: true);
+ var drop = DatabaseDrop(name);
+
+ await target.DropAsync(drop, CancellationToken.None);
+
+ var only = Assert.Single(calls);
+ Assert.Equal("Execute", only.Kind);
+ Assert.Equal(drop.Statement, only.Text);
+ Assert.Equal(ApplicationIntent.ReadWrite, IntentOf(only.ConnectionString));
+ Assert.Equal(Database, DatabaseOf(only.ConnectionString));
+ }
+
+ /// A server-scoped target (SQL Server, Managed Instance, RDS) never stops anything first: its drop is one statement
+ /// over the registration's own connection, intent and all.
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public async Task AnOnPremisesDrop_IsOneDropOverTheRegistrationsOwnConnection_WhateverItsIntent(bool readOnlyIntent)
+ {
+ var own = "Server=sql01,1433;Encrypt=True" + (readOnlyIntent ? ";ApplicationIntent=ReadOnly" : string.Empty);
+ var (target, calls) = Rig(own, azure: false, running: true);
+ var drop = new XeSessionDrop(new ExistingXeSession(DeadlocksCollector.XeSessionName, XeSessionScope.Server), InstallId);
+
+ await target.DropAsync(drop, CancellationToken.None);
+
+ var only = Assert.Single(calls);
+ Assert.Equal("Execute", only.Kind);
+ Assert.Equal(drop.Statement, only.Text);
+ Assert.Equal(readOnlyIntent ? ApplicationIntent.ReadOnly : ApplicationIntent.ReadWrite, IntentOf(only.ConnectionString));
+ }
+
+ /// A name the target may not drop is refused for the stop as for the drop, before any connection opens.
+ [Theory]
+ [InlineData("system_health")]
+ [InlineData("performancemonitor_deadlock")]
+ [InlineData("PerformanceMonitor_Deadlock]; DROP DATABASE x; --")]
+ [InlineData("PerformanceMonitor_Darling_ffffffff_LongQueryCompletions")]
+ public async Task AReadOnlyIntentDrop_OfANameTheTargetMayNotDrop_IsRefusedBeforeAnyConnectionOpens(string name)
+ {
+ var (target, calls) = Rig(AzureConnectionString(readOnlyIntent: true), azure: true, running: true);
+
+ await Assert.ThrowsAsync(() => target.DropAsync(DatabaseDrop(name), CancellationToken.None));
+
+ Assert.Empty(calls);
+ }
+
+ // ---- the stop's text and its allow-list -------------------------------------------------------------------------
+
+ /// The stop reaches exactly the names the drop does: one list decides both, spelled exactly, for this install's id.
+ /// A name outside it, another install's, a case variant, and an own name with no install id are refused by both.
+ [Fact]
+ public void TheStop_AcceptsExactlyTheNamesTheDropAccepts()
+ {
+ var allowed = DarlingXeSessionCleanup.NamesToDrop(InstallId);
+ var candidates = allowed
+ .Concat(allowed.Select(n => n.ToUpperInvariant()))
+ .Concat(allowed.Select(n => n.ToLowerInvariant()))
+ .Concat(new[]
+ {
+ "system_health",
+ string.Empty,
+ "PerformanceMonitor_Darling_ffffffff_LongQueryCompletions",
+ "PerformanceMonitor_Darling_ffffffff_Deadlock",
+ "PerformanceMonitor_Lite_1a2b3c4d_BlockedProcess",
+ "PerformanceMonitor_Deadlock]; DROP DATABASE x; --",
+ "master'; DROP DATABASE x;--",
+ })
+ .Distinct(StringComparer.Ordinal)
+ .ToList();
+
+ foreach (var installId in new string?[] { InstallId, null, "ffffffff" })
+ {
+ foreach (var name in candidates)
+ {
+ var dropAccepts = Accepts(() => DarlingXeSessionCleanup.DropStatement(name, XeSessionScope.Database, installId));
+ var stopAccepts = Accepts(() => DarlingXeSessionCleanup.StopStatement(name, installId));
+ Assert.True(dropAccepts == stopAccepts, $"'{name}' for install '{installId}': drop accepts {dropAccepts}, stop accepts {stopAccepts}");
+ Assert.Equal(dropAccepts, DarlingXeSessionCleanup.NamesToDrop(installId).Contains(name, StringComparer.Ordinal));
+ }
+ }
+ }
+
+ private static bool Accepts(Func build)
+ {
+ try
+ {
+ build();
+ return true;
+ }
+ catch (ArgumentException)
+ {
+ return false;
+ }
+ }
+
+ /// A long-query name, the install's own or the old shared one, is stopped by the collector's builder, which guards
+ /// on the sessions that run in the database. The own deadlock and blocked-process names use the always-on builder.
+ [Fact]
+ public void TheStop_OfANameABuilderTakes_IsThatBuildersText()
+ {
+ var own = DarlingXeSessionCleanup.InstallSessionNames(InstallId);
+ var ownLongQuery = own.Single(LongQueryCompletionsCollector.IsInstallSessionName);
+
+ Assert.Equal(LongQueryCompletionsCollector.BuildStopSessionSql(ownLongQuery), DarlingXeSessionCleanup.StopStatement(ownLongQuery, InstallId));
+ Assert.Equal(
+ LongQueryCompletionsCollector.BuildStopSessionSql(LongQueryCompletionsCollector.LegacyXeSessionName),
+ DarlingXeSessionCleanup.StopStatement(LongQueryCompletionsCollector.LegacyXeSessionName, InstallId));
+ foreach (var kind in new[] { AlwaysOnXeSessionKind.Deadlock, AlwaysOnXeSessionKind.BlockedProcess })
+ {
+ var ownName = own.Single(n => AlwaysOnXeSessions.IsOwnName(n, kind));
+ Assert.Equal(AlwaysOnXeSessions.BuildAzureStopSql(kind, ownName), DarlingXeSessionCleanup.StopStatement(ownName, InstallId));
+ }
+ }
+
+ /// The shared deadlock and blocked-process names are ones no builder takes, so the verb builds their stop the way the
+ /// long-query one is built: a guard on sys.dm_xe_database_sessions, which lists the sessions that run on the replica
+ /// the connection reaches, around the one STOP.
+ [Theory]
+ [InlineData("PerformanceMonitor_Deadlock")]
+ [InlineData("PerformanceMonitor_BlockedProcess")]
+ public void TheStop_OfASharedName_IsGuardedOnTheSessionsThatRunInTheDatabase(string name)
+ {
+ var stop = DarlingXeSessionCleanup.StopStatement(name, InstallId);
+
+ Assert.Equal(
+ "\nIF EXISTS\n(\n SELECT\n 1/0\n FROM sys.dm_xe_database_sessions\n"
+ + $" WHERE name = N'{name}'\n)\nBEGIN\n ALTER EVENT SESSION [{name}] ON DATABASE STATE = STOP;\nEND;",
+ stop.Replace("\r\n", "\n", StringComparison.Ordinal));
+ }
+}
diff --git a/Darling/Darling.Tests/DropXeSessionsVerbTests.cs b/Darling/Darling.Tests/DropXeSessionsVerbTests.cs
index 0c219aff4d..c3f4524283 100644
--- a/Darling/Darling.Tests/DropXeSessionsVerbTests.cs
+++ b/Darling/Darling.Tests/DropXeSessionsVerbTests.cs
@@ -24,8 +24,8 @@
namespace Darling.Tests;
///
-/// #4732: removing a server leaves its Extended Events sessions on it, and --drop-xe-sessions is the operator's
-/// explicit way to remove them. The pure halves (the grammar, the statements, the plan) pin as text; the executor runs over a
+/// #4732, #4961: removing a server drops this install's own Extended Events sessions on it, and --drop-xe-sessions is
+/// the operator's explicit way to drop the shared ones and what a removal could not drop. The pure halves (the grammar, the statements, the plan) pin as text; the executor runs over a
/// fake target so the find-print-drop path needs no SQL Server.
///
public sealed class DropXeSessionsVerbTests
@@ -43,6 +43,9 @@ public sealed class DropXeSessionsVerbTests
private const string AzureHost = "dropxe.database.windows.net";
private static SqlServerXeSessionCleanupTarget AzureTarget(MonitoredServer self, params MonitoredServer[] registry) =>
+ AzureTarget(facts: null, self, registry);
+
+ private static SqlServerXeSessionCleanupTarget AzureTarget(XeCleanupStoreFacts? facts, MonitoredServer self, params MonitoredServer[] registry) =>
new(
new ServerRuntime
{
@@ -53,7 +56,35 @@ private static SqlServerXeSessionCleanupTarget AzureTarget(MonitoredServer self,
ServerId = self.ServerId,
},
sessionNames: null,
- registry);
+ registry,
+ facts);
+
+ private const int SelfId = 11;
+ private const int SecondId = 12;
+ private const string StoreInstallId = "1a2b3c4d";
+ private const string OwnLongQuery = "PerformanceMonitor_Darling_1a2b3c4d_LongQueryCompletions";
+
+ /// One row of the store's collector schedules for the long-query collector: a registration's own override, or the
+ /// install's default when is null.
+ private static ScheduleOverride LongQueryRow(int? serverId, bool enabled) =>
+ new(serverId, "long_query_completions", FrequencyMinutes: null, RetentionDays: null, enabled);
+
+ private static XeCleanupStoreFacts FactsWith(IReadOnlyList? rows, IReadOnlyDictionary? names = null) =>
+ new(StoreInstallId, rows, names);
+
+ /// Two registrations of one logical server: this one, which excludes gamma, and a second with read-only intent,
+ /// which excludes delta.
+ private static (MonitoredServer Self, MonitoredServer Second) TwoRegistrations() =>
+ (new MonitoredServer { Name = "dropxe", Host = AzureHost, ExcludedDatabases = ["gamma"], StoredServerId = SelfId },
+ new MonitoredServer { Name = "dropxe-ro", Host = AzureHost, ReadOnlyIntent = true, ExcludedDatabases = ["delta"], StoredServerId = SecondId });
+
+ private static SqlServerXeSessionCleanupTarget.AzureSearchPlan PlanWith(XeCleanupStoreFacts? facts)
+ {
+ var (self, second) = TwoRegistrations();
+ return AzureTarget(facts, self, self, second).PlanAzureSearch(
+ monitored: ["master", "alpha", "delta"],
+ every: ["master", "alpha", "gamma", "delta"]);
+ }
///
/// The long-query session is searched in the databases the registration excludes, because a session created before the
@@ -82,23 +113,57 @@ public void TheLongQuerySearch_VisitsAnExcludedDatabase_AndSkipsOneMonitoredAsIt
}
///
- /// The verb cannot read another registration's long-query schedule, so it counts every other registration of the logical
- /// server as keeping the session: it leaves each database that registration would create the session in.
+ /// #4961: another registration of the logical server keeps the session when its EFFECTIVE long-query setting is on, its own
+ /// override or else the install's default: the verb leaves each database that registration would create the session in.
///
[Fact]
public void TheLongQuerySearch_SkipsADatabaseAnotherRegistrationOfTheServerKeeps()
{
- var self = new MonitoredServer { Name = "dropxe", Host = AzureHost, ExcludedDatabases = ["gamma"] };
- var second = new MonitoredServer { Name = "dropxe-ro", Host = AzureHost, ReadOnlyIntent = true, ExcludedDatabases = ["delta"] };
- var target = AzureTarget(self, self, second);
+ var plan = PlanWith(FactsWith([LongQueryRow(SecondId, enabled: true)]));
- var plan = target.PlanAzureSearch(
- monitored: ["master", "alpha", "delta"],
- every: ["master", "alpha", "gamma", "delta"]);
+ Assert.Equal(new[] { "delta" }, plan.LongQueryDatabases);
+ }
+
+ /// The install's default can turn a registration's trace on: it keeps the session with no row of its own.
+ [Fact]
+ public void TheLongQuerySearch_SkipsADatabaseWhenTheInstallsDefaultTurnsTheOtherRegistrationOn()
+ {
+ var plan = PlanWith(FactsWith([LongQueryRow(serverId: null, enabled: true)]));
Assert.Equal(new[] { "delta" }, plan.LongQueryDatabases);
}
+ ///
+ /// #4961: a registration whose long-query trace is off keeps nothing, so the verb drops the session in every database it
+ /// finds it in, the ones that registration would have created it in too. Its own override beats the install's default,
+ /// and with no row at all the collector's default, which is off, applies.
+ ///
+ [Fact]
+ public void TheLongQuerySearch_DropsInADatabaseTheOtherRegistrationLeavesOff()
+ {
+ var everyDatabase = new[] { "alpha", "gamma", "delta" };
+
+ /* Its own override is off. */
+ Assert.Equal(everyDatabase, PlanWith(FactsWith([LongQueryRow(SecondId, enabled: false)])).LongQueryDatabases);
+
+ /* Its own override beats an install's default that is on. */
+ Assert.Equal(
+ everyDatabase,
+ PlanWith(FactsWith([LongQueryRow(serverId: null, enabled: true), LongQueryRow(SecondId, enabled: false)])).LongQueryDatabases);
+
+ /* No row at all: the collector's own default applies, which is off. */
+ Assert.Equal(everyDatabase, PlanWith(FactsWith([])).LongQueryDatabases);
+ }
+
+ /// A store whose schedule rows could not be read gives the verb no setting to go by, so it keeps counting every
+ /// other registration of the logical server as keeping the session, as it did before it read them.
+ [Fact]
+ public void TheLongQuerySearch_WhenTheScheduleRowsCouldNotBeRead_CountsEveryOtherRegistrationAsKeeping()
+ {
+ Assert.Equal(new[] { "delta" }, PlanWith(FactsWith(rows: null)).LongQueryDatabases);
+ Assert.Equal(new[] { "delta" }, PlanWith(facts: null).LongQueryDatabases);
+ }
+
[Fact]
public void ATargetWithItsOwnNames_SearchesNoDatabaseForTheLongQuerySession()
{
@@ -119,6 +184,176 @@ public void ATargetWithItsOwnNames_SearchesNoDatabaseForTheLongQuerySession()
Assert.Equal(new[] { "alpha" }, plan.AlwaysOnDatabases);
}
+ // ---- on-premises: this install's long-query session when another registration of this install keeps it (#4961) ---------------
+
+ private static SqlServerXeSessionCleanupTarget OnPremisesTarget(XeCleanupStoreFacts facts, MonitoredServer self, params MonitoredServer[] registry) =>
+ new(
+ new ServerRuntime
+ {
+ Config = self,
+ ConnectionString = "Server=sql01;Encrypt=True",
+ Target = new CollectorTargetInfo(),
+ StorageName = "sql01",
+ ServerId = self.ServerId,
+ },
+ sessionNames: null,
+ registry,
+ facts);
+
+ private static (MonitoredServer Self, MonitoredServer Second) TwoOnPremisesRegistrations() =>
+ (new MonitoredServer { Name = "sql01-a", Host = "sql01-a", StoredServerId = SelfId },
+ new MonitoredServer { Name = "sql01-b", Host = "sql01-b", StoredServerId = SecondId });
+
+ private static Dictionary InstanceNames(string? own, string? second)
+ {
+ var names = new Dictionary();
+ if (own is not null)
+ {
+ names[SelfId] = own;
+ }
+
+ if (second is not null)
+ {
+ names[SecondId] = second;
+ }
+
+ return names;
+ }
+
+ /// The server-scope search of a server that holds this install's long-query session, the old shared one and the
+ /// shared deadlock session.
+ private static Task OnPremisesSearchAsync(
+ IReadOnlyList? rows, IReadOnlyDictionary? names, params string[] found)
+ {
+ var (self, second) = TwoOnPremisesRegistrations();
+ return OnPremisesTarget(FactsWith(rows, names), self, self, second)
+ .ServerScopeSearchAsync(found.Length == 0 ? [Deadlock, OwnLongQuery, LongQuery] : found, []);
+ }
+
+ private static readonly string[] SharedAndLegacy = [Deadlock, LongQuery];
+
+ ///
+ /// A server's session is the instance's own, so another registration of this install on the same instance, with its
+ /// long-query trace on, keeps this install's session: the verb leaves it and says why. The shared sessions and the old
+ /// shared long-query session are nobody's keeper session, so they stay in the plan.
+ ///
+ [Fact]
+ public async Task OnPremises_AKeeperOnTheSameInstance_LeavesThisInstallsLongQuerySession_AndSaysWhy()
+ {
+ var search = await OnPremisesSearchAsync([LongQueryRow(SecondId, enabled: true)], InstanceNames("SQL01", "sql01"));
+
+ Assert.Equal(SharedAndLegacy, search.Sessions.Select(session => session.Name).ToArray());
+ var note = Assert.Single(search.Notes);
+ Assert.Contains(OwnLongQuery, note, StringComparison.Ordinal);
+ Assert.Contains("Another registration of this install keeps it on the same instance", note, StringComparison.Ordinal);
+ }
+
+ /// A name that is not known cannot match: this registration's own, or the keeper's. The session is dropped.
+ [Fact]
+ public async Task OnPremises_AnUnknownInstanceName_DropsThisInstallsLongQuerySession()
+ {
+ var keeperOn = new[] { LongQueryRow(SecondId, enabled: true) };
+
+ foreach (var names in new[] { InstanceNames(null, "SQL01"), InstanceNames("SQL01", null), InstanceNames(null, null) })
+ {
+ var search = await OnPremisesSearchAsync(keeperOn, names);
+
+ Assert.Equal(new[] { Deadlock, OwnLongQuery, LongQuery }, search.Sessions.Select(session => session.Name).ToArray());
+ Assert.Empty(search.Notes);
+ }
+ }
+
+ [Fact]
+ public async Task OnPremises_AKeeperOnAnotherInstance_DropsThisInstallsLongQuerySession()
+ {
+ var search = await OnPremisesSearchAsync([LongQueryRow(SecondId, enabled: true)], InstanceNames("SQL01", "SQL02"));
+
+ Assert.Equal(new[] { Deadlock, OwnLongQuery, LongQuery }, search.Sessions.Select(session => session.Name).ToArray());
+ Assert.Empty(search.Notes);
+ }
+
+ /// A registration whose effective trace setting is off keeps nothing, however its instance name reads: its own
+ /// override, or with no row at all the collector's default, which is off.
+ [Fact]
+ public async Task OnPremises_AKeeperWithItsTraceOff_DoesNotCount()
+ {
+ var sameInstance = InstanceNames("SQL01", "SQL01");
+
+ foreach (var rows in new[] { new[] { LongQueryRow(SecondId, enabled: false) }, Array.Empty() })
+ {
+ var search = await OnPremisesSearchAsync(rows, sameInstance);
+
+ Assert.Equal(new[] { Deadlock, OwnLongQuery, LongQuery }, search.Sessions.Select(session => session.Name).ToArray());
+ Assert.Empty(search.Notes);
+ }
+ }
+
+ /// With the schedule rows unreadable, every other registration counts as having its trace on, as on Azure SQL
+ /// Database; the instance names still have to match.
+ [Fact]
+ public async Task OnPremises_WhenTheScheduleRowsCouldNotBeRead_AKeeperOnTheSameInstanceStillCounts()
+ {
+ var search = await OnPremisesSearchAsync(rows: null, InstanceNames("SQL01", "SQL01"));
+
+ Assert.Equal(SharedAndLegacy, search.Sessions.Select(session => session.Name).ToArray());
+ Assert.Single(search.Notes);
+ }
+
+ /// The guard is for this install's long-query session alone: with that session not on the server, nothing is left
+ /// and nothing is said.
+ [Fact]
+ public async Task OnPremises_WithNoLongQuerySessionOfThisInstallOnTheServer_SaysNothing()
+ {
+ var search = await OnPremisesSearchAsync([LongQueryRow(SecondId, enabled: true)], InstanceNames("SQL01", "SQL01"), Deadlock, LongQuery);
+
+ Assert.Equal(SharedAndLegacy, search.Sessions.Select(session => session.Name).ToArray());
+ Assert.Empty(search.Notes);
+ }
+
+ ///
+ /// Under the real executor: the session left in place is never dropped, the reason is on stderr, and it is a note, not a
+ /// failure, so the exit code stays 0 for a script that removes the server next.
+ ///
+ [Fact]
+ public async Task OnPremises_TheVerbDoesNotDropTheSessionItLeft_AndPrintsWhyOnStderr()
+ {
+ var (self, second) = TwoOnPremisesRegistrations();
+ var real = OnPremisesTarget(FactsWith([LongQueryRow(SecondId, enabled: true)], InstanceNames("SQL01", "SQL01")), self, self, second);
+ var target = new OnPremisesSearchTarget(real, [Deadlock, OwnLongQuery, LongQuery]);
+ var output = new StringWriter();
+ var error = new StringWriter();
+
+ var exit = await DarlingXeSessionCleanup.RunAsync("sql01", dryRun: false, target, output, error, CancellationToken.None, StoreInstallId);
+
+ Assert.Equal(0, exit);
+ Assert.Equal(SharedAndLegacy, target.Dropped.Select(drop => drop.Session.Name).ToArray());
+ Assert.Contains(OwnLongQuery, error.ToString(), StringComparison.Ordinal);
+ Assert.Contains("keeps it on the same instance", error.ToString(), StringComparison.Ordinal);
+ Assert.DoesNotContain(OwnLongQuery, output.ToString(), StringComparison.Ordinal);
+ }
+
+ private sealed class OnPremisesSearchTarget : IXeSessionCleanupTarget
+ {
+ private readonly SqlServerXeSessionCleanupTarget _real;
+ private readonly string[] _found;
+
+ public OnPremisesSearchTarget(SqlServerXeSessionCleanupTarget real, string[] found)
+ {
+ _real = real;
+ _found = found;
+ }
+
+ public List Dropped { get; } = [];
+
+ public Task FindSessionsAsync(CancellationToken cancellationToken) => _real.ServerScopeSearchAsync(_found, []);
+
+ public Task DropAsync(XeSessionDrop drop, CancellationToken cancellationToken)
+ {
+ Dropped.Add(drop);
+ return Task.CompletedTask;
+ }
+ }
+
// ---- a database the search cannot open: a problem when it is monitored, a note when the registration excludes it ----------
/// The registration excludes gamma and monitors alpha as its own server: the plan visits alpha and beta for the
@@ -395,7 +630,9 @@ public void TheScriptDropsThreeNamesInTwoScopes_AndNothingElse()
var script = DarlingXeSessionCleanup.GuardedDropScript();
var code = string.Join('\n', script.Split('\n').Where(l => !l.TrimStart().StartsWith("--", StringComparison.Ordinal)));
- Assert.Equal(6, Regex.Matches(code, "DROP EVENT SESSION", RegexOptions.CultureInvariant).Count);
+ /* Six guarded drops, and the two per-install queries (one for each scope) that print a DROP statement as a column for
+ the operator to run (#4961). */
+ Assert.Equal(8, Regex.Matches(code, "DROP EVENT SESSION", RegexOptions.CultureInvariant).Count);
Assert.Equal(6, Regex.Matches(code, @"IF EXISTS", RegexOptions.CultureInvariant).Count);
Assert.Equal(
new[] { Blocked, Deadlock, LongQuery },
@@ -404,7 +641,7 @@ public void TheScriptDropsThreeNamesInTwoScopes_AndNothingElse()
new[] { Deadlock, Blocked, LongQuery },
DarlingXeSessionCleanup.SessionNames.ToArray());
Assert.Equal(
- new[] { DeadlocksCollector.XeSessionName, BlockedProcessReportCollector.XeSessionName, LongQueryCompletionsCollector.XeSessionName },
+ new[] { DeadlocksCollector.XeSessionName, BlockedProcessReportCollector.XeSessionName, LongQueryCompletionsCollector.LegacyXeSessionName },
DarlingXeSessionCleanup.SessionNames.ToArray());
foreach (var other in new[] { "CREATE", "ALTER", "DELETE", "TRUNCATE", "EXEC", "sp_", "DROP DATABASE", "DROP TABLE", "ON ALL SERVER" })
{
@@ -782,7 +1019,7 @@ public async Task ANameTheTargetReturnsThatIsNotDarlings_IsNeverDropped()
// ---- the whole verb through the configuration, with the connection injected ------------------------------------------
- private static Task ThrowingConnect(MonitoredServer server, IReadOnlyList registry, CancellationToken cancellationToken) =>
+ private static Task ThrowingConnect(MonitoredServer server, IReadOnlyList registry, XeCleanupStoreFacts facts, CancellationToken cancellationToken) =>
throw new InvalidOperationException("the verb connected when it should not have");
private static string WriteConfig(DirectoryInfo root)
@@ -810,7 +1047,7 @@ private static string WriteConfig(DirectoryInfo root)
var exit = await DarlingCliCommands.DropXeSessionsAsync(
rest,
- (server, _, _) =>
+ (server, _, _, _) =>
{
connected.Add(server.DisplayName);
return connectFails is not null
@@ -963,6 +1200,11 @@ public void RemoveServer_NamesTheVerb_ForAServerThatHadConnected()
Assert.Contains("--print-sql", note, StringComparison.Ordinal);
Assert.Contains("history are kept", note, StringComparison.Ordinal);
+ /* #4961: the removal drops this install's own sessions, so the note no longer says every session stays. */
+ Assert.Contains("also drops this install's own Extended Events sessions on the server", note, StringComparison.Ordinal);
+ Assert.Contains("The shared sessions (", note, StringComparison.Ordinal);
+ Assert.DoesNotContain("are not dropped and stay on the server", note, StringComparison.Ordinal);
+
/* The server is gone by the time the answer is read, so the named form cannot find it: the first form the note points at is
--print-sql, and the named form is mentioned only to say it had to run before the removal. */
Assert.Equal(
@@ -988,7 +1230,7 @@ public void TheReadmeNamesEverySessionTheVerbDrops_InItsSectionAndInTheRemoveSer
Assert.True(end > start, "the --drop-xe-sessions section no longer ends at a horizontal rule");
var section = readme[start..end];
- var bulletAt = readme.IndexOf("`remove_server` (which leaves the server's Extended Events sessions on it", StringComparison.Ordinal);
+ var bulletAt = readme.IndexOf("`remove_server` (which drops this install's own Extended Events sessions on the server", StringComparison.Ordinal);
Assert.True(bulletAt >= 0, "the remove_server bullet no longer says what it leaves on the server (#4732)");
var bullet = readme[bulletAt..Math.Min(readme.Length, bulletAt + 500)];
@@ -1031,17 +1273,19 @@ public void TheReadmeSaysWhenToRunTheNamedForm_WhatItStops_WhatToRunAfterTheRemo
Assert.DoesNotContain("monitored server: the Extended Events sessions", section, StringComparison.Ordinal);
// The other places that point at the verb say the same: after the removal it is --print-sql.
- var bulletAt = readme.IndexOf("`remove_server` (which leaves the server's Extended Events sessions on it", StringComparison.Ordinal);
+ var bulletAt = readme.IndexOf("`remove_server` (which drops this install's own Extended Events sessions on the server", StringComparison.Ordinal);
Assert.True(bulletAt >= 0, "the remove_server bullet no longer says what it leaves on the server (#4732)");
var bullet = readme[bulletAt..Math.Min(readme.Length, bulletAt + 1000)];
Assert.Contains("`--drop-xe-sessions --print-sql`", bullet, StringComparison.Ordinal);
Assert.Contains("works only before the removal", bullet, StringComparison.Ordinal);
- var collectorAt = readme.IndexOf("a server removed while the collector was on keeps the session.", StringComparison.Ordinal);
- Assert.True(collectorAt >= 0, "the long_query_completions paragraph no longer says a removed server keeps its session (#4732)");
+ /* #4961: removing the server drops this install's long-query session; the verb's script is for an attempt that failed. */
+ Assert.DoesNotContain("a server removed while the collector was on keeps the session", readme, StringComparison.Ordinal);
+ var collectorAt = readme.IndexOf("Removing the server also drops this install's session on it", StringComparison.Ordinal);
+ Assert.True(collectorAt >= 0, "the long_query_completions paragraph no longer says removing a server drops this install's session (#4961)");
var collector = readme[collectorAt..Math.Min(readme.Length, collectorAt + 500)];
- Assert.Contains("just before you remove the server", collector, StringComparison.Ordinal);
- Assert.Contains("`--drop-xe-sessions --print-sql` afterwards", collector, StringComparison.Ordinal);
+ Assert.Contains("If that attempt failed or ran out of time", collector, StringComparison.Ordinal);
+ Assert.Contains("`--drop-xe-sessions --print-sql`", collector, StringComparison.Ordinal);
// The list of verbs that survive an unusable store connection names this one.
var listAt = readme.IndexOf("Every other verb that opens the store (", StringComparison.Ordinal);
diff --git a/Darling/Darling.Tests/EmptyDatabaseListNoteLiveTests.cs b/Darling/Darling.Tests/EmptyDatabaseListNoteLiveTests.cs
new file mode 100644
index 0000000000..d1ceb988d6
--- /dev/null
+++ b/Darling/Darling.Tests/EmptyDatabaseListNoteLiveTests.cs
@@ -0,0 +1,187 @@
+/*
+ * 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.Data.Common;
+using System.Globalization;
+using System.Threading;
+using System.Threading.Tasks;
+using Npgsql;
+using PerformanceMonitor.Collectors;
+using PerformanceMonitor.Darling.Service;
+using PerformanceMonitor.Darling.Storage;
+using Xunit;
+
+namespace Darling.Tests;
+
+///
+/// Drives the host's REAL per-database loop with a database list that has nothing left to read. The run returns
+/// no rows, does not fail, and carries the note that says why: every user database monitored as its own server,
+/// or every database excluded. An empty list opens no per-database connection, so the only hook is the list.
+///
+[Collection("live-postgres")]
+public sealed class EmptyDatabaseListNoteLiveTests
+{
+ private const int LiveServerId = -490061;
+
+ [Theory]
+ [InlineData(true)]
+ [InlineData(false)]
+ public async Task APerDatabaseRunWithNoDatabaseToRead_ReturnsItsNote_AndNoRows(bool everyDatabaseSeparatelyMonitored)
+ {
+ var connectionString = Environment.GetEnvironmentVariable("DARLING_TEST_PG");
+ Assert.SkipWhen(string.IsNullOrEmpty(connectionString),
+ "Set DARLING_TEST_PG to a Postgres connection string to run the live empty database list test.");
+
+ var ct = TestContext.Current.CancellationToken;
+
+ /* #1776 own-store: the run reads and writes only under a distinctive fake server id, and the cleanup removes
+ whatever the run saved for it. */
+ using var connection = new NpgsqlConnection(connectionString);
+ await connection.OpenAsync(ct);
+ await PgMigrations.MigrateAsync(connection, ct);
+ await DeleteAsync(connection, ct);
+
+ await using var postgres = NpgsqlDataSource.Create(connectionString!);
+ var runner = new DarlingCollectorRunner(
+ postgres,
+ new CollectorDeltaCalculator(),
+ separatelyMonitoredDatabases: _ => everyDatabaseSeparatelyMonitored ? new[] { "alpha", "zeta" } : Array.Empty());
+
+ var bodySucceeded = false;
+ try
+ {
+ /* Separately monitored: the server lists both databases and the long-query read leaves both out.
+ Excluded: the server's exclusions took every database out of the list before the read saw it. */
+ runner.AzureDatabaseListOverrideForTests = (_, _) => Task.FromResult(
+ everyDatabaseSeparatelyMonitored ? new List { "alpha", "zeta" } : new List());
+
+ var server = new ServerRuntime
+ {
+ Config = new MonitoredServer
+ {
+ Name = "t",
+ Host = "h",
+ ExcludedDatabases = everyDatabaseSeparatelyMonitored ? new List() : new List { "alpha", "zeta" },
+ },
+ ConnectionString = "Server=azure;Database=master",
+ Target = new CollectorTargetInfo { IsAzureSqlDb = true, Engine = CollectorTargetEngine.SqlServer },
+ StorageName = "h",
+ ServerId = LiveServerId,
+ };
+
+ var result = await runner.RunAsync(LongQueryCompletionsCollector.Instance, server, ct);
+
+ Assert.Equal(0, result.Rows);
+ Assert.Equal(
+ everyDatabaseSeparatelyMonitored ? EmptyDatabaseListNote.EverySeparatelyMonitored : EmptyDatabaseListNote.EveryExcluded,
+ result.Note);
+
+ bodySucceeded = true;
+ }
+ finally
+ {
+ await LiveStoreCleanup.RunAsync(connectionString!, bodySucceeded, async (cleanup, cleanupCt) =>
+ await DeleteAsync(cleanup, cleanupCt));
+ }
+ }
+
+ ///
+ /// A real logical server lists master beside its user databases, and the long-query trace never keeps a session in
+ /// master, so the read leaves it out. A list of master plus user databases that are all monitored separately has
+ /// nothing left to read and says so; master alone says why only when the exclusions took the user databases (#4961).
+ /// The first run above lists user databases only. Here a provider refuses every connection, so a read that
+ /// reaches any database, master included, fails the run by name instead of reading nothing.
+ ///
+ [Theory]
+ [InlineData("master,alpha,zeta", "", "alpha,zeta", EmptyDatabaseListNote.EverySeparatelyMonitored)]
+ [InlineData("master", "alpha,zeta", "", EmptyDatabaseListNote.EveryExcluded)]
+ [InlineData("master", "", "", null)]
+ public async Task APerDatabaseRunWhoseListHoldsMaster_ReturnsItsNote_AndReadsNoDatabase(
+ string listed, string excluded, string separatelyMonitored, string? expectedNote)
+ {
+ var connectionString = Environment.GetEnvironmentVariable("DARLING_TEST_PG");
+ Assert.SkipWhen(string.IsNullOrEmpty(connectionString),
+ "Set DARLING_TEST_PG to a Postgres connection string to run the live empty database list test.");
+
+ var ct = TestContext.Current.CancellationToken;
+
+ /* #1776 own-store: the run reads and writes only under a distinctive fake server id, and the cleanup removes
+ whatever the run saved for it. */
+ using var connection = new NpgsqlConnection(connectionString);
+ await connection.OpenAsync(ct);
+ await PgMigrations.MigrateAsync(connection, ct);
+ await DeleteAsync(connection, ct);
+
+ await using var postgres = NpgsqlDataSource.Create(connectionString!);
+ var runner = new DarlingCollectorRunner(
+ postgres,
+ new CollectorDeltaCalculator(),
+ separatelyMonitoredDatabases: _ => Names(separatelyMonitored));
+
+ var bodySucceeded = false;
+ try
+ {
+ runner.TargetProviderOverrideForTests = _ => new RefusingTargetProvider();
+ runner.AzureDatabaseListOverrideForTests = (_, _) => Task.FromResult(new List(Names(listed)));
+
+ var server = new ServerRuntime
+ {
+ Config = new MonitoredServer { Name = "t", Host = "h", ExcludedDatabases = new List(Names(excluded)) },
+ ConnectionString = "Server=azure;Database=master",
+ Target = new CollectorTargetInfo { IsAzureSqlDb = true, Engine = CollectorTargetEngine.SqlServer },
+ StorageName = "h",
+ ServerId = LiveServerId,
+ };
+
+ var result = await runner.RunAsync(LongQueryCompletionsCollector.Instance, server, ct);
+
+ Assert.Equal(0, result.Rows);
+ Assert.Equal(expectedNote, result.Note);
+
+ bodySucceeded = true;
+ }
+ finally
+ {
+ await LiveStoreCleanup.RunAsync(connectionString!, bodySucceeded, async (cleanup, cleanupCt) =>
+ await DeleteAsync(cleanup, cleanupCt));
+ }
+ }
+
+ private static string[] Names(string commaSeparated) =>
+ commaSeparated.Split(',', StringSplitOptions.RemoveEmptyEntries);
+
+ /// A target provider that refuses to open any connection, naming the database the run asked for.
+ private sealed class RefusingTargetProvider : ITargetProvider
+ {
+ public CollectorTargetEngine Engine => CollectorTargetEngine.SqlServer;
+
+ public DbConnection CreateConnection(string connectionString) =>
+ throw new InvalidOperationException("the long-query read opened a connection to " + connectionString);
+
+ public DbCommand CreateCommand(CollectorQuery query, DbConnection connection, int commandTimeoutSeconds) =>
+ throw new NotSupportedException();
+
+ public CollectorTargetFault Classify(Exception exception, bool yieldsOnLockTimeout) => CollectorTargetFault.Unclassified;
+
+ public string WithDatabase(string connectionString, string databaseName) => "database " + databaseName;
+
+ public (string ConnectionString, CollectorQuery Query) BuildDatabaseListPlan(
+ string connectionString, IReadOnlyList? excludedDatabases, IReadOnlyList? databaseScope) =>
+ throw new NotSupportedException();
+ }
+
+ private static async Task DeleteAsync(NpgsqlConnection connection, CancellationToken ct)
+ {
+ var id = LiveServerId.ToString(CultureInfo.InvariantCulture);
+ using var command = new NpgsqlCommand($"DELETE FROM collect.collector_state WHERE server_id = {id}", connection);
+ command.CommandTimeout = 30;
+ await command.ExecuteNonQueryAsync(ct);
+ }
+}
diff --git a/Darling/Darling.Tests/EmptyDatabaseListNoteTests.cs b/Darling/Darling.Tests/EmptyDatabaseListNoteTests.cs
new file mode 100644
index 0000000000..03000aa3b2
--- /dev/null
+++ b/Darling/Darling.Tests/EmptyDatabaseListNoteTests.cs
@@ -0,0 +1,106 @@
+/*
+ * 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.IO;
+using PerformanceMonitor.Collectors;
+using PerformanceMonitor.Common;
+using Xunit;
+
+namespace Darling.Tests;
+
+///
+/// A per-database run whose database list comes back empty records SUCCESS with 0 rows. Without a note that reads
+/// as "nothing ran". Two things empty the list: every user database on a logical server is monitored as its own
+/// server (the long-query read leaves those to their own registrations), or every database is excluded. The run
+/// keeps SUCCESS and carries a note that says why nothing was read (#4961). The note itself is pinned for real
+/// here and in ; the wiring is pinned at the source, the way the
+/// per-database read of the long-query trace already is.
+///
+public sealed class EmptyDatabaseListNoteTests
+{
+ [Fact]
+ public void TheNotes_AreFixedText_InProductWords()
+ {
+ Assert.Equal("no database read: every user database is monitored as its own server", EmptyDatabaseListNote.EverySeparatelyMonitored);
+ Assert.Equal("no database read: every database is excluded", EmptyDatabaseListNote.EveryExcluded);
+ }
+
+ [Theory]
+ [InlineData(EmptyDatabaseListNote.EverySeparatelyMonitored)]
+ [InlineData(EmptyDatabaseListNote.EveryExcluded)]
+ public void AServerWhoseEveryRunCarriesTheNote_ReadsItWithItsRunCount_AndNoQualifier(string note)
+ {
+ /* index_object_stats is a collector the health bands expect to find user databases for, so the missing
+ qualifier comes from the note, not from the collector being left out of that list. */
+ Assert.True(CollectorHealthClassifier.ExpectsUserDatabases("index_object_stats"));
+ Assert.DoesNotContain(CollectorHealthClassifier.EmptyEnumerationMarker, note, StringComparison.Ordinal);
+ Assert.False(CollectorHealthClassifier.HasMeasurements(note));
+
+ var formatted = CollectorHealthClassifier.FormatCollectionNote(
+ note, noteCount: 96, totalRuns: 96, collectorName: "index_object_stats", targetHasUserDatabases: true);
+
+ Assert.Equal(note + " (all 96 runs)", formatted);
+ Assert.DoesNotContain(CollectorHealthClassifier.HasUserDatabasesQualifier, formatted, StringComparison.Ordinal);
+ }
+
+ [Theory]
+ [InlineData(0, 0, false, false, false, null)]
+ [InlineData(0, 0, false, true, false, EmptyDatabaseListNote.EveryExcluded)]
+ [InlineData(0, 0, true, true, false, EmptyDatabaseListNote.EveryExcluded)]
+ [InlineData(0, 0, false, true, true, null)]
+ [InlineData(2, 0, true, false, false, EmptyDatabaseListNote.EverySeparatelyMonitored)]
+ [InlineData(2, 0, true, true, true, EmptyDatabaseListNote.EverySeparatelyMonitored)]
+ [InlineData(2, 1, true, false, false, null)]
+ [InlineData(2, 2, false, true, false, null)]
+ public void TheNote_NamesTheReasonTheListEmptied_AndNothingItCannotName(
+ int listed, int read, bool skipsSeparatelyMonitored, bool exclusionsConfigured, bool databaseScoped, string? expected)
+ {
+ Assert.Equal(expected, EmptyDatabaseListNote.For(listed, read, skipsSeparatelyMonitored, exclusionsConfigured, databaseScoped));
+ }
+
+ [Fact]
+ public void TheRunner_TakesTheNoteFromTheSharedRule_AndCarriesNoTextOfItsOwn()
+ {
+ var runner = ReadLf("Darling", "PerformanceMonitor.Darling.Service", "DarlingCollectorRunner.cs");
+
+ Assert.Contains("EmptyDatabaseListNote.For(", runner, StringComparison.Ordinal);
+ Assert.DoesNotContain("no database read", runner, StringComparison.Ordinal);
+ }
+
+ ///
+ /// The assignment that follows the per-database loop is unconditional, so a note assigned anywhere earlier
+ /// would be erased by it. The note rides in that assignment instead.
+ ///
+ [Fact]
+ public void TheNote_IsMergedIntoTheAssignmentThatFollowsThePerDatabaseLoop()
+ {
+ var runner = ReadLf("Darling", "PerformanceMonitor.Darling.Service", "DarlingCollectorRunner.cs");
+
+ var loopEnd = runner.IndexOf("await PrunePgPerDatabaseStateAsync(server.ServerId, definition.Name, databases, cancellationToken);", StringComparison.Ordinal);
+ Assert.True(loopEnd > 0, "the per-database loop is followed by the per-database state prune");
+ var assignment = runner.IndexOf("collectionNote = EnumeratedCollectorDriver.MergeNotes(", loopEnd, StringComparison.Ordinal);
+ Assert.True(assignment > loopEnd, "the run note is assigned after the per-database loop");
+
+ var statement = runner[assignment..runner.IndexOf(';', assignment)];
+ Assert.Contains("emptyListNote", statement, StringComparison.Ordinal);
+ }
+
+ private static string ReadLf(params string[] relativePath)
+ {
+ var path = Path.Combine(relativePath);
+ var dir = AppContext.BaseDirectory;
+ while (dir != null && !File.Exists(Path.Combine(dir, path)))
+ {
+ dir = Path.GetDirectoryName(dir);
+ }
+
+ Assert.NotNull(dir);
+ return File.ReadAllText(Path.Combine(dir!, path)).Replace("\r\n", "\n");
+ }
+}
diff --git a/Darling/Darling.Tests/InstallIdRungTests.cs b/Darling/Darling.Tests/InstallIdRungTests.cs
new file mode 100644
index 0000000000..aac29ad835
--- /dev/null
+++ b/Darling/Darling.Tests/InstallIdRungTests.cs
@@ -0,0 +1,134 @@
+/*
+ * 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.Reflection;
+using System.Text.RegularExpressions;
+using PerformanceMonitor.Darling.Storage;
+using PerformanceMonitor.Darling.Viewer;
+using Xunit;
+
+namespace Darling.Tests;
+
+///
+/// Pins the Darling rung that holds the install id (#4961): one table, one row, made by the service at start and
+/// read by everything else. The id is the eight characters that tell this install's Extended Events sessions from
+/// another install's on a server both monitor, so the row carries the id and the binding that says which store it
+/// was made for (the cluster's system_identifier and the store database's OID). The rung is DDL only: the
+/// service inserts the row, so a store that has never started this build holds an empty table and the reads that
+/// need an id say so.
+///
+/// Every fact finds the rung by NAME, so a renumber (another rung landing first) is one edit to the
+/// registration, the StorageVersion constant and the viewer's return.
+///
+public sealed class InstallIdRungTests
+{
+ /// The rung's registered name - how every test here (and the live reset helper) finds it.
+ public const string RungName = "install-id";
+
+ /// The probe's newest sentinel, so the last argument; the ordinal is a fact of the probe's shape, not
+ /// of the rung's number, so a renumber leaves it alone.
+ private const int ProbeOrdinal = 133;
+
+ private static PgMigrations.Migration Rung => PgMigrations.Scripts.Single(m => m.Name == RungName);
+
+ /// The rung's version, read off the migration registered under .
+ public static int RungVersion => Rung.Version;
+
+ [Fact]
+ public void TheRungIsRegisteredAtTheTopOfADenseLadder()
+ {
+ var versions = PgMigrations.Scripts.Select(s => s.Version).ToList();
+
+ Assert.Equal(StorageVersion.SchemaVersion, PgMigrations.Scripts[^1].Version);
+ Assert.Equal(PgMigrations.Scripts[^1].Version, Rung.Version);
+ Assert.Equal(StorageVersion.SchemaVersion, versions.Max());
+ Assert.Equal(versions.Distinct().OrderBy(v => v), versions);
+ Assert.Contains(Rung.Version - 1, versions);
+ }
+
+ ///
+ /// ONE table in the config schema, shaped like its neighbours: a single row pinned to id = 1, the id
+ /// under a CHECK for exactly eight lowercase hex digits, and the two binding columns: the database's OID NOT NULL
+ /// (every login can read it) and the cluster's identifier nullable (a managed or hardened server may refuse a login
+ /// the call that reads it, and the id is then bound to the database alone). No row is inserted here (the service
+ /// makes it) and nothing else is touched.
+ ///
+ [Fact]
+ public void TheRungCreatesOneSingleRowTable_WithTheIdFormatCheckAndTheBinding_AndNothingElse()
+ {
+ var sql = Rung.Sql.Replace("\r\n", "\n", StringComparison.Ordinal);
+ var body = Regex.Replace(sql, @"/\*.*?\*/", string.Empty, RegexOptions.Singleline);
+
+ Assert.Contains("CREATE TABLE IF NOT EXISTS config.config_install_id", body, StringComparison.Ordinal);
+ Assert.Single(Regex.Matches(body, "CREATE TABLE"));
+ Assert.Contains("id smallint NOT NULL PRIMARY KEY DEFAULT 1 CHECK (id = 1)", body, StringComparison.Ordinal);
+ Assert.Contains("install_id text NOT NULL", body, StringComparison.Ordinal);
+ Assert.Contains("CONSTRAINT ck_config_install_id_format CHECK (install_id ~ '^[0-9a-f]{8}$')", body, StringComparison.Ordinal);
+ Assert.Contains("system_identifier bigint,", body, StringComparison.Ordinal);
+ Assert.DoesNotContain("system_identifier bigint NOT NULL", body, StringComparison.Ordinal);
+ Assert.Contains("database_oid bigint NOT NULL", body, StringComparison.Ordinal);
+ Assert.Contains("created_at timestamp NOT NULL DEFAULT (now() AT TIME ZONE 'UTC')", body, StringComparison.Ordinal);
+
+ foreach (var absent in new[] { "INSERT ", "UPDATE ", "DELETE ", "DROP ", "ALTER ", "CREATE INDEX", "TRIGGER", "VIEW" })
+ {
+ Assert.DoesNotContain(absent, body, StringComparison.Ordinal);
+ }
+ }
+
+ ///
+ /// The viewer probe's sentinel carries this rung and the map treats it as the TOP arm: a missing top arm maps a
+ /// fully-migrated store one rung short, permanently, because
+ /// is .
+ ///
+ [Fact]
+ public void TheProbeCarriesTheTableAsItsLastArm_AndMapsFullyMigratedToThisTopRung()
+ {
+ var probe = ViewerDataService.StoreSchemaProbeSql.Replace("\r\n", "\n", StringComparison.Ordinal);
+ var arm = "table_schema = 'config' AND table_name = 'config_install_id'";
+ Assert.Contains(arm, probe, StringComparison.Ordinal);
+ Assert.True(probe.LastIndexOf("EXISTS", StringComparison.Ordinal) < probe.IndexOf(arm, StringComparison.Ordinal),
+ "the new arm is the probe's last EXISTS, so it reads at the next ordinal");
+
+ var viewer = RepoFile.ReadRepoFile("Darling", "PerformanceMonitor.Darling.Viewer", "ViewerDataService.cs");
+ Assert.Contains($"reader.GetBoolean({ProbeOrdinal})", viewer, StringComparison.Ordinal);
+ Assert.DoesNotContain($"reader.GetBoolean({ProbeOrdinal + 1})", viewer, StringComparison.Ordinal);
+
+ Assert.Equal(StorageVersion.SchemaVersion, ViewerDataService.RequiredStoreSchemaVersion);
+
+ var method = typeof(ViewerDataService).GetMethod("MapProbedSchemaVersion", BindingFlags.NonPublic | BindingFlags.Static)!;
+ var arity = method.GetParameters().Length;
+ Assert.Equal(ProbeOrdinal, arity - 1);
+ Assert.Equal("hasInstallId", method.GetParameters()[ProbeOrdinal].Name);
+
+ var all = Enumerable.Repeat((object)true, arity).ToArray();
+ Assert.Equal(RungVersion, (int)method.Invoke(null, all)!);
+
+ var behind = (object[])all.Clone();
+ behind[ProbeOrdinal] = false;
+ Assert.Equal(RungVersion - 1, (int)method.Invoke(null, behind)!);
+
+ var thisArm = viewer.IndexOf("if (hasInstallId)", StringComparison.Ordinal);
+ var previousArm = viewer.IndexOf("if (hasMuteRuleServerId)", StringComparison.Ordinal);
+ Assert.True(thisArm >= 0, "the viewer has no sentinel arm for this rung - a fully-migrated store would map one rung short");
+ Assert.True(previousArm >= 0, "the previous rung's arm is gone, so this pin is comparing against nothing");
+ Assert.True(thisArm < previousArm, "this rung's arm sits below the previous rung's, so a current store maps one rung short");
+ Assert.Contains("return " + RungVersion + ";", viewer[thisArm..previousArm], StringComparison.Ordinal);
+
+ /* The V71 finding: the table is named in the probe line and nowhere in the arm's prose - the coverage ratchet
+ strips information_schema lines but cannot strip a comment. The comment block sits ABOVE the `if`. */
+ var armProseStart = viewer.LastIndexOf("/* V", thisArm, StringComparison.Ordinal);
+ Assert.True(armProseStart >= 0, "the arm has no comment block saying why it exists");
+ var prose = viewer[armProseStart..thisArm];
+ foreach (var name in new[] { "config_install_id", "install_id", "system_identifier" })
+ {
+ Assert.DoesNotContain(name, prose, StringComparison.Ordinal);
+ }
+ }
+}
diff --git a/Darling/Darling.Tests/LegacyLongQueryRecordLiveTests.cs b/Darling/Darling.Tests/LegacyLongQueryRecordLiveTests.cs
new file mode 100644
index 0000000000..021af09fb0
--- /dev/null
+++ b/Darling/Darling.Tests/LegacyLongQueryRecordLiveTests.cs
@@ -0,0 +1,218 @@
+/*
+ * 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.Globalization;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Npgsql;
+using PerformanceMonitor.Collectors;
+using PerformanceMonitor.Darling.Service;
+using PerformanceMonitor.Darling.Storage;
+using Xunit;
+
+namespace Darling.Tests;
+
+///
+/// The record of the one-time legacy long-query session drop, against a real store: the record one runner writes is read
+/// back by a new runner (a service restart) and again after a reconnect, and neither drops the session a second time.
+/// The drop itself is replaced on the runner, so no monitored server is needed; the record goes through the real
+/// store-backed implementation (). The record belongs to one server: another server's
+/// record in the same database name does not stop a server's drop, and a server's own record stops its second one.
+///
+[Collection("live-postgres")]
+public sealed class LegacyLongQueryRecordLiveTests
+{
+ /* #1776 own-store: the rows are keyed by distinctive fake server ids and removed in the cleanup. The two-server test keeps
+ its own pair, so neither test's cleanup touches the other's rows. */
+ private const int LiveServerId = -496100;
+ private const int FirstServerId = -496110;
+ private const int SecondServerId = -496111;
+
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public async Task TheRecord_WrittenByOneRunner_IsReadBackByANewRunnerAndAfterAReconnect_AndNeitherDropsAgain(bool azureSqlDatabase)
+ {
+ var connectionString = Environment.GetEnvironmentVariable("DARLING_TEST_PG");
+ Assert.SkipWhen(string.IsNullOrEmpty(connectionString),
+ "Set DARLING_TEST_PG to a Postgres connection string to run the live legacy long-query record test.");
+
+ var ct = TestContext.Current.CancellationToken;
+ using var connection = new NpgsqlConnection(connectionString);
+ await connection.OpenAsync(ct);
+ await PgMigrations.MigrateAsync(connection, ct);
+ await DeleteAsync(connection, ct);
+
+ await using var postgres = NpgsqlDataSource.Create(connectionString!);
+ var databases = azureSqlDatabase ? new[] { "alpha", "beta" } : new[] { string.Empty };
+ var legacyDrops = new List();
+
+ (DarlingCollectorRunner Runner, DarlingWorker.ServerLoopState State) Build() =>
+ BuildInstall(postgres, LiveServerId, "legacy-live", azureSqlDatabase, (_, database) => legacyDrops.Add(database));
+
+ var bodySucceeded = false;
+ try
+ {
+ /* The first install drops the session once in each database, and records it in the store. */
+ var first = Build();
+ await ReconcileAsync(first);
+ Assert.Equal(databases, legacyDrops);
+
+ var stored = await first.Runner.GetCollectorStateAsync(LiveServerId, LegacyLongQuerySession.StateCollector, ct);
+ Assert.Equal(databases.Select(LegacyLongQuerySession.StateKey).OrderBy(k => k, StringComparer.Ordinal), stored.Keys.OrderBy(k => k, StringComparer.Ordinal));
+ Assert.All(stored.Values, value => Assert.True(DateTime.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out _), value));
+
+ /* A service restart: a new runner reads the record back, and drops nothing. */
+ var restarted = Build();
+ await ReconcileAsync(restarted);
+ Assert.Equal(databases, legacyDrops);
+
+ /* A reconnect: the record is read again, and nothing is dropped. */
+ restarted.Runner.OnServerReconnected(LiveServerId);
+ restarted.State.LongQueryTraceApplied = null;
+ restarted.State.LongQueryTraceAppliedKey = null;
+ restarted.State.LongQueryTraceAppliedAtUtc = null;
+ await ReconcileAsync(restarted);
+ Assert.Equal(databases, legacyDrops);
+
+ bodySucceeded = true;
+ }
+ finally
+ {
+ await LiveStoreCleanup.RunAsync(connectionString!, bodySucceeded, async (cleanup, cleanupCt) =>
+ await DeleteAsync(cleanup, cleanupCt));
+ }
+ }
+
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public async Task TheRecordOfOneServer_DoesNotStopAnotherServersDropInTheSameDatabaseName_AndEachServersOwnRecordStopsItsSecondDrop(bool azureSqlDatabase)
+ {
+ var connectionString = Environment.GetEnvironmentVariable("DARLING_TEST_PG");
+ Assert.SkipWhen(string.IsNullOrEmpty(connectionString),
+ "Set DARLING_TEST_PG to a Postgres connection string to run the live legacy long-query record test.");
+
+ var ct = TestContext.Current.CancellationToken;
+ using var connection = new NpgsqlConnection(connectionString);
+ await connection.OpenAsync(ct);
+ await PgMigrations.MigrateAsync(connection, ct);
+ await DeleteServersAsync(connection, ct);
+
+ await using var postgres = NpgsqlDataSource.Create(connectionString!);
+ var databases = azureSqlDatabase ? new[] { "alpha", "beta" } : new[] { string.Empty };
+ var expectedKeys = databases.Select(LegacyLongQuerySession.StateKey).OrderBy(k => k, StringComparer.Ordinal).ToList();
+ var legacyDrops = new List<(int ServerId, string Database)>();
+
+ (DarlingCollectorRunner Runner, DarlingWorker.ServerLoopState State) BuildFirst() =>
+ BuildInstall(postgres, FirstServerId, "legacy-live-first", azureSqlDatabase, (id, database) => legacyDrops.Add((id, database)));
+
+ (DarlingCollectorRunner Runner, DarlingWorker.ServerLoopState State) BuildSecond() =>
+ BuildInstall(postgres, SecondServerId, "legacy-live-second", azureSqlDatabase, (id, database) => legacyDrops.Add((id, database)));
+
+ List DropsOf(int serverId) => legacyDrops.Where(drop => drop.ServerId == serverId).Select(drop => drop.Database).ToList();
+
+ var bodySucceeded = false;
+ try
+ {
+ /* The first server drops the session once in each database, and records it under its own server id. */
+ await ReconcileAsync(BuildFirst());
+ Assert.Equal(databases, DropsOf(FirstServerId));
+
+ /* The second server has the same database names. A new runner has read nothing, so the store is all that could
+ tell it the session was dropped, and the first server's record is not its own: it still drops in each one. */
+ var second = BuildSecond();
+ await ReconcileAsync(second);
+ Assert.Equal(databases, DropsOf(SecondServerId));
+ Assert.Equal(databases, DropsOf(FirstServerId));
+
+ /* Each server holds exactly its own record. */
+ foreach (var serverId in new[] { FirstServerId, SecondServerId })
+ {
+ var stored = await second.Runner.GetCollectorStateAsync(serverId, LegacyLongQuerySession.StateCollector, ct);
+ Assert.Equal(expectedKeys, stored.Keys.OrderBy(k => k, StringComparer.Ordinal).ToList());
+ }
+
+ /* A service restart: each server's own record, read back from the store, stops its second drop. */
+ await ReconcileAsync(BuildSecond());
+ await ReconcileAsync(BuildFirst());
+ Assert.Equal(databases, DropsOf(SecondServerId));
+ Assert.Equal(databases, DropsOf(FirstServerId));
+
+ bodySucceeded = true;
+ }
+ finally
+ {
+ await LiveStoreCleanup.RunAsync(connectionString!, bodySucceeded, async (cleanup, cleanupCt) =>
+ await DeleteServersAsync(cleanup, cleanupCt));
+ }
+ }
+
+ ///
+ /// One server's runner and loop state over the real store. The drop of the legacy session is replaced by
+ /// , called with the server id and the database named, so no monitored server is needed;
+ /// the record goes through the store-backed implementation. Each call is a new process: it has read nothing from the
+ /// store yet.
+ ///
+ private static (DarlingCollectorRunner Runner, DarlingWorker.ServerLoopState State) BuildInstall(
+ NpgsqlDataSource postgres, int serverId, string name, bool azureSqlDatabase, Action onLegacyDrop)
+ {
+ var config = new MonitoredServer { Name = name, Host = name + ".database.windows.net" };
+ var runtime = new ServerRuntime
+ {
+ Config = config,
+ ConnectionString = "Server=tcp:" + name + ".database.windows.net,1433;Initial Catalog=master;Encrypt=True",
+ Target = new CollectorTargetInfo { IsAzureSqlDb = azureSqlDatabase },
+ StorageName = name,
+ ServerId = serverId,
+ };
+ var runner = new DarlingCollectorRunner(
+ postgres,
+ new CollectorDeltaCalculator(),
+ databaseScope: (_, _) => new List(),
+ separatelyMonitoredDatabases: _ => new List(),
+ installId: () => "0a1b2c3d");
+ runner.LongQueryTraceListOverrideForTests = (_, _, _, _) => Task.FromResult(new List { "master", "alpha", "beta" });
+ runner.LongQueryTraceDatabaseOverrideForTests = (server, database, _, sessionName, _) =>
+ {
+ if (sessionName == LongQueryCompletionsCollector.LegacyXeSessionName)
+ {
+ onLegacyDrop(server.ServerId, database);
+ }
+
+ return Task.CompletedTask;
+ };
+
+ return (runner, new DarlingWorker.ServerLoopState { Config = config, Runtime = runtime });
+ }
+
+ private static Task ReconcileAsync((DarlingCollectorRunner Runner, DarlingWorker.ServerLoopState State) install) =>
+ DarlingWorker.ReconcileLongQueryTraceAsync(
+ install.State, install.Runner, enabled: true, Array.Empty(), Array.Empty(),
+ DateTime.UtcNow, new DarlingSelfAlertTests.CapturingLogger(), CancellationToken.None);
+
+ private static async Task DeleteServersAsync(NpgsqlConnection connection, CancellationToken ct)
+ {
+ using var command = new NpgsqlCommand(
+ "DELETE FROM collect.collector_state WHERE server_id IN ("
+ + FirstServerId.ToString(CultureInfo.InvariantCulture) + ", " + SecondServerId.ToString(CultureInfo.InvariantCulture) + ")", connection);
+ command.CommandTimeout = 30;
+ await command.ExecuteNonQueryAsync(ct);
+ }
+
+ private static async Task DeleteAsync(NpgsqlConnection connection, CancellationToken ct)
+ {
+ using var command = new NpgsqlCommand(
+ "DELETE FROM collect.collector_state WHERE server_id = " + LiveServerId.ToString(CultureInfo.InvariantCulture), connection);
+ command.CommandTimeout = 30;
+ await command.ExecuteNonQueryAsync(ct);
+ }
+}
diff --git a/Darling/Darling.Tests/LongQueryBannerSessionScopeTests.cs b/Darling/Darling.Tests/LongQueryBannerSessionScopeTests.cs
index 8a193efad6..7f191831d7 100644
--- a/Darling/Darling.Tests/LongQueryBannerSessionScopeTests.cs
+++ b/Darling/Darling.Tests/LongQueryBannerSessionScopeTests.cs
@@ -39,13 +39,25 @@ public void TheViewerBanner_OnAzureSqlDatabase_SaysEachMonitoredDatabase_NotThis
var text = ViewerServerTab.LongQueriesDisabledText(CollectorEngineCapability.AzureSqlDatabaseEngineEdition);
Assert.EndsWith(
- "Enabling it creates the Extended Events session in each monitored database. Disabling it drops the session from every database that has it, except where another registration of that database still has the trace on. A database that is also monitored as its own server follows that server's setting.",
+ "Enabling it creates this install's Extended Events session in each monitored database. Disabling it drops that session from every database that has it, except where another registration of that database still has the trace on. A database that is also monitored as its own server follows that server's setting.",
text,
StringComparison.Ordinal);
Assert.DoesNotContain("session on this server", text, StringComparison.Ordinal);
Assert.Contains("Settings → Collection Schedule → Edit Collector Schedules…", text, StringComparison.Ordinal);
}
+ [Theory]
+ [InlineData(CollectorEngineCapability.AzureSqlDatabaseEngineEdition)]
+ [InlineData(CollectorEngineCapability.UnknownEngineEdition)]
+ [InlineData(2)]
+ public void TheViewerBanner_NamesTheSessionAsThisInstalls_NotAsOneSessionEveryInstallShares(int engineEdition)
+ {
+ var text = ViewerServerTab.LongQueriesDisabledText(engineEdition);
+
+ Assert.Contains("this install's Extended Events session", text, StringComparison.Ordinal);
+ Assert.DoesNotContain("PerformanceMonitor_LongQueryCompletions", text, StringComparison.Ordinal);
+ }
+
[Theory]
[InlineData(CollectorEngineCapability.UnknownEngineEdition)]
[InlineData(2)]
@@ -57,7 +69,7 @@ public void TheViewerBanner_OnEveryOtherEdition_KeepsTheServerSentence(int engin
var text = ViewerServerTab.LongQueriesDisabledText(engineEdition);
Assert.EndsWith(
- "Enabling it creates the Extended Events session on this server. Disabling it drops the session.",
+ "Enabling it creates this install's Extended Events session on this server. Disabling it drops that session, unless another registration of this install on the same instance still has the trace on.",
text,
StringComparison.Ordinal);
Assert.DoesNotContain("monitored database", text, StringComparison.Ordinal);
diff --git a/Darling/Darling.Tests/LongQueryTraceInstanceGuardTests.cs b/Darling/Darling.Tests/LongQueryTraceInstanceGuardTests.cs
new file mode 100644
index 0000000000..531db7d86e
--- /dev/null
+++ b/Darling/Darling.Tests/LongQueryTraceInstanceGuardTests.cs
@@ -0,0 +1,317 @@
+/*
+ * 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.Collections.Generic;
+using System.Linq;
+using PerformanceMonitor.Collectors;
+using PerformanceMonitor.Darling.Service;
+using Xunit;
+
+namespace Darling.Tests;
+
+///
+/// #4961: the on-premises counterpart of the Azure co-owner rule. Where the session is the server's own, two registrations
+/// of one install can reach the same instance under different host names, so the instance's last-known
+/// @@SERVERNAME decides whether one registration's drop would stop the trace another one keeps. These pin the pure
+/// decision both apps share, and the read of the name from a carrier's persisted identity row.
+///
+public sealed class LongQueryTraceInstanceGuardTests
+{
+ private static LongQueryTraceInstance Other(string? name, bool enabled = true, bool traceOn = true) => new(enabled, traceOn, name);
+
+ [Fact]
+ public void AnotherRegistrationOfTheSameInstance_WithItsTraceOn_KeepsTheSession()
+ {
+ Assert.True(LongQueryTraceDatabases.KeptOnInstance("SQL01", new[] { Other("SQL01") }));
+ }
+
+ [Fact]
+ public void TheNamesCompareIgnoringCaseAndPadding()
+ {
+ Assert.True(LongQueryTraceDatabases.KeptOnInstance(" sql01 ", new[] { Other("SQL01 ") }));
+ }
+
+ [Theory]
+ [InlineData(false, true)]
+ [InlineData(true, false)]
+ public void ARegistrationThatIsNotMonitoredOrHasItsTraceOff_KeepsNothing(bool enabled, bool traceOn)
+ {
+ Assert.False(LongQueryTraceDatabases.KeptOnInstance("SQL01", new[] { Other("SQL01", enabled, traceOn) }));
+ }
+
+ [Fact]
+ public void ARegistrationOfAnotherInstance_KeepsNothing()
+ {
+ Assert.False(LongQueryTraceDatabases.KeptOnInstance("SQL01", new[] { Other("SQL02") }));
+ }
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ [InlineData(" ")]
+ public void AnUnknownName_MatchesNothing_OnEitherSide(string? unknown)
+ {
+ Assert.False(LongQueryTraceDatabases.KeptOnInstance(unknown, new[] { Other("SQL01") }));
+ Assert.False(LongQueryTraceDatabases.KeptOnInstance("SQL01", new[] { Other(unknown) }));
+ Assert.False(LongQueryTraceDatabases.KeptOnInstance(unknown, new[] { Other(unknown) }));
+ }
+
+ [Fact]
+ public void NoOtherRegistration_KeepsNothing()
+ {
+ Assert.False(LongQueryTraceDatabases.KeptOnInstance("SQL01", new List()));
+ }
+
+ /* A removed server is checked once and never again, so its session stays whenever the match cannot be ruled out: its own
+ name or the name of a registration that keeps the trace on is not known. The trace-off drop asks Kept instead, and an
+ unknown name there matches nothing. */
+
+ [Fact]
+ public void TheRemoval_DropsTheSession_WhenNoOtherRegistrationCouldKeepIt_WhateverTheNames()
+ {
+ Assert.Null(LongQueryTraceInstanceGuard.NoKeepers.RemovalSkipReason());
+ Assert.Null(new LongQueryTraceInstanceGuard(null, new[] { Other(null, enabled: false), Other(null, traceOn: false) }).RemovalSkipReason());
+ Assert.Null(new LongQueryTraceInstanceGuard("SQL01", new[] { Other(null, enabled: false), Other(null, traceOn: false) }).RemovalSkipReason());
+ }
+
+ [Fact]
+ public void TheRemoval_LeavesTheSession_WhenItsOwnNameIsNotKnown_AndAnotherRegistrationKeepsTheTraceOn()
+ {
+ var guard = new LongQueryTraceInstanceGuard(null, new[] { Other("SQL01") });
+
+ Assert.Contains("this server's instance name is not known", guard.RemovalSkipReason(), System.StringComparison.Ordinal);
+ Assert.False(guard.Kept);
+ }
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ [InlineData(" ")]
+ public void TheRemoval_LeavesTheSession_WhenAKeepersNameIsNotKnown_BecauseItCannotBeRuledOut(string? unknown)
+ {
+ var guard = new LongQueryTraceInstanceGuard("SQL01", new[] { Other(unknown) });
+
+ Assert.Contains("another registration", guard.RemovalSkipReason(), System.StringComparison.Ordinal);
+ Assert.Contains("not known", guard.RemovalSkipReason(), System.StringComparison.Ordinal);
+
+ /* The trace-off drop is not the removal: a name that is not known matches nothing there, and it drops as it always did. */
+ Assert.False(guard.Kept);
+ }
+
+ [Fact]
+ public void TheRemoval_LeavesTheSession_WhenOneKeeperIsAnotherInstance_AndAnotherKeepersNameIsNotKnown()
+ {
+ var guard = new LongQueryTraceInstanceGuard("SQL01", new[] { Other("SQL02"), Other(null) });
+
+ Assert.NotNull(guard.RemovalSkipReason());
+ }
+
+ [Fact]
+ public void TheRemoval_IgnoresTheUnknownNameOfARegistrationThatCouldNotKeepTheSession()
+ {
+ var guard = new LongQueryTraceInstanceGuard("SQL01", new[] { Other("SQL02"), Other(null, enabled: false), Other(null, traceOn: false) });
+
+ Assert.Null(guard.RemovalSkipReason());
+ }
+
+ [Fact]
+ public void TheRemoval_DropsTheSession_WhenEveryKeepersKnownNameIsAnotherInstance()
+ {
+ Assert.Null(new LongQueryTraceInstanceGuard("SQL01", new[] { Other("SQL02"), Other("SQL03") }).RemovalSkipReason());
+ }
+
+ [Fact]
+ public void TheRemoval_SaysSo_WhenAnotherRegistrationKeepsTheSessionOnTheSameInstance()
+ {
+ var guard = new LongQueryTraceInstanceGuard("sql01", new[] { Other("SQL02"), Other(" SQL01 ") });
+
+ Assert.Contains("keeps it on the same instance", guard.RemovalSkipReason(), System.StringComparison.Ordinal);
+ Assert.True(guard.Kept);
+ }
+
+ [Fact]
+ public void ThePersistedIdentityRow_GivesItsName()
+ {
+ var stamp = new ServerEpoch.Stamp(new System.DateTime(2026, 10, 2, 8, 0, 0, System.DateTimeKind.Utc), "SQL01");
+ var state = new Dictionary { [ServerEpoch.IdentityStateKey] = ServerEpoch.Serialize(stamp) };
+
+ Assert.Equal("SQL01", ServerEpoch.LastKnownName(state));
+ }
+
+ [Fact]
+ public void AnIdentityRowWithNoName_AndNoRow_GiveNull()
+ {
+ var noName = new Dictionary
+ {
+ [ServerEpoch.IdentityStateKey] = ServerEpoch.Serialize(new ServerEpoch.Stamp(new System.DateTime(2026, 10, 2, 8, 0, 0, System.DateTimeKind.Utc), null)),
+ };
+
+ Assert.Null(ServerEpoch.LastKnownName(noName));
+ Assert.Null(ServerEpoch.LastKnownName(new Dictionary()));
+ Assert.Null(ServerEpoch.LastKnownName(new Dictionary { [ServerEpoch.IdentityStateKey] = "not an identity" }));
+ }
+
+ [Fact]
+ public void TheCarriers_AreTheCollectorsThatPersistTheIdentity()
+ {
+ Assert.Equal(new[] { WaitStatsCollector.Instance.Name, CpuUtilizationCollector.Instance.Name }, ServerEpoch.IdentityCarrierCollectors);
+ }
+
+ /* ── The guard the worker builds for one registration (#4961) ── */
+
+ private const int OwnId = 4944;
+
+ private static MonitoredServer Registration(int id, string engine = "sqlserver") =>
+ new() { Name = "registration-" + id, Host = "host-" + id, Engine = engine, StoredServerId = id };
+
+ ///
+ /// The persisted identity rows of a few registrations, read the way the store's reader gives them: one carrier's state at
+ /// a time. Every read is counted, with the registration it was for.
+ ///
+ private sealed class Names
+ {
+ private readonly Dictionary<(int Id, string Carrier), string> _held = new();
+
+ public List Reads { get; } = new();
+
+ public Names Hold(int id, string name, string? carrier = null)
+ {
+ _held[(id, carrier ?? ServerEpoch.IdentityCarrierCollectors[0])] = name;
+ return this;
+ }
+
+ public System.Threading.Tasks.Task> ReadAsync(int id, string carrier)
+ {
+ Reads.Add(id);
+ var state = new Dictionary();
+ if (_held.TryGetValue((id, carrier), out var name))
+ {
+ state[ServerEpoch.IdentityStateKey] = ServerEpoch.Serialize(
+ new ServerEpoch.Stamp(new System.DateTime(2026, 10, 2, 8, 0, 0, System.DateTimeKind.Utc), name));
+ }
+
+ return System.Threading.Tasks.Task.FromResult(state);
+ }
+ }
+
+ private static System.Threading.Tasks.Task GuardFor(IReadOnlyList? live, Names names, int[]? traceOff = null) =>
+ DarlingWorker.LongQueryTraceInstanceGuardFor(OwnId, live, id => traceOff is null || !traceOff.Contains(id), names.ReadAsync);
+
+ /// A SQL Server registration whose trace is on, on the instance this registration's own name says, keeps the session, and both names were read.
+ [Fact]
+ public async System.Threading.Tasks.Task TheGuard_ASqlServerRegistrationWithItsTraceOn_IsAKeeper_WhenBothNamesAreKnown()
+ {
+ var names = new Names().Hold(OwnId, "SQL01").Hold(101, "sql01");
+
+ var guard = await GuardFor(new[] { Registration(101) }, names);
+
+ Assert.Equal("SQL01", guard.ServerName);
+ var keeper = Assert.Single(guard.Keepers);
+ Assert.Equal(new LongQueryTraceInstance(Enabled: true, TraceOn: true, "sql01"), keeper);
+ Assert.True(guard.Kept);
+ Assert.Contains(101, names.Reads);
+ }
+
+ /// A PostgreSQL registration holds no Extended Events session: it is not a keeper, even with its trace on, and its state is never read.
+ [Fact]
+ public async System.Threading.Tasks.Task TheGuard_APostgresRegistration_IsNotAKeeper_AndNothingIsRead()
+ {
+ var names = new Names().Hold(OwnId, "SQL01").Hold(101, "SQL01");
+
+ var guard = await GuardFor(new[] { Registration(101, engine: "postgres") }, names);
+
+ Assert.Empty(guard.Keepers);
+ Assert.False(guard.Kept);
+ Assert.Empty(names.Reads);
+ }
+
+ /// A registration whose effective trace setting is off is not a keeper, whatever name it last reported.
+ [Fact]
+ public async System.Threading.Tasks.Task TheGuard_ARegistrationWithItsTraceOff_IsNotAKeeper_AndNothingIsRead()
+ {
+ var names = new Names().Hold(OwnId, "SQL01").Hold(101, "SQL01");
+
+ var guard = await GuardFor(new[] { Registration(101) }, names, traceOff: new[] { 101 });
+
+ Assert.Empty(guard.Keepers);
+ Assert.False(guard.Kept);
+ Assert.Empty(names.Reads);
+ }
+
+ ///
+ /// The live registry holds only the monitored servers, so a registration that is disabled is not in it and keeps nothing.
+ /// With no registry published yet, or only this registration in it, there is no other registration to keep the session.
+ ///
+ [Fact]
+ public async System.Threading.Tasks.Task TheGuard_WithNoOtherRegistrationInTheRegistry_HasNoKeepers_AndReadsNothing()
+ {
+ var names = new Names().Hold(OwnId, "SQL01");
+
+ Assert.Same(LongQueryTraceInstanceGuard.NoKeepers, await GuardFor(null, names));
+ Assert.Same(LongQueryTraceInstanceGuard.NoKeepers, await GuardFor(System.Array.Empty(), names));
+ Assert.Same(LongQueryTraceInstanceGuard.NoKeepers, await GuardFor(new[] { Registration(OwnId) }, names));
+ Assert.Empty(names.Reads);
+ }
+
+ /// With no name of its own, this registration matches nothing, so the names of the others are not read.
+ [Fact]
+ public async System.Threading.Tasks.Task TheGuard_WithNoNameOfItsOwn_ReadsNoKeepersName_AndMatchesNothing()
+ {
+ var names = new Names().Hold(101, "SQL01");
+
+ var guard = await GuardFor(new[] { Registration(101) }, names);
+
+ Assert.Null(guard.ServerName);
+ Assert.False(guard.Kept);
+ Assert.All(names.Reads, id => Assert.Equal(OwnId, id));
+ Assert.NotEmpty(names.Reads);
+
+ /* The keeper is still listed, with no name, for a caller that must rule it out. */
+ Assert.Equal(new LongQueryTraceInstance(Enabled: true, TraceOn: true, ServerName: null), Assert.Single(guard.Keepers));
+ }
+
+ /// A keeper that has reported no name yet is not a match, and a keeper of another instance is not one either.
+ [Fact]
+ public async System.Threading.Tasks.Task TheGuard_AKeeperWithNoNameYet_OrAnotherInstance_IsNotAMatch()
+ {
+ var names = new Names().Hold(OwnId, "SQL01").Hold(102, "SQL02");
+
+ var guard = await GuardFor(new[] { Registration(101), Registration(102) }, names);
+
+ Assert.Equal(2, guard.Keepers.Count);
+ Assert.False(guard.Kept);
+ Assert.Null(guard.Keepers[0].ServerName);
+ Assert.Equal("SQL02", guard.Keepers[1].ServerName);
+ }
+
+ /// The name comes from the first carrier that holds one, so a name only the second carrier holds still matches.
+ [Fact]
+ public async System.Threading.Tasks.Task TheGuard_ANameOnlyTheSecondCarrierHolds_StillMatches()
+ {
+ var second = ServerEpoch.IdentityCarrierCollectors[1];
+ var names = new Names().Hold(OwnId, "SQL01", second).Hold(101, "SQL01", second);
+
+ var guard = await GuardFor(new[] { Registration(101) }, names);
+
+ Assert.True(guard.Kept);
+ }
+
+ /// Only the registrations whose trace is on are candidates: the others' names are never read.
+ [Fact]
+ public async System.Threading.Tasks.Task TheGuard_ReadsOnlyTheNamesOfTheRegistrationsThatCouldKeepTheSession()
+ {
+ var names = new Names().Hold(OwnId, "SQL01").Hold(101, "SQL01").Hold(102, "SQL01").Hold(103, "SQL01");
+
+ var guard = await GuardFor(
+ new[] { Registration(101), Registration(102, engine: "postgres"), Registration(103) }, names, traceOff: new[] { 103 });
+
+ Assert.Single(guard.Keepers);
+ Assert.DoesNotContain(102, names.Reads);
+ Assert.DoesNotContain(103, names.Reads);
+ }
+}
diff --git a/Darling/Darling.Tests/LongQueryTraceLifecycleTests.cs b/Darling/Darling.Tests/LongQueryTraceLifecycleTests.cs
index b2892e966e..9f66c3864c 100644
--- a/Darling/Darling.Tests/LongQueryTraceLifecycleTests.cs
+++ b/Darling/Darling.Tests/LongQueryTraceLifecycleTests.cs
@@ -16,6 +16,7 @@
using PerformanceMonitor.Darling.Service;
using Xunit;
using static Darling.Tests.RepoFile;
+using LogLevel = Microsoft.Extensions.Logging.LogLevel;
namespace Darling.Tests;
@@ -38,6 +39,9 @@ private sealed class Rig
{
public required DarlingCollectorRunner Runner { get; init; }
public required DarlingWorker.ServerLoopState State { get; init; }
+
+ /* Every session name the long-query work named: the name a create or a drop would have put in its statement (#4961). */
+ public List Names { get; } = new();
public required MonitoredServer Config { get; init; }
public DarlingSelfAlertTests.CapturingLogger Logger { get; } = new();
@@ -59,12 +63,84 @@ logical server's registration sees them. */
public DateTime Clock { get; set; } = new(2026, 10, 2, 12, 0, 0, DateTimeKind.Utc);
public HashSet Sessions { get; } = new(StringComparer.OrdinalIgnoreCase);
+ /* #4961: the guard an on-premises server's reconcile resolves, and how many times it did. A test that sets none passes none,
+ as the sweep does for an Azure SQL Database target. */
+ public Func>? InstanceGuard { get; set; }
+ public int GuardResolutions { get; set; }
+
public Task ReconcileAsync(bool enabled) =>
- DarlingWorker.ReconcileLongQueryTraceAsync(State, Runner, enabled, Others, ServerOwned, Clock, Logger, CancellationToken.None);
+ DarlingWorker.ReconcileLongQueryTraceAsync(State, Runner, enabled, Others, ServerOwned, Clock, Logger, CancellationToken.None, InstanceGuard);
public IEnumerable Dropped => Calls.Where(c => !c.Create).Select(c => c.Database);
public IEnumerable Created => Calls.Where(c => c.Create).Select(c => c.Database);
+
+ /* #4961: the legacy session's drops, kept apart from the per-install work above. Each entry is the database the
+ drop ran in; the server's own session is the empty name. */
+ public List LegacyCalls { get; } = new();
+ public List LegacyNames { get; } = new();
+ public HashSet RefuseLegacy { get; } = new(StringComparer.OrdinalIgnoreCase);
+
+ /* The databases that hold a legacy session right now. An older install creates it again by adding one here, and
+ two installs that monitor the same server share one set. */
+ public HashSet LegacySessions { get; set; } = new(StringComparer.OrdinalIgnoreCase);
+
+ /* Every call in the order it ran: "legacy:alpha", "create:alpha", "drop:gamma". */
+ public List Events { get; } = new();
+
+ /* Where this install keeps the record of the legacy drop, in memory because the store is never opened. */
+ public InMemoryLegacyRecords Records { get; init; } = new();
+
+ /* What a reconnect does to the loop state (DarlingWorker's connect block), and to the runner. */
+ public void Reconnect()
+ {
+ Runner.OnServerReconnected(State.Runtime!.ServerId);
+ State.LongQueryTraceApplied = null;
+ State.LongQueryTraceAppliedKey = null;
+ State.LongQueryTraceAppliedAtUtc = null;
+ State.LongQueryTraceFault = null;
+ State.LongQueryTracePartialNote = null;
+ State.LongQueryTraceDropRetry.Reset();
+ State.LongQueryTraceCreateWarned = false;
+ }
+ }
+
+ /// The record the legacy drop leaves, in memory. A read or a write fails on demand, as the store's would.
+ internal sealed class InMemoryLegacyRecords : ILegacyLongQueryRecords
+ {
+ public Dictionary<(int ServerId, string StateKey), string> Rows { get; } = new();
+ public Exception? ReadFailure { get; set; }
+ public Exception? WriteFailure { get; set; }
+ public int Reads { get; private set; }
+ public int Writes { get; private set; }
+
+ public Task> ReadAsync(int serverId, CancellationToken cancellationToken)
+ {
+ Reads++;
+ if (ReadFailure is { } failure)
+ {
+ return Task.FromException>(failure);
+ }
+
+ return Task.FromResult>(Rows.Keys
+ .Where(k => k.ServerId == serverId && k.StateKey.StartsWith(LegacyLongQuerySession.StateKeyPrefix, StringComparison.Ordinal))
+ .Select(k => k.StateKey)
+ .ToList());
+ }
+
+ public Task WriteAsync(int serverId, string stateKey, DateTime droppedUtc, CancellationToken cancellationToken)
+ {
+ if (WriteFailure is { } failure)
+ {
+ return Task.FromException(failure);
+ }
+
+ Writes++;
+ Rows[(serverId, stateKey)] = droppedUtc.ToString("o", System.Globalization.CultureInfo.InvariantCulture);
+ return Task.CompletedTask;
+ }
+
+ public bool Has(int serverId, string database) => Rows.ContainsKey((serverId, LegacyLongQuerySession.StateKey(database)));
}
/// A logical-server registration (no database named) on an Azure SQL Database server.
@@ -90,13 +166,21 @@ private static LongQueryTraceRegistration LogicalServer(int id, bool traceOn, pa
private static LongQueryTraceRegistration OneDatabase(int id, string database, bool traceOn) =>
new(id.ToString(System.Globalization.CultureInfo.InvariantCulture), Host, database, Enabled: true, traceOn, Array.Empty(), DatabaseScope: null);
- private Rig BuildRig(MonitoredServer config, int serverId)
+ /// A server on an engine with no per-database sessions, an on-premises server: its session is the server's.
+ private Rig BuildOnPremRig() =>
+ BuildRig(new MonitoredServer { Name = "lqtrace-sql", Host = "lqtrace-sql" }, ServerId, azureSqlDatabase: false);
+
+ /* This install's id (#4961), and the session it makes from it. */
+ private const string InstallIdValue = "0a1b2c3d";
+ private static readonly string OwnSession = LongQueryCompletionsCollector.XeSessionNameFor(LongQueryCompletionsCollector.DarlingProduct, InstallIdValue);
+
+ private Rig BuildRig(MonitoredServer config, int serverId, bool azureSqlDatabase = true, string? installId = InstallIdValue, InMemoryLegacyRecords? records = null)
{
var runtime = new ServerRuntime
{
Config = config,
ConnectionString = $"Server=tcp:{Host},1433;Initial Catalog={config.Database ?? "master"};Encrypt=True",
- Target = new CollectorTargetInfo { IsAzureSqlDb = true },
+ Target = new CollectorTargetInfo { IsAzureSqlDb = azureSqlDatabase },
StorageName = Host,
ServerId = serverId,
};
@@ -106,15 +190,20 @@ private Rig BuildRig(MonitoredServer config, int serverId)
_store,
new CollectorDeltaCalculator(),
databaseScope: (_, _) => rig!.Scope.ToList(),
- separatelyMonitoredDatabases: _ => rig!.Owned.ToList());
+ separatelyMonitoredDatabases: _ => rig!.Owned.ToList(),
+ installId: () => installId);
rig = new Rig
{
Runner = runner,
State = new DarlingWorker.ServerLoopState { Config = config, Runtime = runtime },
Config = config,
+ Records = records ?? new InMemoryLegacyRecords(),
};
+ runner.LegacyLongQueryRecordsForTests = rig.Records;
+ runner.LegacyLongQueryPresentForTests = (_, database, _) => Task.FromResult(rig.LegacySessions.Contains(database));
+
runner.LongQueryTraceListOverrideForTests = (_, allDatabases, scope, _) =>
{
rig.ListCalls++;
@@ -131,12 +220,28 @@ private Rig BuildRig(MonitoredServer config, int serverId)
.ToList());
};
- runner.LongQueryTraceDatabaseOverrideForTests = (_, database, create, _) =>
+ runner.LongQueryTraceDatabaseOverrideForTests = (_, database, create, sessionName, _) =>
{
+ if (sessionName == LongQueryCompletionsCollector.LegacyXeSessionName)
+ {
+ rig.LegacyCalls.Add(database);
+ rig.LegacyNames.Add(sessionName);
+ rig.Events.Add("legacy:" + database);
+ if (rig.RefuseLegacy.Contains(database))
+ {
+ return Task.FromException(new InvalidOperationException($"The legacy drop was refused in {database}."));
+ }
+
+ rig.LegacySessions.Remove(database);
+ return Task.CompletedTask;
+ }
+
+ rig.Events.Add((create ? "create:" : "drop:") + database);
rig.Calls.Add((database, create));
+ rig.Names.Add(sessionName);
if (rig.Refuse.Contains(database))
{
- return Task.FromException(new InvalidOperationException($"The drop was refused in {database}."));
+ return Task.FromException(new InvalidOperationException($"The {(create ? "create" : "drop")} was refused in {database}."));
}
if (create)
@@ -154,6 +259,17 @@ private Rig BuildRig(MonitoredServer config, int serverId)
return rig;
}
+ /// A service restart: a new runner and a new loop state over the same record, on the same server.
+ private Rig Restart(Rig before)
+ {
+ var after = BuildRig(
+ before.Config, before.State.Runtime!.ServerId, before.State.Runtime.Target.IsAzureSqlDb, before.Runner.InstallId, before.Records);
+ after.Listed = before.Listed;
+ after.LegacySessions = before.LegacySessions;
+ after.Clock = before.Clock;
+ return after;
+ }
+
/* ── M1: a failed listing or drop is retried, with a cap ── */
[Fact]
@@ -413,6 +529,250 @@ public async Task On_TheAttemptAfterTheCap_ThatThrowsAnotherError_RunsAgainAnHou
private static int Warnings(Rig rig) =>
rig.Logger.Entries.Count(e => e.Level == Microsoft.Extensions.Logging.LogLevel.Warning);
+ /* ── #4964: a create that fails again and again logs its first failure at Warning, the repeats at Debug ── */
+
+ private const string EnumerateLine = "Failed to enumerate databases for the long-query completion XE session";
+
+ private static int Logged(Rig rig, Microsoft.Extensions.Logging.LogLevel level, string text) =>
+ rig.Logger.Entries.Count(e => e.Level == level && e.Message.Contains(text, StringComparison.Ordinal));
+
+ /* The worker's own line has no database in it; the per-database line does. */
+ private static string WorkerLine(Rig rig) => $"[{rig.Config.DisplayName}] Failed to reconcile the long-query completion XE session: ";
+
+ /* ── #4964: the drop of a server-scoped session follows the same cap as the Azure arm's ── */
+
+ /* The server-scoped session reaches the test replacement with no database name. */
+ private const string TheServer = "";
+
+ private const string DropLine = "Could not drop the long-query trace session:";
+
+ ///
+ /// On an engine with no per-database sessions, a disabled trace whose drop fails on every sweep follows the cadence of
+ /// the Azure arm: the first four failures are Warnings that the next sweep tries again, the fifth is the one Warning that
+ /// gives up, the sweeps after it run nothing, and an hour later one attempt logs at Debug. Before, the failure reached
+ /// the worker's general catch, which warned on every sweep with no end.
+ ///
+ [Fact]
+ public async Task Off_OnPremises_ADropThatFailsOnEverySweep_WarnsToTheCap_ThenTriesOnceAnHourAtDebug()
+ {
+ var rig = BuildOnPremRig();
+ rig.Refuse.Add(TheServer);
+
+ for (var pass = 1; pass < LongQueryTraceDatabases.DropAttemptCap; pass++)
+ {
+ await rig.ReconcileAsync(enabled: false);
+ Assert.Null(rig.State.LongQueryTraceApplied);
+ }
+
+ await rig.ReconcileAsync(enabled: false);
+ Assert.False(rig.State.LongQueryTraceApplied);
+ Assert.Equal(LongQueryTraceDatabases.DropAttemptCap - 1, Logged(rig, Microsoft.Extensions.Logging.LogLevel.Warning, DropLine));
+ var giveUp = Assert.Single(rig.Logger.Entries, e => e.Message.Contains("Stopped retrying", StringComparison.Ordinal));
+ Assert.Contains("may remain on the server", giveUp.Message, StringComparison.Ordinal);
+ Assert.DoesNotContain("databases could not be listed", giveUp.Message, StringComparison.Ordinal);
+ Assert.EndsWith(" It also tries again after it reconnects.", giveUp.Message, StringComparison.Ordinal);
+ Assert.Equal(0, Logged(rig, Microsoft.Extensions.Logging.LogLevel.Warning, WorkerLine(rig)));
+ Assert.Equal(LongQueryTraceDatabases.DropAttemptCap, Warnings(rig));
+
+ /* Done: the sweeps within the hour run nothing. */
+ rig.Calls.Clear();
+ await rig.ReconcileAsync(enabled: false);
+ rig.Clock += LongQueryTraceDatabases.RetryInterval - TimeSpan.FromMinutes(1);
+ await rig.ReconcileAsync(enabled: false);
+ Assert.Empty(rig.Calls);
+
+ /* An hour after the cap: one attempt, at Debug, and the sweep after it runs nothing. */
+ rig.Clock += TimeSpan.FromMinutes(1);
+ await rig.ReconcileAsync(enabled: false);
+ await rig.ReconcileAsync(enabled: false);
+ Assert.Single(rig.Calls);
+ Assert.Equal(LongQueryTraceDatabases.DropAttemptCap, Warnings(rig));
+ Assert.Equal(1, Logged(rig, Microsoft.Extensions.Logging.LogLevel.Debug, "The next attempt is in an hour."));
+ Assert.False(rig.State.LongQueryTraceApplied);
+ }
+
+ ///
+ /// Turning the trace on is a create that succeeds, and it ends the run of drop failures: after the cap gave up, on and
+ /// off again, the next failures count from one, and warn again.
+ ///
+ [Fact]
+ public async Task Off_OnPremises_AfterTheCap_TurnedOnAndOffAgain_CountsTheFailuresAgain()
+ {
+ var rig = BuildOnPremRig();
+ rig.Refuse.Add(TheServer);
+ for (var pass = 1; pass <= LongQueryTraceDatabases.DropAttemptCap; pass++)
+ {
+ await rig.ReconcileAsync(enabled: false);
+ }
+
+ Assert.False(rig.State.LongQueryTraceApplied);
+
+ rig.Refuse.Clear();
+ await rig.ReconcileAsync(enabled: true);
+ Assert.True(rig.State.LongQueryTraceApplied);
+
+ var warnings = Warnings(rig);
+ rig.Refuse.Add(TheServer);
+ for (var pass = 1; pass < LongQueryTraceDatabases.DropAttemptCap; pass++)
+ {
+ await rig.ReconcileAsync(enabled: false);
+ }
+
+ Assert.Equal(LongQueryTraceDatabases.DropAttemptCap - 1, Warnings(rig) - warnings);
+ Assert.True(rig.State.LongQueryTraceApplied);
+ }
+
+ [Fact]
+ public async Task On_ACreateThatFailsOnEverySweep_LogsOneWarning_ThenDebug_AndRecordsTheFaultEachTime()
+ {
+ var rig = BuildRig();
+ var warning = Microsoft.Extensions.Logging.LogLevel.Warning;
+ var debug = Microsoft.Extensions.Logging.LogLevel.Debug;
+
+ rig.ListFailure = new InvalidOperationException("master is not readable, first.");
+ await rig.ReconcileAsync(enabled: true);
+
+ Assert.Equal(1, Logged(rig, warning, WorkerLine(rig)));
+ Assert.Equal(1, Logged(rig, warning, EnumerateLine));
+ Assert.Contains("first.", rig.State.LongQueryTraceFault, StringComparison.Ordinal);
+
+ /* The same failure on the next two sweeps: no new Warning, one Debug line each, and the fault is recorded
+ again with the newest message, so collection health keeps reading SESSION_MISSING. */
+ foreach (var attempt in new[] { "second.", "third." })
+ {
+ rig.ListFailure = new InvalidOperationException("master is not readable, " + attempt);
+ await rig.ReconcileAsync(enabled: true);
+ Assert.Contains(attempt, rig.State.LongQueryTraceFault, StringComparison.Ordinal);
+ }
+
+ Assert.Equal(1, Logged(rig, warning, WorkerLine(rig)));
+ Assert.Equal(1, Logged(rig, warning, EnumerateLine));
+ Assert.Equal(2, Logged(rig, debug, WorkerLine(rig)));
+ Assert.Equal(2, Logged(rig, debug, EnumerateLine));
+
+ /* The retry did not change: every sweep listed again, and the latch is still unset. */
+ Assert.Equal(3, rig.ListCalls);
+ Assert.Null(rig.State.LongQueryTraceApplied);
+ }
+
+ [Fact]
+ public async Task On_ACreateThatSucceedsAfterFailures_WarnsAgainWhenItFailsAfterwards()
+ {
+ var rig = BuildRig();
+ var warning = Microsoft.Extensions.Logging.LogLevel.Warning;
+
+ rig.ListFailure = new InvalidOperationException("master is not readable.");
+ await rig.ReconcileAsync(enabled: true);
+ await rig.ReconcileAsync(enabled: true);
+ Assert.Equal(1, Logged(rig, warning, WorkerLine(rig)));
+
+ /* A create that succeeds ends the run of failures. */
+ rig.ListFailure = null;
+ await rig.ReconcileAsync(enabled: true);
+ Assert.True(rig.State.LongQueryTraceApplied);
+ Assert.Null(rig.State.LongQueryTraceFault);
+
+ /* The hourly create pass fails: a new failure, so it warns again, and its repeat does not. */
+ rig.ListFailure = new InvalidOperationException("master is not readable again.");
+ rig.Clock += LongQueryTraceDatabases.RetryInterval;
+ await rig.ReconcileAsync(enabled: true);
+ Assert.Equal(2, Logged(rig, warning, WorkerLine(rig)));
+
+ rig.Clock += LongQueryTraceDatabases.RetryInterval;
+ await rig.ReconcileAsync(enabled: true);
+ Assert.Equal(2, Logged(rig, warning, WorkerLine(rig)));
+
+ /* One repeat in each run of failures. */
+ Assert.Equal(2, Logged(rig, Microsoft.Extensions.Logging.LogLevel.Debug, WorkerLine(rig)));
+ }
+
+ [Fact]
+ public async Task On_ACreateRefusedInEveryDatabase_LogsEachRefusalAtWarningOnce_ThenAtDebug()
+ {
+ var rig = BuildRig();
+ var warning = Microsoft.Extensions.Logging.LogLevel.Warning;
+ var debug = Microsoft.Extensions.Logging.LogLevel.Debug;
+ foreach (var database in new[] { "alpha", "beta", "gamma" })
+ {
+ rig.Refuse.Add(database);
+ }
+
+ await rig.ReconcileAsync(enabled: true);
+
+ /* The first pass names each refusing database, says that every one refused, and the worker says it once. */
+ Assert.Equal(1, Logged(rig, warning, "[beta] Failed to reconcile the long-query completion XE session"));
+ Assert.Equal(1, Logged(rig, warning, "could not be ensured in all 3 database(s)"));
+ Assert.Equal(1, Logged(rig, warning, WorkerLine(rig)));
+ var firstPass = Warnings(rig);
+ Assert.Equal(5, firstPass);
+ Assert.NotNull(rig.State.LongQueryTraceFault);
+
+ await rig.ReconcileAsync(enabled: true);
+ await rig.ReconcileAsync(enabled: true);
+
+ /* The repeats log the same lines at Debug, and every sweep still tried every database. */
+ Assert.Equal(firstPass, Warnings(rig));
+ Assert.Equal(2, Logged(rig, debug, "[beta] Failed to reconcile the long-query completion XE session"));
+ Assert.Equal(2, Logged(rig, debug, "could not be ensured in all 3 database(s)"));
+ Assert.Equal(2, Logged(rig, debug, WorkerLine(rig)));
+ Assert.Equal(9, rig.Created.Count());
+ Assert.NotNull(rig.State.LongQueryTraceFault);
+ }
+
+ [Fact]
+ public async Task Off_ADropFailureAfterCreateFailures_IsNotCountedAsARepeat()
+ {
+ var rig = BuildRig();
+ var warning = Microsoft.Extensions.Logging.LogLevel.Warning;
+
+ rig.ListFailure = new InvalidOperationException("master is not readable.");
+ await rig.ReconcileAsync(enabled: true);
+ await rig.ReconcileAsync(enabled: true);
+ Assert.Equal(1, Logged(rig, warning, WorkerLine(rig)));
+
+ /* Turned off, the drop is a different pass with its own failure: its first line is a Warning. The cap on the
+ drop side is its own (#4944). */
+ await rig.ReconcileAsync(enabled: false);
+ Assert.Equal(1, Logged(rig, warning, "Could not list the databases to drop the long-query trace session from"));
+ }
+
+ [Fact]
+ public async Task AReconnect_StartsTheRunOfCreateFailuresAgain_SoTheNextOneWarns()
+ {
+ var rig = BuildRig();
+ var warning = Microsoft.Extensions.Logging.LogLevel.Warning;
+
+ rig.ListFailure = new InvalidOperationException("master is not readable.");
+ await rig.ReconcileAsync(enabled: true);
+ await rig.ReconcileAsync(enabled: true);
+ Assert.Equal(1, Logged(rig, warning, WorkerLine(rig)));
+ Assert.True(rig.State.LongQueryTraceCreateWarned);
+
+ /* What the connect block does to the long-query state (pinned below). */
+ rig.State.LongQueryTraceApplied = null;
+ rig.State.LongQueryTraceFault = null;
+ rig.State.LongQueryTracePartialNote = null;
+ rig.State.LongQueryTraceAppliedKey = null;
+ rig.State.LongQueryTraceAppliedAtUtc = null;
+ rig.State.LongQueryTraceDropRetry.Reset();
+ rig.State.LongQueryTraceCreateWarned = false;
+
+ await rig.ReconcileAsync(enabled: true);
+ Assert.Equal(2, Logged(rig, warning, WorkerLine(rig)));
+ }
+
+ [Fact]
+ public void TheConnectBlock_ClearsTheCreateFailureWarned_WithTheRestOfTheLongQueryState()
+ {
+ var source = ReadRepoFileLf("Darling", "PerformanceMonitor.Darling.Service", "DarlingWorker.cs");
+
+ var latchReset = source.IndexOf("server.LongQueryTraceApplied = null;", StringComparison.Ordinal);
+ Assert.True(latchReset > 0);
+ var slice = source.Substring(latchReset, 1600);
+ Assert.Contains("server.LongQueryTraceDropRetry.Reset();", slice, StringComparison.Ordinal);
+ Assert.Contains("server.LongQueryTraceCreateWarned = false;", slice, StringComparison.Ordinal);
+ }
+
/* ── M2: the trace follows the monitored set ── */
[Fact]
@@ -665,6 +1025,52 @@ public void TheLongQueryRead_SkipsTheDatabasesMonitoredAsTheirOwnServers()
Assert.Contains("separatelyMonitoredDatabases: runtime => AzureMasterScope.SeparatelyMonitoredDatabases(", worker, StringComparison.Ordinal);
}
+ ///
+ /// The read leaves out master too, by the trace's own rule (), and
+ /// counts the databases it lists without master, so a logical server whose user databases are all monitored
+ /// separately is not left reading master alone, and a list of master alone is not blamed on them (#4961). The
+ /// behaviour is driven in ; pinned here in the source as well, so the
+ /// rule stays the trace's own and not a second copy in the runner.
+ ///
+ [Fact]
+ public void TheLongQueryRead_AlsoSkipsMaster_ByTheTracesOwnRule()
+ {
+ var runner = ReadRepoFileLf("Darling", "PerformanceMonitor.Darling.Service", "DarlingCollectorRunner.cs");
+ var skip = runner.IndexOf("if (definition.SkipsSeparatelyMonitoredDatabases)", StringComparison.Ordinal);
+ var note = runner.IndexOf("EmptyDatabaseListNote.For(", skip, StringComparison.Ordinal);
+ Assert.True(skip > 0 && note > skip, "the skip comes before the note's inputs");
+
+ var block = runner[skip..note];
+ var master = block.IndexOf("databases = databases.FindAll(LongQueryTraceDatabases.CanHoldSession);", StringComparison.Ordinal);
+ var counted = block.IndexOf("listedDatabaseCount = databases.Count;", StringComparison.Ordinal);
+ var separately = block.IndexOf("databases = AzureSweepScope.WithoutSeparatelyMonitored(databases, SeparatelyMonitoredDatabasesFor(server));", StringComparison.Ordinal);
+ Assert.True(master > 0 && counted > master && separately > counted,
+ "master leaves the list, then the list is counted, then the separately monitored databases leave");
+ Assert.DoesNotContain("\"master\"", block, StringComparison.Ordinal);
+ }
+
+ [Theory]
+ [InlineData("master", false)]
+ [InlineData("MASTER", false)]
+ [InlineData("alpha", true)]
+ [InlineData("masters", true)]
+ public void TheTrace_CanHoldASessionInEveryDatabaseButMaster(string database, bool expected)
+ {
+ Assert.Equal(expected, LongQueryTraceDatabases.CanHoldSession(database));
+ }
+
+ [Fact]
+ public void ThePlan_CreatesTheSessionWhereTheReadReads_NeverInMaster()
+ {
+ var listed = new[] { "master", "alpha", "zeta" };
+
+ var plan = LongQueryTraceDatabases.Plan(
+ enabled: true, listed, listed, Array.Empty(), Array.Empty());
+
+ Assert.Equal(listed.Where(LongQueryTraceDatabases.CanHoldSession), plan.Create);
+ Assert.DoesNotContain("master", plan.Create, StringComparer.OrdinalIgnoreCase);
+ }
+
///
/// The sweep hands the reconcile the other registrations and the server's own list of separately monitored
/// databases from the live registry, through the two adapters the tests above drive. The server's list is the
@@ -699,7 +1105,764 @@ public void TheAlwaysOnSessions_StillFollowTheInventory()
Assert.True(ensure > 0, "the always-on database-scoped ensure is gone");
var body = source[ensure..source.IndexOf("\n }\n", ensure, StringComparison.Ordinal)];
- Assert.Contains("databases = await runner.GetAzureDatabaseListAsync(server, databaseScope: null, cancellationToken);", body, StringComparison.Ordinal);
+ /* #4961: a test replaces the listing, so the read of the server is the second arm of a conditional. */
+ Assert.Contains(": await runner.GetAzureDatabaseListAsync(server, databaseScope: null, cancellationToken);", body, StringComparison.Ordinal);
Assert.DoesNotContain("SeparatelyMonitored", body, StringComparison.Ordinal);
}
+
+ /* ── #4961: the session is this install's own, named from its id ── */
+
+ [Fact]
+ public async Task Azure_EveryPass_NamesOnlyThisInstallsSession()
+ {
+ /* "gamma" is excluded and refuses the drop outside the monitored set until the cap gives up: the full pass, its
+ retries, the attempt after the cap and the hourly create all run, then the trace goes off. */
+ var rig = BuildRig("gamma");
+ rig.Refuse.Add("gamma");
+ for (var pass = 1; pass <= LongQueryTraceDatabases.DropAttemptCap; pass++)
+ {
+ await rig.ReconcileAsync(enabled: true);
+ }
+
+ rig.Clock += LongQueryTraceDatabases.RetryInterval;
+ await rig.ReconcileAsync(enabled: true);
+ rig.Refuse.Clear();
+ rig.Clock += LongQueryTraceDatabases.RetryInterval;
+ await rig.ReconcileAsync(enabled: true);
+ await rig.ReconcileAsync(enabled: false);
+
+ Assert.Contains(rig.Calls, c => c.Create);
+ Assert.Contains(rig.Calls, c => !c.Create);
+ Assert.Equal(rig.Calls.Count, rig.Names.Count);
+ Assert.All(rig.Names, name => Assert.Equal(OwnSession, name));
+ Assert.DoesNotContain(rig.Names, name => name == LongQueryCompletionsCollector.LegacyXeSessionName);
+
+ /* The legacy session is dropped once in each listed database but master, by its own name, and never again. */
+ Assert.Equal(new[] { "alpha", "beta", "gamma" }, rig.LegacyCalls);
+ Assert.All(rig.LegacyNames, name => Assert.Equal(LongQueryCompletionsCollector.LegacyXeSessionName, name));
+ }
+
+ [Fact]
+ public async Task OnPrem_TheFullAndTheHourlyPass_AndTheDrop_NameThisInstallsSession()
+ {
+ var rig = BuildOnPremRig();
+
+ await rig.ReconcileAsync(enabled: true);
+ rig.Clock += LongQueryTraceDatabases.RetryInterval;
+ await rig.ReconcileAsync(enabled: true);
+ Assert.Equal(2, rig.Calls.Count(c => c.Create));
+
+ await rig.ReconcileAsync(enabled: false);
+
+ Assert.Single(rig.Calls, c => !c.Create);
+ Assert.Equal(3, rig.Names.Count);
+ Assert.All(rig.Names, name => Assert.Equal(OwnSession, name));
+
+ /* The legacy session is dropped once on the server, by its own name, and the hourly pass does not touch it. */
+ Assert.Equal(new[] { TheServer }, rig.LegacyCalls);
+ Assert.All(rig.LegacyNames, name => Assert.Equal(LongQueryCompletionsCollector.LegacyXeSessionName, name));
+ }
+
+ [Theory]
+ [InlineData(true)]
+ [InlineData(false)]
+ public async Task NoInstallId_NothingIsCreated_AndTheRunRecordsWhy(bool azureSqlDatabase)
+ {
+ var rig = BuildRig(
+ new MonitoredServer { Name = "lqtrace-noid", Host = azureSqlDatabase ? Host : "lqtrace-sql" },
+ ServerId, azureSqlDatabase, installId: null);
+
+ await rig.ReconcileAsync(enabled: true);
+
+ Assert.Empty(rig.Calls);
+ Assert.Empty(rig.Names);
+ Assert.Null(rig.State.LongQueryTraceApplied);
+ Assert.NotNull(rig.State.LongQueryTraceFault);
+ Assert.Contains("no id", rig.State.LongQueryTraceFault, StringComparison.Ordinal);
+
+ /* Off: there is no session of this install's to drop, and the fault is cleared. */
+ await rig.ReconcileAsync(enabled: false);
+
+ Assert.Empty(rig.Calls);
+ Assert.Null(rig.State.LongQueryTraceFault);
+ }
+
+ [Fact]
+ public void TheEnsures_StartASessionTheyFindStopped_ByThisInstallsName_AndNothingNamesTheLegacySession()
+ {
+ var source = ReadRepoFileLf("Darling", "PerformanceMonitor.Darling.Service", "DarlingXeSessions.cs");
+
+ /* On-prem: the existence check also reports whether it runs, and a stopped one is started by name. */
+ Assert.Contains("is_running = CASE WHEN dxs.name IS NOT NULL THEN 1 ELSE 0 END", source, StringComparison.Ordinal);
+ Assert.Contains("isRunning == 0", source, StringComparison.Ordinal);
+ Assert.Contains("BuildStartSessionSql(sessionName, databaseScoped: false)", source, StringComparison.Ordinal);
+
+ /* Azure SQL Database: a session that exists and does not run is started by name. */
+ Assert.Contains("FROM sys.dm_xe_database_sessions AS xes", source, StringComparison.Ordinal);
+ Assert.Contains("ALTER EVENT SESSION [{sessionName}] ON DATABASE STATE = START;", source, StringComparison.Ordinal);
+
+ Assert.DoesNotContain("LegacyXeSessionName", source, StringComparison.Ordinal);
+ Assert.DoesNotContain("LongQueryCompletionsCollector.XeSessionName", source, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void OnlyThePerInstallDdlStartsOff_TheDeadlockAndBlockedProcessDdlStaysOn()
+ {
+ var source = ReadRepoFileLf("Darling", "PerformanceMonitor.Darling.Service", "DarlingXeSessions.cs");
+
+ /* The two server-scoped always-on session statements (deadlock and blocked process) are unchanged. The two Azure ones are
+ made by the shared builder since #4961, which keeps the shared sessions on and starts only an own fallback off. */
+ Assert.Equal(2, source.Split("STARTUP_STATE = ON", StringSplitOptions.None).Length - 1);
+ foreach (var kind in new[] { AlwaysOnXeSessionKind.Deadlock, AlwaysOnXeSessionKind.BlockedProcess })
+ {
+ Assert.Contains("STARTUP_STATE = ON", AlwaysOnXeSessions.BuildAzureCreateSql(kind, AlwaysOnXeSessions.SharedNameFor(kind)), StringComparison.Ordinal);
+ }
+ Assert.DoesNotContain("STARTUP_STATE = OFF", source, StringComparison.Ordinal);
+
+ var sql = LongQueryCompletionsCollector.BuildCreateSessionSql(OwnSession, databaseScoped: false, 2_000_000);
+ Assert.Contains("STARTUP_STATE = OFF", sql, StringComparison.Ordinal);
+ Assert.DoesNotContain("STARTUP_STATE = ON", sql, StringComparison.Ordinal);
+ }
+
+ /* ── #4961: the legacy session is dropped once per registration and database, then recorded ── */
+
+ private static readonly string LegacyName = LongQueryCompletionsCollector.LegacyXeSessionName;
+
+ private static readonly string[] ThreeDatabases = { "alpha", "beta", "gamma" };
+
+ /* The one Information line that names a legacy session an older install created again. */
+ private static int Notices(Rig rig) =>
+ rig.Logger.Entries.Count(e => e.Level == LogLevel.Information && e.Message.Contains(LegacyName, StringComparison.Ordinal));
+
+ [Fact]
+ public async Task Legacy_Azure_IsDroppedOncePerDatabase_RecordedAfterTheDrop_AndNeverDroppedAgain()
+ {
+ var rig = BuildRig();
+ rig.LegacySessions.UnionWith(ThreeDatabases);
+
+ await rig.ReconcileAsync(enabled: true);
+
+ /* Each listed database but master, once, and the session is gone. */
+ Assert.Equal(ThreeDatabases, rig.LegacyCalls);
+ Assert.Empty(rig.LegacySessions);
+ foreach (var database in ThreeDatabases)
+ {
+ Assert.True(rig.Records.Has(ServerId, database), database);
+ var value = rig.Records.Rows[(ServerId, LegacyLongQuerySession.StateKey(database))];
+ Assert.True(DateTime.TryParse(value, System.Globalization.CultureInfo.InvariantCulture, System.Globalization.DateTimeStyles.RoundtripKind, out _), value);
+ }
+
+ Assert.False(rig.Records.Has(ServerId, "master"));
+ Assert.Equal(ThreeDatabases.Length, rig.Records.Rows.Count);
+
+ /* A reconnect, the trace turned off and on again: each is a full pass, and none drops it again. */
+ rig.Reconnect();
+ await rig.ReconcileAsync(enabled: true);
+ await rig.ReconcileAsync(enabled: false);
+ rig.Clock += TimeSpan.FromMinutes(1);
+ await rig.ReconcileAsync(enabled: true);
+
+ Assert.Equal(ThreeDatabases, rig.LegacyCalls);
+ }
+
+ [Theory]
+ [InlineData(true)]
+ [InlineData(false)]
+ public async Task Legacy_OnPrem_IsDroppedOnceOnTheServer_WhetherTheTraceIsOnOrOff_BeforeThePerInstallWork(bool enabled)
+ {
+ var rig = BuildOnPremRig();
+ rig.LegacySessions.Add(TheServer);
+
+ await rig.ReconcileAsync(enabled);
+
+ Assert.Equal(new[] { TheServer }, rig.LegacyCalls);
+ Assert.Equal(new[] { "legacy:", enabled ? "create:" : "drop:" }, rig.Events);
+
+ /* The server scope's database part is empty, and the value is the time of the drop. */
+ var key = Assert.Single(rig.Records.Rows.Keys);
+ Assert.Equal((ServerId, "legacy_session_dropped:"), key);
+
+ rig.Reconnect();
+ await rig.ReconcileAsync(!enabled);
+ await rig.ReconcileAsync(enabled);
+
+ Assert.Single(rig.LegacyCalls);
+ }
+
+ [Fact]
+ public async Task Legacy_ACrashBetweenTheDropAndTheRecord_CostsOneMoreDrop_ThenIsRecorded()
+ {
+ var rig = BuildRig();
+ rig.LegacySessions.UnionWith(ThreeDatabases);
+ rig.Records.WriteFailure = new InvalidOperationException("The store went away.");
+
+ await rig.ReconcileAsync(enabled: true);
+
+ /* The drops ran and the record did not, so the pass is not done and nothing is remembered. */
+ Assert.Equal(ThreeDatabases, rig.LegacyCalls);
+ Assert.Empty(rig.Records.Rows);
+ Assert.Null(rig.State.LongQueryTraceApplied);
+
+ /* The next sweep drops each one more time and records it. After that nothing drops it again. */
+ rig.Records.WriteFailure = null;
+ await rig.ReconcileAsync(enabled: true);
+ Assert.Equal(ThreeDatabases.Length * 2, rig.LegacyCalls.Count);
+ Assert.True(rig.State.LongQueryTraceApplied);
+
+ rig.Clock += LongQueryTraceDatabases.RetryInterval;
+ await rig.ReconcileAsync(enabled: true);
+ rig.Reconnect();
+ await rig.ReconcileAsync(enabled: true);
+ Assert.Equal(ThreeDatabases.Length * 2, rig.LegacyCalls.Count);
+ }
+
+ [Fact]
+ public async Task Legacy_TheRecordSurvivesAServiceRestart_AndAReconnect()
+ {
+ var rig = BuildRig();
+ rig.LegacySessions.UnionWith(ThreeDatabases);
+ await rig.ReconcileAsync(enabled: true);
+ Assert.Equal(ThreeDatabases, rig.LegacyCalls);
+
+ /* A service restart: a new runner that has only the record to go on. */
+ var restarted = Restart(rig);
+ restarted.LegacySessions.UnionWith(ThreeDatabases);
+ await restarted.ReconcileAsync(enabled: true);
+ Assert.Empty(restarted.LegacyCalls);
+
+ restarted.Reconnect();
+ await restarted.ReconcileAsync(enabled: true);
+ Assert.Empty(restarted.LegacyCalls);
+ Assert.Equal(ThreeDatabases.Length, restarted.LegacySessions.Count);
+ }
+
+ [Fact]
+ public async Task Legacy_ASessionAnOlderInstallCreatesAgain_IsNotDropped_AndOneInformationLineNamesItPerConnect()
+ {
+ var rig = BuildRig();
+ await rig.ReconcileAsync(enabled: true);
+ Assert.Equal(ThreeDatabases, rig.LegacyCalls);
+ Assert.Equal(0, Notices(rig));
+
+ /* An older install creates it again. The hourly create pass finds it, says so once, and leaves it alone. */
+ rig.LegacySessions.Add("alpha");
+ rig.LegacyCalls.Clear();
+ rig.Clock += LongQueryTraceDatabases.RetryInterval;
+ await rig.ReconcileAsync(enabled: true);
+ rig.Clock += LongQueryTraceDatabases.RetryInterval;
+ await rig.ReconcileAsync(enabled: true);
+
+ Assert.Empty(rig.LegacyCalls);
+ Assert.Contains("alpha", rig.LegacySessions);
+ var notice = Assert.Single(rig.Logger.Entries, e => e.Level == LogLevel.Information && e.Message.Contains(LegacyName, StringComparison.Ordinal));
+ Assert.Contains("older Lite or Darling created it", notice.Message, StringComparison.Ordinal);
+ Assert.Contains("--drop-xe-sessions", notice.Message, StringComparison.Ordinal);
+
+ /* A reconnect is a new connect: the full pass leaves it alone and says so again. */
+ rig.Reconnect();
+ await rig.ReconcileAsync(enabled: true);
+ Assert.Empty(rig.LegacyCalls);
+ Assert.Equal(2, Notices(rig));
+ Assert.Contains("alpha", rig.LegacySessions);
+ }
+
+ [Fact]
+ public async Task Legacy_TheAttemptAfterTheCap_LeavesASessionAnOlderInstallCreatedAgainAlone()
+ {
+ /* gamma is excluded and refuses the drop outside the monitored set, until the cap gives up on it. */
+ var rig = BuildRig("gamma");
+ rig.Refuse.Add("gamma");
+ for (var pass = 1; pass <= LongQueryTraceDatabases.DropAttemptCap; pass++)
+ {
+ await rig.ReconcileAsync(enabled: true);
+ }
+
+ Assert.Equal(ThreeDatabases, rig.LegacyCalls);
+ Assert.True(rig.State.LongQueryTraceDropRetry.NextAttemptUtc is not null);
+
+ rig.LegacySessions.Add("beta");
+ rig.LegacyCalls.Clear();
+ rig.Clock += LongQueryTraceDatabases.RetryInterval;
+ await rig.ReconcileAsync(enabled: true);
+
+ Assert.Empty(rig.LegacyCalls);
+ Assert.Contains("beta", rig.LegacySessions);
+ Assert.Equal(1, Notices(rig));
+ }
+
+ [Fact]
+ public async Task Legacy_TheDrop_NamesOnlyTheLegacySession_NeverAnotherInstallsNorTheDeadlockOrBlockedProcessSessions()
+ {
+ var azure = BuildRig("gamma");
+ azure.LegacySessions.UnionWith(ThreeDatabases);
+ await azure.ReconcileAsync(enabled: true);
+ azure.Clock += LongQueryTraceDatabases.RetryInterval;
+ await azure.ReconcileAsync(enabled: true);
+ await azure.ReconcileAsync(enabled: false);
+
+ var onPrem = BuildOnPremRig();
+ onPrem.LegacySessions.Add(TheServer);
+ await onPrem.ReconcileAsync(enabled: false);
+
+ foreach (var rig in new[] { azure, onPrem })
+ {
+ Assert.NotEmpty(rig.LegacyNames);
+ Assert.All(rig.LegacyNames, name => Assert.Equal(LegacyName, name));
+ Assert.NotEmpty(rig.Names);
+ Assert.All(rig.Names, name => Assert.Equal(OwnSession, name));
+ }
+
+ /* The statement builder takes the legacy name and a per-install one, and nothing else. */
+ foreach (var other in new[] { DeadlocksCollector.XeSessionName, BlockedProcessReportCollector.XeSessionName })
+ {
+ Assert.NotEqual(LegacyName, other);
+ Assert.Throws(() => LongQueryCompletionsCollector.BuildDropSessionSql(other, databaseScoped: false));
+ Assert.Throws(() => LongQueryCompletionsCollector.BuildDropSessionSql(other, databaseScoped: true));
+ }
+ }
+
+ [Fact]
+ public async Task Legacy_AFailedDrop_IsNotRecorded_AndRetriesOnTheCap_ThenOnceAnHourAtDebug()
+ {
+ var rig = BuildRig();
+ rig.LegacySessions.UnionWith(ThreeDatabases);
+ rig.RefuseLegacy.Add("beta");
+
+ for (var pass = 1; pass < LongQueryTraceDatabases.DropAttemptCap; pass++)
+ {
+ await rig.ReconcileAsync(enabled: true);
+ Assert.Null(rig.State.LongQueryTraceApplied);
+ }
+
+ /* The first pass tried every database. The passes after it retried beta alone, which is the one not recorded. */
+ Assert.Equal(new[] { "alpha", "beta", "gamma", "beta", "beta", "beta" }, rig.LegacyCalls);
+ Assert.True(rig.Records.Has(ServerId, "alpha"));
+ Assert.True(rig.Records.Has(ServerId, "gamma"));
+ Assert.False(rig.Records.Has(ServerId, "beta"));
+
+ /* The failed legacy drop did not stop the per-install session from being created there. */
+ Assert.Contains("beta", rig.Sessions);
+
+ await rig.ReconcileAsync(enabled: true);
+ Assert.True(rig.State.LongQueryTraceApplied);
+ var giveUp = Assert.Single(rig.Logger.Entries, e => e.Message.Contains("Stopped retrying", StringComparison.Ordinal));
+ Assert.Equal(LogLevel.Warning, giveUp.Level);
+ Assert.Contains("beta", giveUp.Message, StringComparison.Ordinal);
+
+ /* Done for now: the sweeps within the hour try nothing. An hour later, one attempt, logged at Debug. */
+ rig.LegacyCalls.Clear();
+ await rig.ReconcileAsync(enabled: true);
+ Assert.Empty(rig.LegacyCalls);
+ var warnings = Warnings(rig);
+ rig.Clock += LongQueryTraceDatabases.RetryInterval;
+ await rig.ReconcileAsync(enabled: true);
+ Assert.Equal(new[] { "beta" }, rig.LegacyCalls);
+ Assert.Equal(warnings, Warnings(rig));
+
+ /* The refusal ends: the next attempt records it, and the attempts end. */
+ rig.RefuseLegacy.Clear();
+ rig.Clock += LongQueryTraceDatabases.RetryInterval;
+ await rig.ReconcileAsync(enabled: true);
+ Assert.True(rig.Records.Has(ServerId, "beta"));
+ Assert.Null(rig.State.LongQueryTraceDropRetry.NextAttemptUtc);
+ }
+
+ [Fact]
+ public async Task Legacy_OnPrem_AFailedDrop_IsNotRecorded_AndRetriesOnTheCap_WhileThePerInstallSessionIsStillCreated()
+ {
+ var rig = BuildOnPremRig();
+ rig.LegacySessions.Add(TheServer);
+ rig.RefuseLegacy.Add(TheServer);
+
+ for (var pass = 1; pass <= LongQueryTraceDatabases.DropAttemptCap; pass++)
+ {
+ await rig.ReconcileAsync(enabled: true);
+ }
+
+ Assert.Equal(LongQueryTraceDatabases.DropAttemptCap, rig.LegacyCalls.Count);
+ Assert.Equal(LongQueryTraceDatabases.DropAttemptCap, rig.Created.Count());
+ Assert.Empty(rig.Records.Rows);
+ var giveUp = Assert.Single(rig.Logger.Entries, e => e.Message.Contains("Stopped retrying", StringComparison.Ordinal));
+ Assert.Contains("may remain on the server", giveUp.Message, StringComparison.Ordinal);
+
+ rig.RefuseLegacy.Clear();
+ rig.Clock += LongQueryTraceDatabases.RetryInterval;
+ await rig.ReconcileAsync(enabled: true);
+
+ Assert.True(rig.Records.Has(ServerId, TheServer));
+ Assert.Null(rig.State.LongQueryTraceDropRetry.NextAttemptUtc);
+ }
+
+ [Fact]
+ public async Task Azure_InEachDatabase_TheLegacyDropRunsBeforeThePerInstallCreate()
+ {
+ /* gamma is excluded: its legacy drop still runs, and its per-install drop follows it. */
+ var rig = BuildRig("gamma");
+ rig.LegacySessions.UnionWith(ThreeDatabases);
+
+ await rig.ReconcileAsync(enabled: true);
+
+ Assert.Equal(
+ new[] { "legacy:alpha", "create:alpha", "legacy:beta", "create:beta", "legacy:gamma", "drop:gamma" },
+ rig.Events);
+ Assert.Equal(2, rig.ListCalls);
+ }
+
+ [Fact]
+ public async Task Azure_AFailedLegacyDrop_DoesNotStopThePerInstallCreateInThatDatabase()
+ {
+ var rig = BuildRig();
+ rig.RefuseLegacy.Add("beta");
+
+ await rig.ReconcileAsync(enabled: true);
+
+ Assert.Equal(
+ new[] { "legacy:alpha", "create:alpha", "legacy:beta", "create:beta", "legacy:gamma", "create:gamma" },
+ rig.Events);
+ Assert.Contains("beta", rig.Sessions);
+ Assert.Null(rig.State.LongQueryTraceApplied);
+ }
+
+ [Fact]
+ public async Task Azure_TheTraceOff_DropsTheLegacySessionOnceInEachListedDatabase_WithOneListing()
+ {
+ var rig = BuildRig();
+ rig.LegacySessions.UnionWith(ThreeDatabases);
+
+ await rig.ReconcileAsync(enabled: false);
+
+ Assert.Equal(
+ new[] { "legacy:alpha", "legacy:beta", "legacy:gamma", "drop:alpha", "drop:beta", "drop:gamma" },
+ rig.Events);
+ Assert.Equal(1, rig.ListCalls);
+ Assert.Equal(ThreeDatabases.Length, rig.Records.Rows.Count);
+ }
+
+ [Fact]
+ public async Task Legacy_ARecordThatCannotBeRead_SkipsTheLegacyStep_NeverDropsTwice_AndTriesAgainOnTheNextPass()
+ {
+ /* A store that has the record, read by a restarted service while the store is not answering. */
+ var rig = BuildRig();
+ await rig.ReconcileAsync(enabled: true);
+ var restarted = Restart(rig);
+ restarted.LegacySessions.Add("alpha");
+ restarted.Records.ReadFailure = new InvalidOperationException("The store is not answering.");
+
+ await restarted.ReconcileAsync(enabled: true);
+
+ Assert.Empty(restarted.LegacyCalls);
+ Assert.Equal(ThreeDatabases, restarted.Created);
+ Assert.Null(restarted.State.LongQueryTraceApplied);
+ Assert.Contains(restarted.Logger.Entries, e => e.Level == LogLevel.Warning && e.Message.Contains("The store is not answering.", StringComparison.Ordinal));
+
+ restarted.Records.ReadFailure = null;
+ await restarted.ReconcileAsync(enabled: true);
+
+ Assert.Empty(restarted.LegacyCalls);
+ Assert.True(restarted.State.LongQueryTraceApplied);
+ Assert.Contains("alpha", restarted.LegacySessions);
+
+ /* A store with no record yet: the unreadable pass drops nothing, and the next one drops each session once. */
+ var fresh = BuildRig();
+ fresh.LegacySessions.UnionWith(ThreeDatabases);
+ fresh.Records.ReadFailure = new InvalidOperationException("The store is not answering.");
+ await fresh.ReconcileAsync(enabled: true);
+ Assert.Empty(fresh.LegacyCalls);
+ Assert.Equal(ThreeDatabases, fresh.Created);
+
+ fresh.Records.ReadFailure = null;
+ await fresh.ReconcileAsync(enabled: true);
+ Assert.Equal(ThreeDatabases, fresh.LegacyCalls);
+ Assert.Equal(ThreeDatabases.Length, fresh.Records.Rows.Count);
+ }
+
+ [Fact]
+ public async Task Legacy_OnPrem_ARecordThatCannotBeRead_DropsNothing_AndTheNextPassTriesAgain()
+ {
+ var rig = BuildOnPremRig();
+ rig.LegacySessions.Add(TheServer);
+ rig.Records.ReadFailure = new InvalidOperationException("The store is not answering.");
+
+ await rig.ReconcileAsync(enabled: false);
+
+ Assert.Empty(rig.LegacyCalls);
+ Assert.Null(rig.State.LongQueryTraceApplied);
+
+ rig.Records.ReadFailure = null;
+ await rig.ReconcileAsync(enabled: false);
+
+ Assert.Equal(new[] { TheServer }, rig.LegacyCalls);
+ Assert.False(rig.State.LongQueryTraceApplied);
+ }
+
+ [Fact]
+ public async Task Legacy_AKeyKnownToBeRecorded_IsKeptInMemory_SoLaterPassesDoNotReadTheStoreAgain()
+ {
+ var rig = BuildRig();
+ await rig.ReconcileAsync(enabled: true);
+ Assert.Equal(1, rig.Records.Reads);
+
+ /* Turning the trace off and on again makes full passes, and the hourly pass is the create side alone. */
+ await rig.ReconcileAsync(enabled: false);
+ rig.Clock += LongQueryTraceDatabases.RetryInterval;
+ await rig.ReconcileAsync(enabled: true);
+ rig.Clock += LongQueryTraceDatabases.RetryInterval;
+ await rig.ReconcileAsync(enabled: true);
+ Assert.Equal(1, rig.Records.Reads);
+
+ /* A connect reads it again, once. */
+ rig.Reconnect();
+ await rig.ReconcileAsync(enabled: true);
+ Assert.Equal(2, rig.Records.Reads);
+ }
+
+ [Fact]
+ public async Task Legacy_AnOlderDarlingWithTheTraceOn_LosesItsSessionOncePerUpgradedInstall_ThenNeverAgain()
+ {
+ var first = BuildRig(new MonitoredServer { Name = "lqtrace", Host = Host }, ServerId, installId: "0a1b2c3d");
+ var second = BuildRig(new MonitoredServer { Name = "lqtrace", Host = Host }, ServerId, installId: "4e5f6a7b");
+
+ /* One older install's session, in one server, that both upgraded installs see. */
+ second.LegacySessions = first.LegacySessions;
+ first.LegacySessions.UnionWith(ThreeDatabases);
+
+ await first.ReconcileAsync(enabled: true);
+ Assert.Empty(first.LegacySessions);
+
+ /* The older install creates it again at its next connect, and the second upgraded install drops it once. */
+ first.LegacySessions.UnionWith(ThreeDatabases);
+ await second.ReconcileAsync(enabled: true);
+ Assert.Empty(second.LegacySessions);
+
+ /* The older install creates it once more. Neither upgraded install drops it again, at a connect or an hour later. */
+ first.LegacySessions.UnionWith(ThreeDatabases);
+ foreach (var rig in new[] { first, second })
+ {
+ rig.Reconnect();
+ await rig.ReconcileAsync(enabled: true);
+ rig.Clock += LongQueryTraceDatabases.RetryInterval;
+ await rig.ReconcileAsync(enabled: true);
+ }
+
+ Assert.Equal(ThreeDatabases, first.LegacyCalls);
+ Assert.Equal(ThreeDatabases, second.LegacyCalls);
+ Assert.Equal(ThreeDatabases.Length, first.LegacySessions.Count);
+
+ /* Each install made and kept only the session that carries its own id. */
+ Assert.All(first.Names, name => Assert.Equal(LongQueryCompletionsCollector.XeSessionNameFor(LongQueryCompletionsCollector.DarlingProduct, "0a1b2c3d"), name));
+ Assert.All(second.Names, name => Assert.Equal(LongQueryCompletionsCollector.XeSessionNameFor(LongQueryCompletionsCollector.DarlingProduct, "4e5f6a7b"), name));
+ }
+
+ [Fact]
+ public void TheEnsures_AskForTheLegacySessionInTheirOwnBatch_AndTheLegacyLogicLivesInItsOwnFile()
+ {
+ var xe = ReadRepoFileLf("Darling", "PerformanceMonitor.Darling.Service", "DarlingXeSessions.cs");
+
+ /* The on-premises batch, the Azure SQL Database batch and the check batch of a registration with read-only intent each
+ carry a second SELECT for the legacy name, so finding a session an older install created again costs no round trip
+ of its own. */
+ Assert.Equal(3, xe.Split("legacy_present = ", StringSplitOptions.None).Length - 1);
+ Assert.Contains("FROM sys.server_event_sessions AS ses", xe, StringComparison.Ordinal);
+ Assert.Contains("FROM sys.database_event_sessions AS des", xe, StringComparison.Ordinal);
+
+ /* DarlingXeSessions.cs never spells the legacy constant: the one-time drop and its record live in their own file. */
+ Assert.DoesNotContain("LegacyXeSessionName", xe, StringComparison.Ordinal);
+ var legacy = ReadRepoFileLf("Darling", "PerformanceMonitor.Darling.Service", "DarlingLegacyLongQuerySession.cs");
+ Assert.Contains("LongQueryCompletionsCollector.LegacyXeSessionName", legacy, StringComparison.Ordinal);
+ Assert.Contains("LegacyLongQuerySession.StateKey(database)", legacy, StringComparison.Ordinal);
+ Assert.Contains("LegacyLongQuerySession.StateCollector", legacy, StringComparison.Ordinal);
+
+ /* The store-backed record reads and writes through its own throwing statements, not the runner's swallowing helpers. */
+ Assert.DoesNotContain("GetCollectorStateAsync", legacy, StringComparison.Ordinal);
+ Assert.DoesNotContain("SaveCollectorStateAsync", legacy, StringComparison.Ordinal);
+ }
+
+ /* ── #4961: another registration of this install on the same instance keeps the session (L6, test 21) ── */
+
+ private const string InstanceName = "SQL01";
+
+ ///
+ /// Gives the test's registration the guard the sweep hands an on-premises reconcile: the worker's own builder, over a registry of this
+ /// install's other registrations, each one's effective trace setting, and the name each instance last reported. Every
+ /// resolution is counted. A null name is an instance that has reported none.
+ ///
+ private static void GiveInstanceGuard(Rig rig, string? ownName, params (int Id, bool TraceOn, string? Name, string Engine)[] others)
+ {
+ /* The registry holds this registration too, which the guard leaves out by its id. */
+ rig.Config.StoredServerId = ServerId;
+ var held = others.Where(o => o.Name is not null).ToDictionary(o => o.Id, o => o.Name!);
+ if (ownName is not null)
+ {
+ held[ServerId] = ownName;
+ }
+
+ var registry = others
+ .Select(o => new MonitoredServer { Name = "alias-" + o.Id, Host = "alias-" + o.Id, Engine = o.Engine, StoredServerId = o.Id })
+ .Append(rig.Config)
+ .ToList();
+ rig.InstanceGuard = () =>
+ {
+ rig.GuardResolutions++;
+ return DarlingWorker.LongQueryTraceInstanceGuardFor(
+ ServerId,
+ registry,
+ id => others.Single(o => o.Id == id).TraceOn,
+ (id, carrier) => Task.FromResult(
+ carrier == ServerEpoch.IdentityCarrierCollectors[0] && held.TryGetValue(id, out var name)
+ ? new Dictionary
+ {
+ [ServerEpoch.IdentityStateKey] = ServerEpoch.Serialize(new ServerEpoch.Stamp(new DateTime(2026, 10, 2, 8, 0, 0, DateTimeKind.Utc), name)),
+ }
+ : new Dictionary()));
+ };
+ }
+
+ private const int OtherId = 9001;
+
+ ///
+ /// Test 21: a registration whose trace is off does not drop the session another registration of the same instance
+ /// keeps. The match is positive: both registrations' last-known names are known and agree, ignoring case, and the other
+ /// registration has its trace on. Anything less drops, as before. Either way the reconcile is done, so the next sweep
+ /// opens no connection, and the one-time drop of the legacy session still runs.
+ ///
+ [Theory]
+ [InlineData("SQL01", "sql01", true, "sqlserver", false)]
+ [InlineData("SQL01", "SQL01", true, "sqlserver", false)]
+ [InlineData("SQL01", "SQL01", false, "sqlserver", true)]
+ [InlineData("SQL01", "SQL01", true, "postgres", true)]
+ [InlineData("SQL01", "SQL02", true, "sqlserver", true)]
+ [InlineData("SQL01", null, true, "sqlserver", true)]
+ [InlineData(null, "SQL01", true, "sqlserver", true)]
+ public async Task Off_OnPremises_LeavesTheSession_WhileAnotherRegistrationOfTheSameInstanceKeepsIt(
+ string? ownName, string? otherName, bool otherTraceOn, string otherEngine, bool expectDrop)
+ {
+ var rig = BuildOnPremRig();
+ rig.LegacySessions.Add(TheServer);
+ GiveInstanceGuard(rig, ownName, (OtherId, otherTraceOn, otherName, otherEngine));
+
+ await rig.ReconcileAsync(enabled: false);
+
+ Assert.Equal(expectDrop ? new[] { TheServer } : Array.Empty(), rig.Dropped);
+ Assert.Equal(new[] { TheServer }, rig.LegacyCalls);
+ Assert.Empty(rig.LegacySessions);
+ Assert.False(rig.State.LongQueryTraceApplied);
+ Assert.Equal(1, rig.GuardResolutions);
+
+ /* Done either way: the next sweep runs nothing, and does not resolve the guard again. */
+ rig.Calls.Clear();
+ rig.LegacyCalls.Clear();
+ await rig.ReconcileAsync(enabled: false);
+ Assert.Empty(rig.Calls);
+ Assert.Empty(rig.LegacyCalls);
+ Assert.Equal(1, rig.GuardResolutions);
+ }
+
+ [Fact]
+ public async Task Off_OnPremises_ASkippedDrop_SaysSoAtInformation_AndTheLegacyDropRunsBeforeIt()
+ {
+ var rig = BuildOnPremRig();
+ rig.LegacySessions.Add(TheServer);
+ GiveInstanceGuard(rig, InstanceName, (OtherId, true, "sql01", "sqlserver"));
+
+ await rig.ReconcileAsync(enabled: false);
+
+ Assert.Equal(new[] { "legacy:" }, rig.Events);
+ Assert.Equal(1, Logged(rig, LogLevel.Information, $"[{rig.Config.DisplayName}] Long-query completion XE session left in place: another registration of this install keeps it on the same instance"));
+ Assert.Equal(0, Logged(rig, LogLevel.Information, "reconciled OFF"));
+ Assert.Equal(0, Warnings(rig));
+ }
+
+ /// A drop that goes ahead keeps its account, so the two cases read apart in the log.
+ [Fact]
+ public async Task Off_OnPremises_ADropThatGoesAhead_SaysItReconciledOff_AndNotThatItLeftTheSession()
+ {
+ var rig = BuildOnPremRig();
+ rig.LegacySessions.Add(TheServer);
+ GiveInstanceGuard(rig, InstanceName, (OtherId, true, "SQL02", "sqlserver"));
+
+ await rig.ReconcileAsync(enabled: false);
+
+ Assert.Equal(new[] { "legacy:", "drop:" }, rig.Events);
+ Assert.Equal(1, Logged(rig, LogLevel.Information, "reconciled OFF"));
+ Assert.Equal(0, Logged(rig, LogLevel.Information, "keeps it on the same instance"));
+ }
+
+ /// A failed legacy drop still fails the pass after a skipped per-install drop, and the next sweep tries again.
+ [Fact]
+ public async Task Off_OnPremises_ASkippedDrop_StillReportsAFailedLegacyDrop()
+ {
+ var rig = BuildOnPremRig();
+ rig.RefuseLegacy.Add(TheServer);
+ GiveInstanceGuard(rig, InstanceName, (OtherId, true, "SQL01", "sqlserver"));
+
+ await rig.ReconcileAsync(enabled: false);
+
+ Assert.Empty(rig.Dropped);
+ Assert.Equal(new[] { TheServer }, rig.LegacyCalls);
+ Assert.NotEqual(false, rig.State.LongQueryTraceApplied);
+
+ rig.RefuseLegacy.Clear();
+ rig.LegacyCalls.Clear();
+ await rig.ReconcileAsync(enabled: false);
+
+ Assert.Equal(new[] { TheServer }, rig.LegacyCalls);
+ Assert.False(rig.State.LongQueryTraceApplied);
+ }
+
+ /// The guard reads the store, so a trace that is on never resolves it: not on the create, and not on the hourly pass.
+ [Fact]
+ public async Task On_OnPremises_NeverResolvesTheInstanceGuard()
+ {
+ var rig = BuildOnPremRig();
+ GiveInstanceGuard(rig, InstanceName, (OtherId, true, "SQL01", "sqlserver"));
+
+ await rig.ReconcileAsync(enabled: true);
+ rig.Clock = rig.Clock.AddHours(1);
+ await rig.ReconcileAsync(enabled: true);
+
+ Assert.Equal(0, rig.GuardResolutions);
+ Assert.Equal(2, rig.Created.Count());
+ }
+
+ /// An Azure SQL Database server keeps one session per database, so its drop never asks the instance guard.
+ [Fact]
+ public async Task Off_Azure_NeverResolvesTheInstanceGuard()
+ {
+ var rig = BuildRig();
+ GiveInstanceGuard(rig, InstanceName, (OtherId, true, "SQL01", "sqlserver"));
+
+ await rig.ReconcileAsync(enabled: false);
+
+ Assert.Equal(0, rig.GuardResolutions);
+ Assert.NotEmpty(rig.Dropped);
+ }
+
+ ///
+ /// The sweep builds the guard only for a SQL Server target that is not an Azure SQL Database, and builds it lazily: the
+ /// instance's reconcile hands the static one a function, so a server whose trace is off and already reconciled reads
+ /// no state. The function resolves the live registry and each registration's own schedule when it is called. Pinned in
+ /// the source: the sweep needs a live registry and store.
+ ///
+ [Fact]
+ public void TheSweep_HandsTheReconcileALazyInstanceGuard_OnlyForAnOnPremisesTarget()
+ {
+ var worker = ReadRepoFileLf("Darling", "PerformanceMonitor.Darling.Service", "DarlingWorker.cs");
+ var sweep = worker.IndexOf("private async Task ReconcileLongQueryTraceAsync(ServerLoopState server, DarlingCollectorRunner runner, CancellationToken cancellationToken)", StringComparison.Ordinal);
+ var call = worker.IndexOf("await ReconcileLongQueryTraceAsync(server, runner, enabled, registrations, serverSeparatelyMonitored, DateTime.UtcNow, _logger,", sweep, StringComparison.Ordinal);
+ Assert.True(sweep > 0 && call > sweep, "the sweep's reconcile call is pinned");
+
+ var body = worker[sweep..call];
+ Assert.Contains("Func>? instanceGuard = null;", body, StringComparison.Ordinal);
+ Assert.Contains("if (!server.Runtime.Target.IsAzureSqlDb)", body, StringComparison.Ordinal);
+ Assert.Contains("instanceGuard = () => LongQueryTraceInstanceGuardFor(", body, StringComparison.Ordinal);
+
+ /* What the function reads, each time it is called: the live registry, each registration's own schedule, and the store. */
+ var function = body[body.IndexOf("instanceGuard = () => LongQueryTraceInstanceGuardFor(", StringComparison.Ordinal)..];
+ Assert.Contains("_registryState.Read()?.Servers,", function, StringComparison.Ordinal);
+ Assert.Contains("otherId => StoreConfigProvider.ResolveSchedule(\"long_query_completions\", otherId, _scheduleOverrides).Enabled,", function, StringComparison.Ordinal);
+ Assert.Contains("(id, carrier) => runner.GetCollectorStateAsync(id, carrier, cancellationToken)", function, StringComparison.Ordinal);
+
+ var end = worker.IndexOf(");", call, StringComparison.Ordinal);
+ Assert.EndsWith(", cancellationToken, instanceGuard", worker[call..end], StringComparison.Ordinal);
+ }
}
diff --git a/Darling/Darling.Tests/LongQueryTraceReadOnlyIntentTests.cs b/Darling/Darling.Tests/LongQueryTraceReadOnlyIntentTests.cs
new file mode 100644
index 0000000000..ea88c7c484
--- /dev/null
+++ b/Darling/Darling.Tests/LongQueryTraceReadOnlyIntentTests.cs
@@ -0,0 +1,581 @@
+/*
+ * 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.Reflection;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Data.SqlClient;
+using Microsoft.Extensions.Logging;
+using Npgsql;
+using PerformanceMonitor.Collectors;
+using PerformanceMonitor.Darling.Service;
+using Xunit;
+
+namespace Darling.Tests;
+
+///
+/// Which connection each step of the long-query trace's create opens, for a registration with read-only intent and for one
+/// without (#4961). A session cannot be created on a read-only replica, and the definition replicates from the primary, so
+/// on Azure SQL Database a read-only-intent registration creates the definition over a connection without the intent and
+/// starts the session over its own. Each test drives the worker's real reconcile with the step's open and work replaced on
+/// the runner, which hands over the connection string the step would have opened.
+///
+public sealed class LongQueryTraceReadOnlyIntentTests : IAsyncDisposable
+{
+ private const string Host = "example.database.windows.net";
+ private const string InstallIdValue = "0a1b2c3d";
+
+ /* Never opened: the runner reaches the store only to write collected rows, and these tests collect nothing. */
+ private readonly NpgsqlDataSource _store = NpgsqlDataSource.Create("Host=127.0.0.1;Port=1;Database=unused;Username=unused");
+
+ public ValueTask DisposeAsync() => _store.DisposeAsync();
+
+ private sealed class Rig
+ {
+ public required DarlingCollectorRunner Runner { get; init; }
+ public required DarlingWorker.ServerLoopState State { get; init; }
+ public DarlingSelfAlertTests.CapturingLogger Logger { get; } = new();
+ public List Listed { get; set; } = new();
+
+ /* Every step the create took: which one, the database ("" on a server-scoped engine) and the string it opened. */
+ public List<(LongQueryTraceStep Step, string Database, string ConnectionString)> Steps { get; } = new();
+
+ /* Every step a drop took, the one-time drop of the legacy session included: the session's name rides along. */
+ public List<(LongQueryTraceStep Step, string Database, string ConnectionString, string SessionName)> DropSteps { get; } = new();
+
+ /* What the step says when it runs: null to succeed. */
+ public Func Refusal { get; set; } = _ => null;
+
+ /* What the check finds on the replica: by default a database that has never had the session. */
+ public LongQueryTraceReplicaState Replica { get; set; }
+
+ public DateTime Clock { get; set; } = new(2026, 10, 2, 12, 0, 0, DateTimeKind.Utc);
+
+ public Task ReconcileAsync(bool enabled = true, IReadOnlyList? registrations = null) =>
+ DarlingWorker.ReconcileLongQueryTraceAsync(
+ State, Runner, enabled, registrations ?? Array.Empty(), Array.Empty(), Clock, Logger, CancellationToken.None);
+
+ /// The drop steps of this install's own session: what a drop of the long-query trace does, apart from the legacy session's.
+ public List<(LongQueryTraceStep Step, string Database, string ConnectionString)> OwnDrops() =>
+ DropSteps.Where(s => s.SessionName != LongQueryCompletionsCollector.LegacyXeSessionName)
+ .Select(s => (s.Step, s.Database, s.ConnectionString)).ToList();
+
+ /// The drop steps of the legacy session older versions shared between installs.
+ public List<(LongQueryTraceStep Step, string Database, string ConnectionString)> LegacyDrops() =>
+ DropSteps.Where(s => s.SessionName == LongQueryCompletionsCollector.LegacyXeSessionName)
+ .Select(s => (s.Step, s.Database, s.ConnectionString)).ToList();
+
+ ///
+ /// Every line logged at Warning or above, whatever it says: what an operator reads. Nothing narrows it by a word, so a
+ /// Warning a test does not expect, such as a failed stop or a failed drop of the legacy session, is among them.
+ ///
+ public List Loud() => Logger.Entries
+ .Where(e => e.Level >= LogLevel.Warning)
+ .Select(e => e.Message)
+ .ToList();
+
+ ///
+ /// The one line a create that a read-only database refused logs at Warning: the registration, the database and the
+ /// message the long-query trace words for it.
+ ///
+ public string ReadOnlyLine(string database) =>
+ $"[{State.Config.DisplayName}] [{database}] {LongQueryTraceDatabases.ReadOnlyDatabaseMessage()}";
+ }
+
+ private Rig BuildRig(string database, bool readOnlyIntent, bool azureSqlDatabase = true)
+ {
+ var config = new MonitoredServer { Name = "intent-test", Host = Host, Database = database, ReadOnlyIntent = readOnlyIntent };
+
+ /* The string the runtime holds is what carries the intent: the worker builds it from the registration. */
+ var runtime = new ServerRuntime
+ {
+ Config = config,
+ ConnectionString = $"Server=tcp:{Host},1433;Initial Catalog={database};Encrypt=True;ApplicationIntent={(readOnlyIntent ? "ReadOnly" : "ReadWrite")}",
+ Target = new CollectorTargetInfo { IsAzureSqlDb = azureSqlDatabase },
+ StorageName = Host,
+ ServerId = 4961,
+ };
+
+ var runner = new DarlingCollectorRunner(
+ _store,
+ new CollectorDeltaCalculator(),
+ databaseScope: (_, _) => new List(),
+ separatelyMonitoredDatabases: _ => new List(),
+ installId: () => InstallIdValue);
+
+ var rig = new Rig
+ {
+ Runner = runner,
+ State = new DarlingWorker.ServerLoopState { Config = config, Runtime = runtime },
+ };
+ rig.Listed = new List { database };
+
+ runner.LongQueryTraceListOverrideForTests = (_, _, _, _) => Task.FromResult(rig.Listed.ToList());
+ /* The one-time drop of the legacy session has nothing to find, and keeps its record in memory. */
+ runner.LegacyLongQueryRecordsForTests = new LongQueryTraceLifecycleTests.InMemoryLegacyRecords();
+ runner.LegacyLongQueryPresentForTests = (_, _, _) => Task.FromResult(false);
+ runner.LongQueryTraceReplicaStateForTests = (_, _) => rig.Replica;
+ runner.LongQueryTraceStepOverrideForTests = (_, databaseName, connectionString, step, sessionName, _) =>
+ {
+ if (step is LongQueryTraceStep.Stop or LongQueryTraceStep.Drop)
+ {
+ rig.DropSteps.Add((step, databaseName, connectionString, sessionName));
+ }
+ else
+ {
+ rig.Steps.Add((step, databaseName, connectionString));
+ }
+
+ return rig.Refusal(step) is { } refusal ? Task.FromException(refusal) : Task.CompletedTask;
+ };
+
+ return rig;
+ }
+
+ private static ApplicationIntent IntentOf(string connectionString) =>
+ new SqlConnectionStringBuilder(connectionString).ApplicationIntent;
+
+ private static string DatabaseOf(string connectionString) =>
+ new SqlConnectionStringBuilder(connectionString).InitialCatalog;
+
+ /* ── Azure SQL Database ── */
+
+ [Fact]
+ public async Task Azure_AReadOnlyIntentRegistration_CreatesTheDefinitionOverAReadWriteConnection_ThenStartsOverItsOwnReadOnlyOne()
+ {
+ var rig = BuildRig("beta", readOnlyIntent: true);
+
+ await rig.ReconcileAsync();
+
+ Assert.Equal(
+ new[] { LongQueryTraceStep.Check, LongQueryTraceStep.CreateDefinition, LongQueryTraceStep.Start },
+ rig.Steps.Select(s => s.Step));
+ Assert.Equal(
+ new[] { ApplicationIntent.ReadOnly, ApplicationIntent.ReadWrite, ApplicationIntent.ReadOnly },
+ rig.Steps.Select(s => IntentOf(s.ConnectionString)));
+ Assert.All(rig.Steps, s => Assert.Equal("beta", DatabaseOf(s.ConnectionString)));
+ Assert.All(rig.Steps, s => Assert.Equal("beta", s.Database));
+ Assert.Empty(rig.Loud());
+ }
+
+ [Fact]
+ public async Task Azure_ARegistrationWithoutReadOnlyIntent_CreatesAndStartsOverItsOwnConnection()
+ {
+ var rig = BuildRig("beta", readOnlyIntent: false);
+
+ await rig.ReconcileAsync();
+
+ var step = Assert.Single(rig.Steps);
+ Assert.Equal(LongQueryTraceStep.CreateAndStart, step.Step);
+ Assert.Equal(ApplicationIntent.ReadWrite, IntentOf(step.ConnectionString));
+ Assert.Equal("beta", DatabaseOf(step.ConnectionString));
+ }
+
+ /* ── Azure SQL Database: a cycle that needs nothing opens nothing on the primary ── */
+
+ [Fact]
+ public async Task Azure_AReadOnlyIntentCycleWhereTheDefinitionExistsAndTheSessionRuns_OpensOnlyTheRegistrationsOwnConnection()
+ {
+ var rig = BuildRig("beta", readOnlyIntent: true);
+ rig.Replica = new LongQueryTraceReplicaState(DefinitionExists: true, Running: true);
+
+ await rig.ReconcileAsync();
+
+ var step = Assert.Single(rig.Steps);
+ Assert.Equal(LongQueryTraceStep.Check, step.Step);
+ Assert.Equal(ApplicationIntent.ReadOnly, IntentOf(step.ConnectionString));
+ Assert.Equal("beta", DatabaseOf(step.ConnectionString));
+ }
+
+ [Fact]
+ public async Task Azure_AReadOnlyIntentCycleWhereTheSessionIsStopped_StartsItOverItsOwnConnectionAndOpensNothingElse()
+ {
+ var rig = BuildRig("beta", readOnlyIntent: true);
+ rig.Replica = new LongQueryTraceReplicaState(DefinitionExists: true, Running: false);
+
+ await rig.ReconcileAsync();
+
+ Assert.Equal(new[] { LongQueryTraceStep.Check, LongQueryTraceStep.Start }, rig.Steps.Select(s => s.Step));
+ Assert.All(rig.Steps, s => Assert.Equal(ApplicationIntent.ReadOnly, IntentOf(s.ConnectionString)));
+ }
+
+ [Fact]
+ public async Task Azure_AStartThatFailsRightAfterTheDefinitionWasCreated_IsRetriedOnALaterPassWithoutAWarning()
+ {
+ var rig = BuildRig("beta", readOnlyIntent: true);
+ rig.Refusal = step => step == LongQueryTraceStep.Start
+ ? SqlExceptionFactory.Create(15151, 16, "Cannot alter the event session, because it does not exist or you do not have permission.")
+ : null;
+
+ await rig.ReconcileAsync();
+
+ /* The replica had not caught up yet: no fault, and nothing at Warning or above. */
+ Assert.Null(rig.State.LongQueryTraceFault);
+ Assert.DoesNotContain(rig.Logger.Entries, e => e.Level >= LogLevel.Warning);
+ Assert.Equal(
+ new[] { LongQueryTraceStep.Check, LongQueryTraceStep.CreateDefinition, LongQueryTraceStep.Start },
+ rig.Steps.Select(s => s.Step));
+
+ /* The replica shows the definition by the next pass of the create side: that pass starts the session. */
+ rig.Refusal = _ => null;
+ rig.Replica = new LongQueryTraceReplicaState(DefinitionExists: true, Running: false);
+ rig.Steps.Clear();
+ rig.Clock += LongQueryTraceDatabases.RetryInterval;
+ await rig.ReconcileAsync();
+
+ Assert.Equal(new[] { LongQueryTraceStep.Check, LongQueryTraceStep.Start }, rig.Steps.Select(s => s.Step));
+ Assert.Null(rig.State.LongQueryTraceFault);
+ Assert.DoesNotContain(rig.Logger.Entries, e => e.Level >= LogLevel.Warning);
+ }
+
+ [Fact]
+ public async Task Azure_AStartThatFailsRightAfterTheDefinitionWasCreated_LeavesTheLatchUnsetSoTheNextSweepRetries()
+ {
+ var rig = BuildRig("beta", readOnlyIntent: true);
+ rig.Refusal = step => step == LongQueryTraceStep.Start
+ ? SqlExceptionFactory.Create(15151, 16, "Cannot alter the event session, because it does not exist or you do not have permission.")
+ : null;
+
+ await rig.ReconcileAsync();
+
+ /* Not applied: a latch would hold the retry back until the hourly create pass. */
+ Assert.Null(rig.State.LongQueryTraceApplied);
+ Assert.Null(rig.State.LongQueryTraceFault);
+
+ /* The next sweep, with the clock where it was, checks again and starts the session, and only then is it applied. */
+ rig.Refusal = _ => null;
+ rig.Replica = new LongQueryTraceReplicaState(DefinitionExists: true, Running: false);
+ rig.Steps.Clear();
+ await rig.ReconcileAsync();
+
+ Assert.Equal(new[] { LongQueryTraceStep.Check, LongQueryTraceStep.Start }, rig.Steps.Select(s => s.Step));
+ Assert.True(rig.State.LongQueryTraceApplied);
+ }
+
+ [Fact]
+ public async Task Azure_AStartThatFailsWhenTheDefinitionWasAlreadyVisible_IsAFailureLikeAnyOther()
+ {
+ var rig = BuildRig("beta", readOnlyIntent: true);
+ rig.Replica = new LongQueryTraceReplicaState(DefinitionExists: true, Running: false);
+ rig.Refusal = step => step == LongQueryTraceStep.Start
+ ? SqlExceptionFactory.Create(262, 14, "ALTER EVENT SESSION permission denied.")
+ : null;
+
+ await rig.ReconcileAsync();
+
+ Assert.NotNull(rig.State.LongQueryTraceFault);
+ Assert.Contains(rig.Logger.Entries, e => e.Level >= LogLevel.Warning);
+ }
+
+ /* ── Azure SQL Database: a drop stops the session over the replica, then drops the definition over the primary ── */
+
+ [Fact]
+ public async Task Azure_TurningTheTraceOff_ForAReadOnlyIntentRegistration_StopsOverItsOwnConnection_ThenDropsOverAReadWriteOne()
+ {
+ var rig = BuildRig("beta", readOnlyIntent: true);
+
+ await rig.ReconcileAsync(enabled: false);
+
+ var drops = rig.OwnDrops();
+ Assert.Equal(new[] { LongQueryTraceStep.Stop, LongQueryTraceStep.Drop }, drops.Select(d => d.Step));
+ Assert.Equal(new[] { ApplicationIntent.ReadOnly, ApplicationIntent.ReadWrite }, drops.Select(d => IntentOf(d.ConnectionString)));
+ Assert.All(drops, d => Assert.Equal("beta", DatabaseOf(d.ConnectionString)));
+ Assert.All(drops, d => Assert.Equal("beta", d.Database));
+ Assert.Empty(rig.Steps);
+ }
+
+ [Fact]
+ public async Task Azure_TurningTheTraceOff_ForARegistrationWithoutReadOnlyIntent_DropsOnceOverItsOwnConnection()
+ {
+ var rig = BuildRig("beta", readOnlyIntent: false);
+
+ await rig.ReconcileAsync(enabled: false);
+
+ var drop = Assert.Single(rig.OwnDrops());
+ Assert.Equal(LongQueryTraceStep.Drop, drop.Step);
+ Assert.Equal(ApplicationIntent.ReadWrite, IntentOf(drop.ConnectionString));
+ Assert.Equal("beta", DatabaseOf(drop.ConnectionString));
+ }
+
+ [Fact]
+ public async Task Azure_TheOneTimeDropOfTheLegacySession_ForAReadOnlyIntentRegistration_FollowsTheSameOrder()
+ {
+ var rig = BuildRig("beta", readOnlyIntent: true);
+
+ await rig.ReconcileAsync();
+
+ var drops = rig.LegacyDrops();
+ Assert.Equal(new[] { LongQueryTraceStep.Stop, LongQueryTraceStep.Drop }, drops.Select(d => d.Step));
+ Assert.Equal(new[] { ApplicationIntent.ReadOnly, ApplicationIntent.ReadWrite }, drops.Select(d => IntentOf(d.ConnectionString)));
+ Assert.All(drops, d => Assert.Equal("beta", DatabaseOf(d.ConnectionString)));
+ }
+
+ [Fact]
+ public async Task Azure_TheOneTimeDropOfTheLegacySession_ForARegistrationWithoutReadOnlyIntent_IsOneDropOverItsOwnConnection()
+ {
+ var rig = BuildRig("beta", readOnlyIntent: false);
+
+ await rig.ReconcileAsync();
+
+ var drop = Assert.Single(rig.LegacyDrops());
+ Assert.Equal(LongQueryTraceStep.Drop, drop.Step);
+ Assert.Equal(ApplicationIntent.ReadWrite, IntentOf(drop.ConnectionString));
+ }
+
+ [Fact]
+ public async Task Azure_ADatabaseAnotherRegistrationKeeps_GetsNeitherStepOfTheDrop()
+ {
+ var rig = BuildRig("beta", readOnlyIntent: true);
+ var other = new LongQueryTraceRegistration("77", Host, Database: null, Enabled: true, TraceOn: true, Array.Empty(), DatabaseScope: null);
+
+ await rig.ReconcileAsync(enabled: false, new[] { other });
+
+ Assert.Empty(rig.OwnDrops());
+ }
+
+ /* ── Every other engine ── */
+
+ [Fact]
+ public async Task OnPrem_AReadOnlyIntentRegistration_CreatesAndStartsItsSessionOverItsOwnConnection()
+ {
+ var rig = BuildRig("app", readOnlyIntent: true, azureSqlDatabase: false);
+
+ await rig.ReconcileAsync();
+
+ var step = Assert.Single(rig.Steps);
+ Assert.Equal(LongQueryTraceStep.CreateAndStart, step.Step);
+ Assert.Equal(string.Empty, step.Database);
+ Assert.Equal(ApplicationIntent.ReadOnly, IntentOf(step.ConnectionString));
+ Assert.Equal(rig.State.Runtime!.ConnectionString, step.ConnectionString);
+ }
+
+ [Fact]
+ public async Task OnPrem_TurningTheTraceOff_DropsOnceOverTheRegistrationsOwnConnection()
+ {
+ var rig = BuildRig("app", readOnlyIntent: true, azureSqlDatabase: false);
+
+ await rig.ReconcileAsync(enabled: false);
+
+ var own = Assert.Single(rig.OwnDrops());
+ Assert.Equal(LongQueryTraceStep.Drop, own.Step);
+ Assert.Equal(string.Empty, own.Database);
+ Assert.Equal(ApplicationIntent.ReadOnly, IntentOf(own.ConnectionString));
+ Assert.Equal(rig.State.Runtime!.ConnectionString, own.ConnectionString);
+
+ var legacy = Assert.Single(rig.LegacyDrops());
+ Assert.Equal(LongQueryTraceStep.Drop, legacy.Step);
+ Assert.Equal(rig.State.Runtime!.ConnectionString, legacy.ConnectionString);
+ }
+
+ /* ── A registration that lands on a read-only database without the intent ── */
+
+ [Fact]
+ public async Task Azure_AReadOnlyDatabaseWithoutTheIntent_LogsOneClearMessage_KeepsTheFault_AndDoesNotRetryInALoop()
+ {
+ var rig = BuildRig("beta", readOnlyIntent: false);
+ rig.Refusal = step => step is LongQueryTraceStep.Stop or LongQueryTraceStep.Drop ? null : ReadOnlyDatabaseRefusal();
+
+ await rig.ReconcileAsync();
+
+ /* One line at Warning or above, whatever it says: the read-only message, which says why and what to change. */
+ var loud = Assert.Single(rig.Loud());
+ Assert.Equal(rig.ReadOnlyLine("beta"), loud);
+ Assert.Contains("read-only", loud, StringComparison.Ordinal);
+ Assert.Contains("3906", loud, StringComparison.Ordinal);
+ Assert.Contains("Register the primary database instead", loud, StringComparison.Ordinal);
+ Assert.Contains("turn the long-query trace off", loud, StringComparison.Ordinal);
+
+ /* The run still records the fault. */
+ Assert.NotNull(rig.State.LongQueryTraceFault);
+ Assert.Single(rig.Steps);
+
+ /* Within the hour: no attempt, no line, and the fault stays. */
+ rig.Clock += LongQueryTraceDatabases.RetryInterval - TimeSpan.FromMinutes(1);
+ await rig.ReconcileAsync();
+ await rig.ReconcileAsync();
+ Assert.Single(rig.Steps);
+ Assert.Equal(new[] { rig.ReadOnlyLine("beta") }, rig.Loud());
+ Assert.NotNull(rig.State.LongQueryTraceFault);
+
+ /* An hour after the first attempt: one more, and it logs at Debug, so the one message stays the only one. */
+ rig.Clock += TimeSpan.FromMinutes(1);
+ await rig.ReconcileAsync();
+ await rig.ReconcileAsync();
+ Assert.Equal(2, rig.Steps.Count);
+ Assert.Equal(new[] { rig.ReadOnlyLine("beta") }, rig.Loud());
+ Assert.NotNull(rig.State.LongQueryTraceFault);
+ }
+
+ [Fact]
+ public async Task Azure_AReadOnlyDatabaseWithoutTheIntent_TheFaultNamesTheSessionNotTheDatabaseError()
+ {
+ var rig = BuildRig("beta", readOnlyIntent: false);
+ rig.Refusal = step => step is LongQueryTraceStep.Stop or LongQueryTraceStep.Drop ? null : ReadOnlyDatabaseRefusal();
+
+ await rig.ReconcileAsync();
+
+ Assert.Contains(
+ LongQueryCompletionsCollector.XeSessionNameFor(LongQueryCompletionsCollector.DarlingProduct, InstallIdValue),
+ rig.State.LongQueryTraceFault,
+ StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public async Task Azure_ARefusalThatIsNotReadOnly_StillRetriesOnEverySweep()
+ {
+ var rig = BuildRig("beta", readOnlyIntent: false);
+ rig.Refusal = _ => SqlExceptionFactory.Create(262, 14, "CREATE EVENT SESSION permission denied.");
+
+ await rig.ReconcileAsync();
+ await rig.ReconcileAsync();
+ await rig.ReconcileAsync();
+
+ Assert.Equal(3, rig.Steps.Count);
+ }
+
+ /* ── A failed create or start on Azure SQL Database carries the caps sentence (#4961, plan test 27) ── */
+
+ private static bool Speaks(string line, string text) => line.Contains(text, StringComparison.Ordinal);
+
+ private static List LoggedLines(Rig rig) => rig.Logger.Entries.Select(e => e.Message).ToList();
+
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public async Task Azure_AFailedCreateOrStart_CarriesTheCapsSentence_InTheLoggedLineAndTheRecordedFault(bool start)
+ {
+ /* A registration without the intent creates and starts in one step. One with it creates the definition, then starts. */
+ var rig = BuildRig("beta", readOnlyIntent: start);
+ var refused = start ? LongQueryTraceStep.Start : LongQueryTraceStep.CreateAndStart;
+ rig.Refusal = step => step == refused ? SqlExceptionFactory.Create(1105, 17, "The statement was refused.") : null;
+
+ await rig.ReconcileAsync();
+
+ Assert.Contains(LoggedLines(rig), line => Speaks(line, "The statement was refused.") && Speaks(line, AlwaysOnXeSessions.AzureCapsSentence));
+ Assert.NotNull(rig.State.LongQueryTraceFault);
+ Assert.Contains(AlwaysOnXeSessions.AzureCapsSentence, rig.State.LongQueryTraceFault, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public async Task OnPrem_AFailedCreate_CarriesNoCapsSentence()
+ {
+ var rig = BuildRig("app", readOnlyIntent: false, azureSqlDatabase: false);
+ rig.Refusal = _ => SqlExceptionFactory.Create(1105, 17, "The statement was refused.");
+
+ await rig.ReconcileAsync();
+
+ var lines = LoggedLines(rig);
+ Assert.Contains(lines, line => Speaks(line, "The statement was refused."));
+ Assert.DoesNotContain(lines, line => Speaks(line, AlwaysOnXeSessions.AzureCapsSentence));
+ Assert.NotNull(rig.State.LongQueryTraceFault);
+ Assert.DoesNotContain(AlwaysOnXeSessions.AzureCapsSentence, rig.State.LongQueryTraceFault, StringComparison.Ordinal);
+ }
+
+ [Theory]
+ [InlineData(25631)]
+ [InlineData(25705)]
+ public async Task Azure_AnAlreadyThereAnswer_CarriesNoCapsSentence(int number)
+ {
+ var rig = BuildRig("beta", readOnlyIntent: false);
+ rig.Refusal = _ => SqlExceptionFactory.Create(number, 16, "The session is already there.");
+
+ await rig.ReconcileAsync();
+
+ Assert.DoesNotContain(LoggedLines(rig), line => Speaks(line, AlwaysOnXeSessions.AzureCapsSentence));
+ Assert.NotNull(rig.State.LongQueryTraceFault);
+ Assert.DoesNotContain(AlwaysOnXeSessions.AzureCapsSentence, rig.State.LongQueryTraceFault, StringComparison.Ordinal);
+ }
+
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public async Task Azure_AReadOnlyDatabasesRefusalOfACreateOrStart_CarriesNoCapsSentence(bool start)
+ {
+ var rig = BuildRig("beta", readOnlyIntent: start);
+ var refused = start ? LongQueryTraceStep.Start : LongQueryTraceStep.CreateAndStart;
+ rig.Refusal = step => step == refused ? ReadOnlyDatabaseRefusal() : null;
+
+ await rig.ReconcileAsync();
+
+ Assert.DoesNotContain(LoggedLines(rig), line => Speaks(line, AlwaysOnXeSessions.AzureCapsSentence));
+ Assert.NotNull(rig.State.LongQueryTraceFault);
+ Assert.DoesNotContain(AlwaysOnXeSessions.AzureCapsSentence, rig.State.LongQueryTraceFault, StringComparison.Ordinal);
+ }
+
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public async Task Azure_APartialFailureOfTheCreate_CarriesTheCapsSentenceInTheRunsNote_UnlessItIsAReadOnlyRefusal(bool readOnlyRefusal)
+ {
+ /* One of two databases refuses the create: the run reads the other, and its note says the first is not in it. */
+ var rig = BuildRig("beta", readOnlyIntent: false);
+ rig.Listed = new List { "beta", "gamma" };
+ rig.Runner.LongQueryTraceStepOverrideForTests = (_, databaseName, _, _, _, _) =>
+ databaseName == "beta"
+ ? Task.FromException(readOnlyRefusal ? ReadOnlyDatabaseRefusal() : SqlExceptionFactory.Create(1105, 17, "The statement was refused."))
+ : Task.CompletedTask;
+
+ await rig.ReconcileAsync();
+
+ var note = rig.State.LongQueryTracePartialNote;
+ Assert.NotNull(note);
+ Assert.Contains("beta", note, StringComparison.Ordinal);
+ if (readOnlyRefusal)
+ {
+ Assert.DoesNotContain(AlwaysOnXeSessions.AzureCapsSentence, note, StringComparison.Ordinal);
+ }
+ else
+ {
+ Assert.Contains("The statement was refused.", note, StringComparison.Ordinal);
+ Assert.Contains(AlwaysOnXeSessions.AzureCapsSentence, note, StringComparison.Ordinal);
+ }
+ }
+
+ private static SqlException ReadOnlyDatabaseRefusal() =>
+ SqlExceptionFactory.Create(LongQueryTraceDatabases.ReadOnlyDatabaseErrorNumber, 16, "Failed to update database because the database is read-only.");
+
+ /// SqlException has no public constructor; this builds one through the driver's internals.
+ internal static class SqlExceptionFactory
+ {
+ public static SqlException Create(int number, byte errorClass, string message)
+ {
+ const BindingFlags all = BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static;
+ var errorCtor = typeof(SqlError).GetConstructors(BindingFlags.NonPublic | BindingFlags.Instance)
+ .OrderByDescending(c => c.GetParameters().Length).First();
+ var ints = new Queue(new object[] { number, 0, 0, 0 });
+ var bytes = new Queue(new object[] { (byte)0, errorClass, (byte)0, (byte)0 });
+ var strings = new Queue(new object[] { "server", message, "procedure", "extra", "extra" });
+ var args = errorCtor.GetParameters().Select(p =>
+ p.ParameterType == typeof(int) ? ints.Dequeue()
+ : p.ParameterType == typeof(byte) ? bytes.Dequeue()
+ : p.ParameterType == typeof(string) ? strings.Dequeue()
+ : p.ParameterType == typeof(uint) ? (object)0u
+ : p.ParameterType == typeof(Exception) ? null!
+ : p.HasDefaultValue ? p.DefaultValue! : throw new InvalidOperationException($"unmapped SqlError ctor parameter {p.ParameterType}")).ToArray();
+ var error = errorCtor.Invoke(args);
+
+ var collection = Activator.CreateInstance(typeof(SqlErrorCollection), true)!;
+ typeof(SqlErrorCollection).GetMethod("Add", all, null, new[] { typeof(SqlError) }, null)!.Invoke(collection, new[] { error });
+
+ var create = typeof(SqlException).GetMethods(all)
+ .Where(m => m.Name == "CreateException" && m.GetParameters().Length >= 2
+ && m.GetParameters()[0].ParameterType == typeof(SqlErrorCollection)
+ && m.GetParameters()[1].ParameterType == typeof(string))
+ .OrderBy(m => m.GetParameters().Length).First();
+ var createArgs = create.GetParameters().Select((p, i) =>
+ i == 0 ? collection
+ : i == 1 ? "11.0"
+ : p.ParameterType == typeof(Guid) ? Guid.Empty
+ : p.HasDefaultValue ? p.DefaultValue! : null!).ToArray();
+ return (SqlException)create.Invoke(null, createArgs)!;
+ }
+ }
+}
diff --git a/Darling/Darling.Tests/LongQueryTraceRemovalTests.cs b/Darling/Darling.Tests/LongQueryTraceRemovalTests.cs
new file mode 100644
index 0000000000..e87c58ba94
--- /dev/null
+++ b/Darling/Darling.Tests/LongQueryTraceRemovalTests.cs
@@ -0,0 +1,575 @@
+/*
+ * 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.Globalization;
+using System.Linq;
+using System.Text.RegularExpressions;
+using System.Threading;
+using System.Threading.Tasks;
+using Npgsql;
+using PerformanceMonitor.Collectors;
+using PerformanceMonitor.Darling.Service;
+using Xunit;
+using static Darling.Tests.RepoFile;
+using LogLevel = Microsoft.Extensions.Logging.LogLevel;
+
+namespace Darling.Tests;
+
+///
+/// Removing a server drops this install's Extended Events sessions on it, and nothing else (#4961): the long-query
+/// trace's session, then the deadlock and blocked-process sessions this install chose for itself in the server's
+/// databases. Each test drives the removal's real step with the database list and the per-database work replaced on the
+/// runner, so no server or store is needed.
+///
+public sealed class LongQueryTraceRemovalTests : IAsyncDisposable
+{
+ private const string Host = "lqremoval.database.windows.net";
+ private const string InstallIdValue = "0a1b2c3d";
+ private static readonly string OwnSession = LongQueryCompletionsCollector.XeSessionNameFor(LongQueryCompletionsCollector.DarlingProduct, InstallIdValue);
+
+ /* Never opened: the runner reaches the store only to write collected rows, and these tests collect nothing. */
+ private readonly NpgsqlDataSource _store = NpgsqlDataSource.Create("Host=127.0.0.1;Port=1;Database=unused;Username=unused");
+
+ public ValueTask DisposeAsync() => _store.DisposeAsync();
+
+ private sealed class RecordingDatabase : IAlwaysOnXeDatabase
+ {
+ private readonly List _statements;
+
+ public RecordingDatabase(List statements) => _statements = statements;
+
+ public Task ReadCatalogAsync(AlwaysOnXeSessionKind kind, string sessionName, CancellationToken cancellationToken) =>
+ Task.FromResult(AlwaysOnXeCatalog.Present);
+
+ public Task IsStartedAsync(string sessionName, CancellationToken cancellationToken) => Task.FromResult(true);
+
+ public Task ExecuteAsync(string statement, CancellationToken cancellationToken)
+ {
+ _statements.Add(statement);
+ return Task.CompletedTask;
+ }
+
+ public bool IsAlreadyPresent(Exception exception) => false;
+ }
+
+ private sealed class Rig
+ {
+ public required DarlingCollectorRunner Runner { get; init; }
+ public required MonitoredServer Config { get; init; }
+ public required ServerRuntime Runtime { get; init; }
+ public bool? TraceApplied { get; set; } = true;
+ public DarlingSelfAlertTests.CapturingLogger Logger { get; } = new();
+
+ /* What the logical server's master lists, before the registration's exclusions. */
+ public List Listed { get; } = new() { "master", "alpha", "beta", "gamma" };
+ public int ListCalls { get; set; }
+
+ /* The per-install session's work: the database each call ran in (the server's own session is the empty name). */
+ public List<(string Database, bool Create)> Calls { get; } = new();
+ public List Names { get; } = new();
+ public HashSet Refuse { get; } = new(StringComparer.OrdinalIgnoreCase);
+ public Func? Hang { get; set; }
+
+ /* The legacy session's drops, which a removal never runs. */
+ public List LegacyCalls { get; } = new();
+ public LongQueryTraceLifecycleTests.InMemoryLegacyRecords Records { get; } = new();
+
+ /* The statements the always-on fallbacks' drops ran, by database. */
+ public Dictionary> Statements { get; } = new(StringComparer.OrdinalIgnoreCase);
+
+ /* The registrations that remain, the ids among them whose long-query trace is on, and what the on-premises guard says. */
+ public List Remaining { get; } = new();
+ public HashSet TraceOn { get; } = new();
+ public LongQueryTraceInstanceGuard Guard { get; set; } = LongQueryTraceInstanceGuard.NoKeepers;
+
+ public RemovedLongQueryServer Removed => new(Config, Runtime, TraceApplied);
+
+ public IEnumerable Dropped => Calls.Where(c => !c.Create).Select(c => c.Database);
+
+ public Task RemoveAsync(CancellationToken cancellationToken = default) =>
+ DarlingRemovedServerSessions.DropAsync(
+ Removed, Runner, Remaining, id => TraceOn.Contains(id), _ => Task.FromResult(Guard), Logger, cancellationToken);
+ }
+
+ private Rig BuildRig(bool azureSqlDatabase = true, string? installId = InstallIdValue)
+ {
+ var config = new MonitoredServer { Name = "lqremoval", Host = azureSqlDatabase ? Host : "lqremoval-sql" };
+ var runtime = new ServerRuntime
+ {
+ Config = config,
+ ConnectionString = $"Server=tcp:{config.Host},1433;Initial Catalog=master;Encrypt=True",
+ Target = new CollectorTargetInfo { IsAzureSqlDb = azureSqlDatabase },
+ StorageName = config.StorageName,
+ ServerId = config.ServerId,
+ };
+
+ var runner = new DarlingCollectorRunner(
+ _store,
+ new CollectorDeltaCalculator(),
+ databaseScope: (_, _) => new List(),
+ separatelyMonitoredDatabases: _ => new List(),
+ installId: () => installId);
+
+ var rig = new Rig { Runner = runner, Config = config, Runtime = runtime };
+ runner.LegacyLongQueryRecordsForTests = rig.Records;
+ runner.LongQueryTraceListOverrideForTests = (_, _, _, _) =>
+ {
+ rig.ListCalls++;
+ return Task.FromResult(rig.Listed.ToList());
+ };
+
+ runner.LongQueryTraceDatabaseOverrideForTests = async (_, database, create, sessionName, token) =>
+ {
+ if (sessionName == LongQueryCompletionsCollector.LegacyXeSessionName)
+ {
+ rig.LegacyCalls.Add(database);
+ return;
+ }
+
+ rig.Calls.Add((database, create));
+ rig.Names.Add(sessionName);
+ if (rig.Hang is { } hang)
+ {
+ await hang(token);
+ }
+
+ if (rig.Refuse.Contains(database))
+ {
+ throw new InvalidOperationException($"The drop was refused in {database}.");
+ }
+ };
+
+ runner.AlwaysOnXeDatabaseForTests = (_, database, _) =>
+ {
+ if (!rig.Statements.TryGetValue(database, out var list))
+ {
+ rig.Statements[database] = list = new List();
+ }
+
+ return Task.FromResult(new RecordingDatabase(list));
+ };
+
+ return rig;
+ }
+
+ ///
+ /// Another registration of the logical server, with read-only intent so that it has an id of its own: the id comes from
+ /// the host, the database and the intent, not the name.
+ ///
+ private static MonitoredServer Another(string host, params string[] excluded) =>
+ new() { Name = "lqremoval-other", Host = host, ReadOnlyIntent = true, ExcludedDatabases = excluded.ToList() };
+
+ private static string KeyOf(MonitoredServer server) => server.ServerId.ToString(CultureInfo.InvariantCulture);
+
+ private static string Drop(Rig rig, AlwaysOnXeSessionKind kind) =>
+ AlwaysOnXeSessions.BuildAzureDropSql(kind, rig.Runner.AlwaysOnOwnSessionName(kind)!);
+
+ private static LongQueryTraceInstance Instance(bool traceOn, string? name) => new(Enabled: true, TraceOn: traceOn, name);
+
+ ///
+ /// The removal's drop, Azure: this install's session in each listed database but master, and not in a database where
+ /// another registration of the logical server keeps it. The legacy session's one-time drop is not the removal's.
+ ///
+ [Fact]
+ public async Task Removal_Azure_DropsThisInstallsSessionEverywhere_ExceptWhereAnotherRegistrationKeepsIt()
+ {
+ var rig = BuildRig();
+ /* A second registration of the logical server, with its trace on, keeps the session in the one database it does not exclude. */
+ var other = Another(Host, "alpha", "gamma");
+ rig.Remaining.Add(other);
+ rig.TraceOn.Add(other.ServerId);
+ Assert.NotEqual(rig.Config.ServerId, other.ServerId);
+
+ await rig.RemoveAsync();
+
+ Assert.Equal(new[] { "alpha", "gamma" }, rig.Dropped);
+ Assert.DoesNotContain(rig.Calls, c => c.Create);
+ Assert.Equal(rig.Calls.Count, rig.Names.Count);
+ Assert.All(rig.Names, name => Assert.Equal(OwnSession, name));
+ Assert.Empty(rig.LegacyCalls);
+ Assert.Equal(0, rig.Records.Reads);
+ Assert.Equal(0, rig.Records.Writes);
+ }
+
+ /// With no other registration, every listed database but master loses the session.
+ [Fact]
+ public async Task Removal_Azure_DropsEveryListedDatabaseButMaster_WhenNoOtherRegistrationKeepsIt()
+ {
+ var rig = BuildRig();
+
+ await rig.RemoveAsync();
+
+ Assert.Equal(new[] { "alpha", "beta", "gamma" }, rig.Dropped);
+ }
+
+ [Fact]
+ public async Task Removal_OnPremises_DropsThisInstallsServerScopedSession_Once()
+ {
+ var rig = BuildRig(azureSqlDatabase: false);
+
+ await rig.RemoveAsync();
+
+ var drop = Assert.Single(rig.Calls);
+ Assert.False(drop.Create);
+ Assert.Equal(string.Empty, drop.Database);
+ Assert.Equal(OwnSession, Assert.Single(rig.Names));
+ Assert.Empty(rig.LegacyCalls);
+ Assert.Equal(0, rig.Records.Reads);
+ }
+
+ [Fact]
+ public async Task Removal_OnPremises_LeavesTheSession_WhileAnotherRegistrationOfTheSameInstanceKeepsIt_AndSaysSo()
+ {
+ var rig = BuildRig(azureSqlDatabase: false);
+ rig.Guard = new LongQueryTraceInstanceGuard("sql01", new[] { Instance(traceOn: true, "SQL01") });
+
+ await rig.RemoveAsync();
+
+ Assert.Empty(rig.Calls);
+ Assert.Contains(rig.Logger.Entries, e =>
+ e.Level == LogLevel.Information
+ && e.Message.Contains(rig.Config.DisplayName, StringComparison.Ordinal)
+ && e.Message.Contains("keeps it on the same instance", StringComparison.Ordinal));
+ }
+
+ [Fact]
+ public async Task Removal_OnPremises_WithNoNameKnown_LeavesTheSession_WhenAnotherRegistrationCouldKeepIt_AndSaysWhy()
+ {
+ var rig = BuildRig(azureSqlDatabase: false);
+ rig.Guard = new LongQueryTraceInstanceGuard(null, new[] { Instance(traceOn: true, "sql01") });
+
+ await rig.RemoveAsync();
+
+ Assert.Empty(rig.Calls);
+ Assert.Contains(rig.Logger.Entries, e =>
+ e.Level == LogLevel.Information
+ && e.Message.Contains(rig.Config.DisplayName, StringComparison.Ordinal)
+ && e.Message.Contains("instance name is not known", StringComparison.Ordinal));
+ }
+
+ [Fact]
+ public async Task Removal_OnPremises_WithNoNameKnown_StillDrops_WhenNoOtherRegistrationCouldKeepIt()
+ {
+ var rig = BuildRig(azureSqlDatabase: false);
+ rig.Guard = new LongQueryTraceInstanceGuard(null, new[] { Instance(traceOn: false, "sql01") });
+
+ await rig.RemoveAsync();
+
+ Assert.Single(rig.Calls, c => !c.Create);
+ }
+
+ [Fact]
+ public async Task Removal_OnPremises_DropsTheSession_WhenTheOtherRegistrationOfTheInstanceHasItsTraceOff()
+ {
+ var rig = BuildRig(azureSqlDatabase: false);
+ rig.Guard = new LongQueryTraceInstanceGuard("sql01", new[] { Instance(traceOn: false, "sql01") });
+
+ await rig.RemoveAsync();
+
+ Assert.Single(rig.Calls, c => !c.Create);
+ }
+
+ /// A server whose last finished reconcile dropped the session has none to remove, so the removal opens nothing on it.
+ [Theory]
+ [InlineData(true)]
+ [InlineData(false)]
+ public async Task Removal_OfAServerWhoseTraceIsOff_TouchesNothing(bool azureSqlDatabase)
+ {
+ var rig = BuildRig(azureSqlDatabase);
+ rig.TraceApplied = false;
+ var guardAsked = false;
+
+ await DarlingRemovedServerSessions.DropAsync(
+ rig.Removed, rig.Runner, rig.Remaining, _ => false, _ => { guardAsked = true; return Task.FromResult(rig.Guard); }, rig.Logger, CancellationToken.None);
+
+ Assert.Empty(rig.Calls);
+ Assert.Equal(0, rig.ListCalls);
+ Assert.False(guardAsked);
+ Assert.Empty(rig.Statements);
+ }
+
+ /// One attempt, and nothing it hits stops the removal: a refused drop, a token that has run out, and a drop that hangs past the timeout all return.
+ [Fact]
+ public async Task Removal_ADropThatFails_OrTimesOut_NeverThrows_AndTriesOnce()
+ {
+ var rig = BuildRig();
+ rig.Refuse.Add("beta");
+
+ await rig.RemoveAsync();
+
+ Assert.Equal(new[] { "alpha", "beta", "gamma" }, rig.Dropped);
+ Assert.Contains(rig.Logger.Entries, e =>
+ e.Level == LogLevel.Warning
+ && e.Message.Contains(rig.Config.DisplayName, StringComparison.Ordinal)
+ && e.Message.Contains("removed", StringComparison.OrdinalIgnoreCase));
+
+ rig.Calls.Clear();
+ using var spent = new CancellationTokenSource();
+ spent.Cancel();
+ await rig.RemoveAsync(spent.Token);
+ Assert.Empty(rig.Calls);
+
+ rig.Calls.Clear();
+ rig.Refuse.Clear();
+ rig.Logger.Entries.Clear();
+ rig.Hang = token => Task.Delay(Timeout.Infinite, token);
+ using var timeout = new CancellationTokenSource(TimeSpan.FromMilliseconds(100));
+ await rig.RemoveAsync(timeout.Token);
+
+ Assert.Single(rig.Calls);
+ Assert.Contains(rig.Logger.Entries, e =>
+ e.Level == LogLevel.Warning
+ && e.Message.Contains(rig.Config.DisplayName, StringComparison.Ordinal)
+ && e.Message.Contains("not dropped within", StringComparison.Ordinal));
+ }
+
+ [Fact]
+ public async Task Removal_WithNoInstallId_DropsNothing()
+ {
+ var rig = BuildRig(installId: null);
+
+ await rig.RemoveAsync();
+
+ Assert.Empty(rig.Calls);
+ Assert.Equal(0, rig.ListCalls);
+ Assert.Empty(rig.Statements);
+ }
+
+ ///
+ /// The deadlock and blocked-process sessions this install chose for itself in the removed server's databases are dropped
+ /// by their own names, except in a database where another registration of the same server reads its own session. A
+ /// database where the install reads the shared session is left alone: the shared session is never dropped. The server's
+ /// choices are forgotten.
+ ///
+ [Fact]
+ public async Task Removal_Azure_DropsTheDeadlockAndBlockedProcessSessionsThisInstallChose_NeverTheSharedOnes()
+ {
+ var rig = BuildRig();
+ rig.TraceApplied = false;
+ var other = Another(Host);
+ rig.Remaining.Add(other);
+ var choices = rig.Runner.AlwaysOnChoices;
+ var key = KeyOf(rig.Config);
+ choices.Set(key, "alpha", AlwaysOnXeSessionKind.Deadlock, AlwaysOnXeChoice.Own);
+ choices.Set(key, "beta", AlwaysOnXeSessionKind.Deadlock, AlwaysOnXeChoice.Own);
+ choices.Set(key, "beta", AlwaysOnXeSessionKind.BlockedProcess, AlwaysOnXeChoice.Own);
+ choices.Set(key, "gamma", AlwaysOnXeSessionKind.BlockedProcess, AlwaysOnXeChoice.Shared);
+ choices.Set(KeyOf(other), "beta", AlwaysOnXeSessionKind.Deadlock, AlwaysOnXeChoice.Own);
+
+ await rig.RemoveAsync();
+
+ Assert.Equal(new[] { Drop(rig, AlwaysOnXeSessionKind.Deadlock) }, rig.Statements["alpha"]);
+ Assert.Equal(new[] { Drop(rig, AlwaysOnXeSessionKind.BlockedProcess) }, rig.Statements["beta"]);
+ Assert.False(rig.Statements.ContainsKey("gamma"));
+ Assert.DoesNotContain(
+ rig.Statements.Values.SelectMany(s => s),
+ statement => statement.Contains("[" + AlwaysOnXeSessions.SharedNameFor(AlwaysOnXeSessionKind.Deadlock) + "]", StringComparison.Ordinal)
+ || statement.Contains("[" + AlwaysOnXeSessions.SharedNameFor(AlwaysOnXeSessionKind.BlockedProcess) + "]", StringComparison.Ordinal));
+ Assert.Empty(choices.OwnDatabases(key, AlwaysOnXeSessionKind.Deadlock));
+ Assert.Empty(choices.OwnDatabases(key, AlwaysOnXeSessionKind.BlockedProcess));
+
+ /* The other registration's own choice is not the removal's to forget. */
+ Assert.Single(choices.OwnDatabases(KeyOf(other), AlwaysOnXeSessionKind.Deadlock), d => string.Equals(d, "beta", StringComparison.OrdinalIgnoreCase));
+
+ /* Trace off: the long-query session was not touched. */
+ Assert.Empty(rig.Calls);
+ }
+
+ ///
+ /// A database of the same name on another logical server is another database, so it holds nothing back: only another
+ /// registration of the removed server's own logical server reads its own session in a database of this server. The
+ /// host compares ignoring case and padding.
+ ///
+ [Fact]
+ public async Task Removal_Azure_TheFallbackIsHeldBackOnlyByARegistrationOfTheSameServer_NotByADatabaseOfTheSameNameElsewhere()
+ {
+ var rig = BuildRig();
+ rig.TraceApplied = false;
+ var elsewhere = Another("lqremoval-other.database.windows.net");
+ var sameServer = Another(" " + Host.ToUpperInvariant() + " ");
+ rig.Remaining.Add(elsewhere);
+ rig.Remaining.Add(sameServer);
+ Assert.NotEqual(elsewhere.ServerId, sameServer.ServerId);
+ var choices = rig.Runner.AlwaysOnChoices;
+ var key = KeyOf(rig.Config);
+ choices.Set(key, "alpha", AlwaysOnXeSessionKind.Deadlock, AlwaysOnXeChoice.Own);
+ choices.Set(key, "beta", AlwaysOnXeSessionKind.Deadlock, AlwaysOnXeChoice.Own);
+ choices.Set(KeyOf(elsewhere), "beta", AlwaysOnXeSessionKind.Deadlock, AlwaysOnXeChoice.Own);
+ choices.Set(KeyOf(sameServer), "alpha", AlwaysOnXeSessionKind.Deadlock, AlwaysOnXeChoice.Own);
+
+ await rig.RemoveAsync();
+
+ Assert.False(rig.Statements.ContainsKey("alpha"));
+ Assert.Equal(new[] { Drop(rig, AlwaysOnXeSessionKind.Deadlock) }, rig.Statements["beta"]);
+ }
+
+ /// A failed fallback drop is logged and never stops the removal, and the next database is still tried.
+ [Fact]
+ public async Task Removal_Azure_AFallbackDropThatFails_IsLogged_AndTheNextDatabaseIsStillDropped()
+ {
+ var rig = BuildRig();
+ rig.TraceApplied = false;
+ var choices = rig.Runner.AlwaysOnChoices;
+ var key = KeyOf(rig.Config);
+ choices.Set(key, "alpha", AlwaysOnXeSessionKind.Deadlock, AlwaysOnXeChoice.Own);
+ choices.Set(key, "beta", AlwaysOnXeSessionKind.Deadlock, AlwaysOnXeChoice.Own);
+ rig.Runner.AlwaysOnXeDatabaseForTests = (_, database, _) =>
+ {
+ if (string.Equals(database, "alpha", StringComparison.OrdinalIgnoreCase))
+ {
+ throw new InvalidOperationException("The connection was refused.");
+ }
+
+ return Task.FromResult(new RecordingDatabase(rig.Statements[database] = new List()));
+ };
+
+ await rig.RemoveAsync();
+
+ Assert.Single(rig.Statements["beta"]);
+ Assert.Contains(rig.Logger.Entries, e =>
+ e.Level == LogLevel.Warning
+ && e.Message.Contains("alpha", StringComparison.OrdinalIgnoreCase)
+ && e.Message.Contains("removed", StringComparison.OrdinalIgnoreCase));
+ Assert.Empty(choices.OwnDatabases(key, AlwaysOnXeSessionKind.Deadlock));
+ }
+
+ /* ── what the registry forgets: the capture ── */
+
+ private static DarlingWorker.ServerLoopState StateOf(Rig rig, bool? applied, bool withRuntime = true) =>
+ new() { Config = rig.Config, Runtime = withRuntime ? rig.Runtime : null, LongQueryTraceApplied = applied };
+
+ ///
+ /// The reconcile forgets a server in one synchronous step, so what the drop needs is taken first: the definition, the
+ /// runtime and the long-query latch of each server that is no longer wanted. A server that stays is not captured.
+ ///
+ [Theory]
+ [InlineData(null)]
+ [InlineData(true)]
+ public void Capture_TakesTheServerNoLongerWanted_WithItsDefinitionRuntimeAndLatch(bool? applied)
+ {
+ var rig = BuildRig();
+ var kept = Another(Host);
+ var keptState = new DarlingWorker.ServerLoopState { Config = kept, Runtime = rig.Runtime, LongQueryTraceApplied = true };
+
+ var captured = DarlingRemovedServerSessions.Capture(new[] { StateOf(rig, applied), keptState }, new[] { kept }, rig.Runner);
+
+ var removed = Assert.Single(captured);
+ Assert.Same(rig.Config, removed.Config);
+ Assert.Same(rig.Runtime, removed.Runtime);
+ Assert.Equal(applied, removed.LongQueryTraceApplied);
+ }
+
+ /// A server with no runtime has nothing to connect with, and a server whose trace was confirmed dropped has no session to drop.
+ [Fact]
+ public void Capture_SkipsAServerWithNoRuntime_AndAServerWhoseTraceWasConfirmedDropped()
+ {
+ var rig = BuildRig();
+
+ Assert.Empty(DarlingRemovedServerSessions.Capture(new[] { StateOf(rig, applied: true, withRuntime: false) }, Array.Empty(), rig.Runner));
+ Assert.Empty(DarlingRemovedServerSessions.Capture(new[] { StateOf(rig, applied: false) }, Array.Empty(), rig.Runner));
+ }
+
+ /// The fallbacks do not depend on the trace: a server whose trace is off but whose database reads this install's own session is still captured.
+ [Fact]
+ public void Capture_TakesAServerWhoseTraceIsOff_WhenItHoldsAnOwnFallbackSession()
+ {
+ var rig = BuildRig();
+ rig.Runner.AlwaysOnChoices.Set(KeyOf(rig.Config), "alpha", AlwaysOnXeSessionKind.BlockedProcess, AlwaysOnXeChoice.Own);
+
+ var removed = Assert.Single(DarlingRemovedServerSessions.Capture(new[] { StateOf(rig, applied: false) }, Array.Empty(), rig.Runner));
+
+ Assert.False(removed.LongQueryTraceApplied);
+ }
+
+ ///
+ /// The reload takes the capture inside the lock and before the reconcile forgets the server, and awaits the drop after the
+ /// lock, with the runner the collection loop holds. Pinned in the source: the reload needs a store.
+ ///
+ [Fact]
+ public void TheReload_CapturesBeforeTheReconcile_AndDropsAfterTheLock()
+ {
+ var code = CSharpSourceWalker.StripCommentsAndStrings(ReadRepoFile("Darling", "PerformanceMonitor.Darling.Service", "DarlingWorker.cs"));
+ var start = code.IndexOf("private async Task ReloadFromStoreAsync(", StringComparison.Ordinal);
+ Assert.True(start > 0, "the reload could not be located");
+ var signature = code[start..code.IndexOf(')', start)];
+ Assert.Contains("DarlingCollectorRunner runner", signature, StringComparison.Ordinal);
+
+ var body = CSharpSourceWalker.BraceBalanced(code, code.IndexOf('{', code.IndexOf(')', start)));
+ var lockAt = body.IndexOf("lock (_serversLock)", StringComparison.Ordinal);
+ var captureAt = body.IndexOf("DarlingRemovedServerSessions.Capture(", StringComparison.Ordinal);
+ var reconcileAt = body.IndexOf("ReconcileServers(servers, view.EnabledServers);", StringComparison.Ordinal);
+ var dropAt = body.IndexOf("await DropRemovedServerSessionsAsync(", StringComparison.Ordinal);
+ Assert.True(lockAt >= 0 && captureAt > lockAt && reconcileAt > captureAt, "the capture is taken inside the lock, before the reconcile");
+ Assert.True(dropAt > reconcileAt, "the drop comes after the reconcile");
+ Assert.Contains("}", body[reconcileAt..dropAt], StringComparison.Ordinal);
+ Assert.Contains("runner", body[dropAt..body.IndexOf(';', dropAt)], StringComparison.Ordinal);
+ Assert.Single(Regex.Matches(body, @"return null\s*;"));
+
+ /* The drop is one attempt within the timeout, and on premises it asks the guard builder, so the session stays where another
+ registration of this install may keep it. */
+ var dropStart = code.IndexOf("private async Task DropRemovedServerSessionsAsync(", StringComparison.Ordinal);
+ Assert.True(dropStart > 0, "the drop of the removed servers could not be located");
+ var dropBody = CSharpSourceWalker.BraceBalanced(code, code.IndexOf('{', code.IndexOf(')', dropStart)));
+ Assert.Contains("CancelAfter(DarlingRemovedServerSessions.Timeout)", dropBody, StringComparison.Ordinal);
+ Assert.Contains("LongQueryTraceInstanceGuardFor(", dropBody, StringComparison.Ordinal);
+
+ /* The loop passes the runner it built. */
+ Assert.Matches(@"await ReloadFromStoreAsync\s*\([^)]*\brunner\b[^)]*\)", code);
+ }
+
+ /* ── no session after the drop ── */
+
+ ///
+ /// A removed server's sweep that was already running can reach the reconcile after the removal retired the server and
+ /// dropped its session. It creates nothing then, and records nothing.
+ ///
+ [Fact]
+ public async Task AReconcileOfARetiredServer_CreatesNothing_EvenWithItsRuntimeStillSet()
+ {
+ var rig = BuildRig();
+ var state = StateOf(rig, applied: null);
+ state.Retired = true;
+
+ await DarlingWorker.ReconcileLongQueryTraceAsync(
+ state, rig.Runner, enabled: true, Array.Empty(), Array.Empty(), DateTime.UtcNow, rig.Logger, CancellationToken.None);
+
+ Assert.Empty(rig.Calls);
+ Assert.Equal(0, rig.ListCalls);
+ Assert.Null(state.LongQueryTraceApplied);
+
+ /* The same call for a server that is not retired runs, so the check is what stopped it. */
+ var live = StateOf(rig, applied: null);
+ await DarlingWorker.ReconcileLongQueryTraceAsync(
+ live, rig.Runner, enabled: true, Array.Empty(), Array.Empty(), DateTime.UtcNow, rig.Logger, CancellationToken.None);
+ Assert.Equal(new[] { "alpha", "beta", "gamma" }, rig.Calls.Where(c => c.Create).Select(c => c.Database));
+ }
+
+ /// The always-on ensure creates the deadlock and blocked-process sessions, so it leaves a retired server alone.
+ [Fact]
+ public async Task TheAlwaysOnEnsure_OfARetiredServer_CreatesNothing()
+ {
+ var rig = BuildRig();
+ var ensured = 0;
+ rig.Runner.XeEnsureOverrideForTests = (_, _) =>
+ {
+ ensured++;
+ return Task.CompletedTask;
+ };
+ var state = StateOf(rig, applied: null);
+ state.Retired = true;
+
+ await DarlingWorker.EnsureAlwaysOnXeSessionsAsync(state, rig.Runner, DateTime.UtcNow, rig.Logger, CancellationToken.None);
+
+ Assert.Equal(0, ensured);
+
+ await DarlingWorker.EnsureAlwaysOnXeSessionsAsync(StateOf(rig, applied: null), rig.Runner, DateTime.UtcNow, rig.Logger, CancellationToken.None);
+ Assert.Equal(1, ensured);
+ }
+}
diff --git a/Darling/Darling.Tests/LongQueryTraceRestartTests.cs b/Darling/Darling.Tests/LongQueryTraceRestartTests.cs
new file mode 100644
index 0000000000..c4ee4b17f5
--- /dev/null
+++ b/Darling/Darling.Tests/LongQueryTraceRestartTests.cs
@@ -0,0 +1,177 @@
+/*
+ * 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.Threading;
+using System.Threading.Tasks;
+using Npgsql;
+using PerformanceMonitor.Collectors;
+using PerformanceMonitor.Darling.Service;
+using Xunit;
+using static Darling.Tests.RepoFile;
+
+namespace Darling.Tests;
+
+///
+/// #4961: a SQL Server restart stops the long-query trace's session, because the per-install session is created with
+/// STARTUP_STATE = OFF, and a stopped session reads as a quiet one. When a collector run sees the instance's
+/// identity move, the worker clears the long-query latch, so the next sweep's reconcile starts the session again
+/// instead of waiting for the hourly create pass. Each test drives the worker's real reconcile on a server whose
+/// session is the server's own, with the create replaced, so no server or store is needed.
+///
+public sealed class LongQueryTraceRestartTests : IAsyncDisposable
+{
+ /* Never opened: these tests collect nothing. */
+ private readonly NpgsqlDataSource _store = NpgsqlDataSource.Create("Host=127.0.0.1;Port=1;Database=unused;Username=unused");
+
+ public ValueTask DisposeAsync() => _store.DisposeAsync();
+
+ private const string InstallIdValue = "0a1b2c3d";
+ private static readonly string OwnSession = LongQueryCompletionsCollector.XeSessionNameFor(LongQueryCompletionsCollector.DarlingProduct, InstallIdValue);
+
+ private sealed class Rig
+ {
+ public required DarlingCollectorRunner Runner { get; init; }
+ public required DarlingWorker.ServerLoopState State { get; init; }
+ public List<(string Database, bool Create, string Name)> Calls { get; } = new();
+ public DateTime Clock { get; set; } = new(2026, 10, 2, 12, 0, 0, DateTimeKind.Utc);
+ public DarlingSelfAlertTests.CapturingLogger Logger { get; } = new();
+
+ public Task ReconcileAsync(bool enabled) =>
+ DarlingWorker.ReconcileLongQueryTraceAsync(
+ State, Runner, enabled, Array.Empty(), Array.Empty(), Clock, Logger, CancellationToken.None);
+ }
+
+ /// A server on an engine with no per-database sessions, an on-premises server: its session is the server's.
+ private Rig BuildOnPremRig()
+ {
+ var config = new MonitoredServer { Name = "lqtrace-sql", Host = "lqtrace-sql" };
+ var runtime = new ServerRuntime
+ {
+ Config = config,
+ ConnectionString = "Server=lqtrace-sql;Initial Catalog=master;Encrypt=True",
+ Target = new CollectorTargetInfo { IsAzureSqlDb = false },
+ StorageName = "lqtrace-sql",
+ ServerId = 4961,
+ };
+
+ var runner = new DarlingCollectorRunner(
+ _store,
+ new CollectorDeltaCalculator(),
+ installId: () => InstallIdValue);
+
+ var rig = new Rig
+ {
+ Runner = runner,
+ State = new DarlingWorker.ServerLoopState { Config = config, Runtime = runtime },
+ };
+
+ /* The store is never opened, so the record of the one-time legacy drop is kept in memory, and the legacy drop is
+ not one of the per-install calls these tests count. */
+ runner.LegacyLongQueryRecordsForTests = new LongQueryTraceLifecycleTests.InMemoryLegacyRecords();
+ runner.LongQueryTraceDatabaseOverrideForTests = (_, database, create, sessionName, _) =>
+ {
+ if (sessionName == PerformanceMonitor.Collectors.LongQueryCompletionsCollector.LegacyXeSessionName)
+ {
+ return Task.CompletedTask;
+ }
+
+ rig.Calls.Add((database, create, sessionName));
+ return Task.CompletedTask;
+ };
+
+ return rig;
+ }
+
+ private static CollectorMeasurement[] IdentityMoved(long count = 1) =>
+ new[] { new CollectorMeasurement(ServerEpoch.IdentityChangesMeasurement, count) };
+
+ [Fact]
+ public async Task AMovedStartTime_ClearsTheLatch_AndTheNextSweepEnsuresTheSession()
+ {
+ var rig = BuildOnPremRig();
+
+ await rig.ReconcileAsync(enabled: true);
+ Assert.True(rig.State.LongQueryTraceApplied);
+ Assert.Single(rig.Calls);
+
+ /* Latched: the next sweep opens no connection. */
+ await rig.ReconcileAsync(enabled: true);
+ Assert.Single(rig.Calls);
+
+ /* A collector run saw the instance's identity move: the restart stopped the session. */
+ var cleared = DarlingWorker.ForgetLongQueryTraceLatchOnRestart(rig.State, IdentityMoved());
+
+ Assert.True(cleared);
+ Assert.Null(rig.State.LongQueryTraceApplied);
+ Assert.Null(rig.State.LongQueryTraceAppliedAtUtc);
+
+ /* Within the hour of the create, so only the cleared latch can make this sweep ensure. */
+ rig.Clock = rig.Clock.AddMinutes(1);
+ await rig.ReconcileAsync(enabled: true);
+
+ Assert.Equal(2, rig.Calls.Count);
+ Assert.All(rig.Calls, call => Assert.True(call.Create));
+ Assert.All(rig.Calls, call => Assert.Equal(OwnSession, call.Name));
+ Assert.True(rig.State.LongQueryTraceApplied);
+ }
+
+ [Fact]
+ public async Task AMovedStartTime_WithTheTraceOff_ChecksTheDropAgainOnTheNextSweep()
+ {
+ var rig = BuildOnPremRig();
+
+ await rig.ReconcileAsync(enabled: false);
+ Assert.False(rig.State.LongQueryTraceApplied);
+ Assert.Single(rig.Calls);
+
+ Assert.True(DarlingWorker.ForgetLongQueryTraceLatchOnRestart(rig.State, IdentityMoved()));
+
+ await rig.ReconcileAsync(enabled: false);
+ Assert.Equal(2, rig.Calls.Count);
+ Assert.All(rig.Calls, call => Assert.False(call.Create));
+ }
+
+ [Theory]
+ [InlineData(0L)]
+ [InlineData(-1L)]
+ public async Task ARunThatSawNoMove_LeavesTheLatchAlone(long count)
+ {
+ var rig = BuildOnPremRig();
+ await rig.ReconcileAsync(enabled: true);
+
+ var cleared = DarlingWorker.ForgetLongQueryTraceLatchOnRestart(rig.State, IdentityMoved(count));
+ Assert.False(cleared);
+
+ /* Nor does a run that measured something else, or nothing. */
+ Assert.False(DarlingWorker.ForgetLongQueryTraceLatchOnRestart(
+ rig.State, new[] { new CollectorMeasurement(ServerEpoch.StatementsChangesMeasurement, 1) }));
+ Assert.False(DarlingWorker.ForgetLongQueryTraceLatchOnRestart(rig.State, CollectorContext.NoMeasurements));
+
+ Assert.True(rig.State.LongQueryTraceApplied);
+ Assert.NotNull(rig.State.LongQueryTraceAppliedAtUtc);
+
+ rig.Calls.Clear();
+ await rig.ReconcileAsync(enabled: true);
+ Assert.Empty(rig.Calls);
+ }
+
+ [Fact]
+ public void TheCollectorRun_ClearsTheLatch_FromItsOwnMeasurements()
+ {
+ var worker = ReadRepoFileLf("Darling", "PerformanceMonitor.Darling.Service", "DarlingWorker.cs");
+
+ var run = worker.IndexOf("private async Task RunOneAsync(", StringComparison.Ordinal);
+ Assert.True(run >= 0, "RunOneAsync must exist.");
+ var end = worker.IndexOf("\n }\n", run, StringComparison.Ordinal);
+ var body = worker[run..end];
+
+ Assert.Contains("ForgetLongQueryTraceLatchOnRestart(server, result.Measurements)", body);
+ }
+}
diff --git a/Darling/Darling.Tests/MuteRuleServerIdRungTests.cs b/Darling/Darling.Tests/MuteRuleServerIdRungTests.cs
index 17b978ec10..62afa49b6d 100644
--- a/Darling/Darling.Tests/MuteRuleServerIdRungTests.cs
+++ b/Darling/Darling.Tests/MuteRuleServerIdRungTests.cs
@@ -35,7 +35,7 @@ public sealed class MuteRuleServerIdRungTests
{
public const string RungName = "mute-rule-server-id";
- /// The probe's newest sentinel, so the last argument; the ordinal is a fact of the probe's shape.
+ /// This rung's sentinel ordinal in the probe; the ordinal is a fact of the probe's shape.
private const int ProbeOrdinal = 132;
public static int RungVersion => Rung.Version;
@@ -43,12 +43,13 @@ public sealed class MuteRuleServerIdRungTests
private static PgMigrations.Migration Rung => PgMigrations.Scripts.Single(m => m.Name == RungName);
[Fact]
- public void TheRungIsTheTopOfADenseLadder_AtVersion157()
+ public void TheRungIsRegisteredInADenseLadder_AtVersion157()
{
var versions = PgMigrations.Scripts.Select(s => s.Version).ToList();
- Assert.Equal(157, StorageVersion.SchemaVersion);
Assert.Equal(157, Rung.Version);
+ /* No longer the top rung: V158 (the install id) landed above it. */
+ Assert.True(Rung.Version < StorageVersion.SchemaVersion);
Assert.Equal(StorageVersion.SchemaVersion, PgMigrations.Scripts[^1].Version);
Assert.Equal(versions.Distinct().OrderBy(v => v), versions);
Assert.Contains(Rung.Version - 1, versions);
@@ -66,24 +67,21 @@ public void TheRungAddsOneNullableServerIdColumn_IdempotentlyAndSchemaQualified(
}
[Fact]
- public void TheProbeCarriesTheColumnAsItsLastArm_AndMapsFullyMigratedToTheTopRung()
+ public void TheProbeCarriesTheColumn_AndMapsAStoreThroughThisRungToIt()
{
var probe = ViewerDataService.StoreSchemaProbeSql.Replace("\r\n", "\n", StringComparison.Ordinal);
var arm = "table_schema = 'config' AND table_name = 'config_mute_rules' AND column_name = 'server_id'";
Assert.Contains(arm, probe, StringComparison.Ordinal);
- Assert.True(probe.LastIndexOf("EXISTS", StringComparison.Ordinal) < probe.IndexOf(arm, StringComparison.Ordinal),
- "the new arm is the probe's last EXISTS, so it reads at the next ordinal");
var viewer = RepoFile.ReadRepoFile("Darling", "PerformanceMonitor.Darling.Viewer", "ViewerDataService.cs");
Assert.Contains($"reader.GetBoolean({ProbeOrdinal})", viewer, StringComparison.Ordinal);
- Assert.DoesNotContain($"reader.GetBoolean({ProbeOrdinal + 1})", viewer, StringComparison.Ordinal);
var method = typeof(ViewerDataService).GetMethod("MapProbedSchemaVersion", BindingFlags.NonPublic | BindingFlags.Static)!;
var arity = method.GetParameters().Length;
- Assert.Equal(ProbeOrdinal, arity - 1);
+ Assert.True(ProbeOrdinal < arity - 1, "a newer rung's sentinel follows this one");
Assert.Equal("hasMuteRuleServerId", method.GetParameters()[ProbeOrdinal].Name);
- var all = Enumerable.Repeat((object)true, arity).ToArray();
+ var all = Enumerable.Range(0, arity).Select(i => (object)(i <= ProbeOrdinal)).ToArray();
Assert.Equal(157, (int)method.Invoke(null, all)!);
var behind = (object[])all.Clone();
diff --git a/Darling/Darling.Tests/PostgresEngineGateBehaviorTests.cs b/Darling/Darling.Tests/PostgresEngineGateBehaviorTests.cs
index 246e55fd2b..14a8c243ce 100644
--- a/Darling/Darling.Tests/PostgresEngineGateBehaviorTests.cs
+++ b/Darling/Darling.Tests/PostgresEngineGateBehaviorTests.cs
@@ -238,7 +238,7 @@ public async Task ReconcileLongQueryCompletions_AgainstAPostgresTarget_ReturnsBe
/* runner is null on purpose: reaching it would mean the gate did not fire. */
await DarlingXeSessions.ReconcileLongQueryCompletionsAsync(
PostgresRuntime(), runner: null!, enabled, LongQueryTracePass.Full, Array.Empty(), Array.Empty(),
- NullLogger.Instance, CancellationToken.None);
+ createFailureWarned: false, NullLogger.Instance, CancellationToken.None);
}
///