From f24d8a22690a1aca1c44531ae929e795ca488191 Mon Sep 17 00:00:00 2001
From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com>
Date: Mon, 12 Oct 2026 14:47:54 -0400
Subject: [PATCH 01/42] Web time-series charts break their line at collection
gaps (#5644 W7)
---
.../WebChartGapBehaviourTests.cs | 166 ++++++++++++++++++
.../Darling.Tests/web-chart-gap-harness.mjs | 160 +++++++++++++++++
.../wwwroot/js/charts.js | 151 +++++++++++-----
3 files changed, 429 insertions(+), 48 deletions(-)
create mode 100644 Darling/Darling.Tests/WebChartGapBehaviourTests.cs
create mode 100644 Darling/Darling.Tests/web-chart-gap-harness.mjs
diff --git a/Darling/Darling.Tests/WebChartGapBehaviourTests.cs b/Darling/Darling.Tests/WebChartGapBehaviourTests.cs
new file mode 100644
index 0000000000..67c216e9af
--- /dev/null
+++ b/Darling/Darling.Tests/WebChartGapBehaviourTests.cs
@@ -0,0 +1,166 @@
+/*
+ * 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.Linq;
+using System.Text.Json;
+using Xunit;
+using static Darling.Tests.RepoFile;
+
+namespace Darling.Tests;
+
+///
+/// A web time-series chart breaks its line, area and stacked bands where no samples were collected (#5644 W7): the service host
+/// is off overnight, and a line drawn point to point across the silence read as data. These run the shipped
+/// charts.js under Node (web-chart-gap-harness.mjs). Skipped when Node is not installed.
+///
+public sealed class WebChartGapBehaviourTests
+{
+ private static JsonElement Run()
+ {
+ var psi = new ProcessStartInfo("node") { RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false };
+ psi.ArgumentList.Add(PathTo("Darling", "Darling.Tests", "web-chart-gap-harness.mjs"));
+ psi.ArgumentList.Add(PathTo("Darling", "PerformanceMonitor.Darling.Service", "wwwroot", "js"));
+
+ Process proc;
+ try
+ {
+ proc = Process.Start(psi)!;
+ }
+ catch (Win32Exception)
+ {
+ Assert.Skip("Node is not installed, so the shipped chart script cannot be run.");
+ return default;
+ }
+
+ using (proc)
+ {
+ var error = proc.StandardError.ReadToEndAsync();
+ var output = proc.StandardOutput.ReadToEnd().Trim();
+ if (!proc.WaitForExit(20000))
+ {
+ proc.Kill(entireProcessTree: true);
+ Assert.Fail("the chart gap harness did not finish in 20 s");
+ }
+
+ Assert.True(proc.ExitCode == 0, "the chart gap harness failed: " + error.Result);
+ using var doc = JsonDocument.Parse(output.Split('\n')[0]);
+ return doc.RootElement.Clone();
+ }
+ }
+
+ private static int N(JsonElement r, string scenario, string mark) => r.GetProperty(scenario).GetProperty(mark).GetInt32();
+
+ [Fact]
+ public void ALineChart_WithNoGap_DrawsOneLinePerSeries()
+ {
+ var r = Run();
+ Assert.Equal(2, N(r, "noGapLine", "lines"));
+ Assert.Equal(0, N(r, "noGapLine", "dots"));
+ }
+
+ [Fact]
+ public void ALineChart_BreaksEachSeriesAtACollectionGap()
+ {
+ var r = Run();
+ Assert.Equal(4, N(r, "gapLine", "lines"));
+ Assert.Equal(0, N(r, "gapLine", "dots"));
+ }
+
+ [Fact]
+ public void NoLineSegment_SpansTheGap()
+ {
+ var r = Run();
+ var runs = r.GetProperty("gapLineXs").EnumerateArray().Select(l => l.EnumerateArray().Select(x => x.GetDouble()).ToArray()).ToArray();
+ Assert.Equal(2, runs.Length);
+ // The widest step inside either run is one 1-minute bucket; the gap is 7 hours, so no step comes near it.
+ var steps = runs.SelectMany(run => run.Zip(run.Skip(1), (a, b) => b - a)).ToArray();
+ Assert.True(steps.Max() < runs[1][0] - runs[0][^1], "a step inside a run is shorter than the gap between runs");
+ }
+
+ [Fact]
+ public void AnAreaChart_BreaksItsFillAndLineAtTheGap()
+ {
+ var r = Run();
+ Assert.Equal(4, N(r, "gapArea", "lines"));
+ Assert.Equal(4, N(r, "gapArea", "areas"));
+ }
+
+ [Fact]
+ public void AStackedChart_BreaksEachBandAtTheGap()
+ {
+ var r = Run();
+ Assert.Equal(2, N(r, "noGapStacked", "areas"));
+ Assert.Equal(4, N(r, "gapStacked", "areas"));
+ }
+
+ [Fact]
+ public void AStackedBarChart_IsLeftAlone()
+ {
+ var r = Run();
+ Assert.Equal(N(r, "noGapStackedBar", "bars"), N(r, "gapStackedBar", "bars"));
+ Assert.Equal(0, N(r, "gapStackedBar", "lines"));
+ Assert.Equal(0, N(r, "gapStackedBar", "areas"));
+ }
+
+ [Fact]
+ public void TheDualAxisOverlay_BreaksAtTheGapToo()
+ {
+ var r = Run();
+ Assert.Equal(1, N(r, "noGapOverlay", "overlays"));
+ Assert.Equal(2, N(r, "gapOverlay", "overlays"));
+ }
+
+ [Fact]
+ public void ALoneBucketAfterAGap_IsADot_AndTheStretchBeforeItStaysALine()
+ {
+ var r = Run();
+ Assert.Equal(1, N(r, "loneAfterGap", "lines"));
+ Assert.Equal(1, N(r, "loneAfterGap", "dots"));
+ }
+
+ [Fact]
+ public void OneOrTwoBuckets_NeverBreak()
+ {
+ var r = Run();
+ Assert.Equal(2, N(r, "single", "dots"));
+ Assert.Equal(0, N(r, "single", "lines"));
+ Assert.Equal(2, N(r, "two", "lines"));
+ Assert.Equal(0, N(r, "two", "dots"));
+ }
+
+ [Fact]
+ public void AMissedCycle_BreaksOnlyPastTheFloorAndThreeTimesTheMedianInterval()
+ {
+ var r = Run();
+ Assert.Equal(1, N(r, "smallMiss", "lines"));
+ Assert.Equal(2, N(r, "bigMiss", "lines"));
+ Assert.Equal(1, N(r, "slowCollector", "lines"));
+ Assert.Equal(2, N(r, "slowCollectorBreak", "lines"));
+ }
+
+ [Fact]
+ public void TheThreshold_HasNothingToCompareUnderThreeBuckets_AndNeverFallsUnderTheFloor()
+ {
+ var r = Run().GetProperty("threshold");
+ Assert.Equal("Infinity", r.GetProperty("empty").GetString());
+ Assert.Equal("Infinity", r.GetProperty("one").GetString());
+ Assert.Equal("Infinity", r.GetProperty("two").GetString());
+ Assert.Equal(5 * 60000, r.GetProperty("oneMinute").GetDouble());
+ Assert.Equal(45 * 60000, r.GetProperty("fifteenMinute").GetDouble());
+ }
+
+ [Fact]
+ public void SplitAtGaps_StartsANewRunWhereTheJumpExceedsTheThreshold()
+ {
+ var r = Run();
+ Assert.Equal(new[] { "0,1,2", "3,4" }, r.GetProperty("split").EnumerateArray().Select(e => e.GetString()!).ToArray());
+ }
+}
diff --git a/Darling/Darling.Tests/web-chart-gap-harness.mjs b/Darling/Darling.Tests/web-chart-gap-harness.mjs
new file mode 100644
index 0000000000..432b8a9f42
--- /dev/null
+++ b/Darling/Darling.Tests/web-chart-gap-harness.mjs
@@ -0,0 +1,160 @@
+/* Runs the web viewer's shipped charts.js (renderLineChart, with util.js) against a stand-in DOM and counts the line, area and dot
+ marks drawn across a collection gap (#5644 W7), printing the counts as one line of JSON.
+ WebChartGapBehaviourTests starts it as
+ node web-chart-gap-harness.mjs
+ The DOM is a node tree of plain objects; a chart's marks are the svg children it appends. */
+import fs from "node:fs";
+import os from "node:os";
+import path from "node:path";
+import { pathToFileURL } from "node:url";
+
+const jsDir = process.argv[process.argv.length - 1];
+
+class FakeNode {
+ constructor(tag, text) {
+ this.tag = tag;
+ this.children = [];
+ this.attrs = {};
+ this.dataset = {};
+ this.style = {};
+ this.className = "";
+ this.listeners = {};
+ this.text = text == null ? null : String(text);
+ this.classList = { add() {}, remove() {}, toggle() {}, contains: () => false };
+ }
+ get firstChild() {
+ return this.children[0] || null;
+ }
+ appendChild(child) {
+ child.parent = this;
+ 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(type, listener) {
+ (this.listeners[type] = this.listeners[type] || []).push(listener);
+ }
+ setPointerCapture() {}
+ focus() {}
+ get offsetWidth() { return 0; }
+ get offsetHeight() { return 0; }
+ getBoundingClientRect() {
+ return { left: 0, top: 0, width: 1000, height: 320 };
+ }
+ 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),
+ body: new FakeNode("body"),
+};
+globalThis.location = { hash: "#/server/A/cpu" };
+
+const scratch = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "chart-gap-")));
+let charts;
+try {
+ fs.cpSync(jsDir, scratch, { recursive: true });
+ fs.writeFileSync(path.join(scratch, "package.json"), '{ "type": "module" }');
+ charts = await import(pathToFileURL(path.join(scratch, "charts.js")).href);
+} catch (e) {
+ fs.rmSync(scratch, { recursive: true, force: true });
+ throw e;
+}
+
+const find = (node, pred, out = []) => {
+ if (pred(node)) out.push(node);
+ node.children.forEach((c) => find(c, pred, out));
+ return out;
+};
+const cls = (n) => String(n.className || n.attrs.class || "");
+const marks = (host) => ({
+ lines: find(host, (n) => n.tag === "polyline" && cls(n).includes("series-line") && !cls(n).includes("overlay")).length,
+ overlays: find(host, (n) => n.tag === "polyline" && cls(n).includes("series-line-overlay")).length,
+ areas: find(host, (n) => n.tag === "polygon" && cls(n).includes("series-area")).length,
+ dots: find(host, (n) => n.tag === "circle" && cls(n).includes("series-dot")).length,
+ bars: find(host, (n) => n.tag === "rect" && cls(n).includes("series-bar")).length,
+});
+/* The x of every vertex of every line, so a test can show no segment spans the gap. */
+const lineXs = (host) =>
+ find(host, (n) => n.tag === "polyline" && cls(n).includes("series-line") && !cls(n).includes("overlay")).map((n) =>
+ n.attrs.points.split(" ").map((p) => Number(p.split(",")[0]))
+ );
+
+const T0 = Date.UTC(2026, 0, 1, 0, 0, 0);
+const MIN = 60000;
+const iso = (ms) => new Date(ms).toISOString().slice(0, 19);
+/* minutes: offsets from T0; each row carries a, b, and c (c only where `withC` says so). */
+const rowsAt = (minutes) => minutes.map((m, i) => ({ t: iso(T0 + m * MIN), a: 10 + (i % 7), b: 20 + (i % 5), c: 5 + (i % 3) }));
+const seq = (from, to, step = 1) => {
+ const o = [];
+ for (let m = from; m <= to; m += step) o.push(m);
+ return o;
+};
+const spec = (minutes, extra) => ({
+ points: rowsAt(minutes),
+ xKey: "t",
+ series: [
+ { key: "a", label: "A", color: "#f00" },
+ { key: "b", label: "B", color: "#0f0" },
+ ],
+ ...extra,
+});
+const draw = (minutes, extra) => charts.renderLineChart(spec(minutes, extra));
+
+/* 60 one-minute buckets, then a 7 hour silence, then 60 more. */
+const withGap = seq(0, 59).concat(seq(60 * 7 + 59, 60 * 7 + 118));
+const noGap = seq(0, 119);
+
+const out = {};
+out.noGapLine = marks(draw(noGap));
+out.gapLine = marks(draw(withGap));
+out.gapLineXs = lineXs(draw(withGap, { series: [{ key: "a", label: "A", color: "#f00" }] }));
+out.gapArea = marks(draw(withGap, { mode: "area" }));
+out.gapStacked = marks(draw(withGap, { mode: "stacked" }));
+out.noGapStacked = marks(draw(noGap, { mode: "stacked" }));
+out.gapStackedBar = marks(draw(withGap, { mode: "stacked-bar" }));
+out.noGapStackedBar = marks(draw(noGap, { mode: "stacked-bar" }));
+out.gapOverlay = marks(draw(withGap, { series2: { key: "c", label: "C", color: "#00f" } }));
+out.noGapOverlay = marks(draw(noGap, { series2: { key: "c", label: "C", color: "#00f" } }));
+/* a gap that leaves one lone bucket at the end draws that bucket as a dot, and the stretch before it as a line */
+out.loneAfterGap = marks(draw(seq(0, 59).concat([60 * 7]), { series: [{ key: "a", label: "A", color: "#f00" }] }));
+/* a single bucket and two buckets never break */
+out.single = marks(draw([0]));
+out.two = marks(draw([0, 600]));
+/* a 4 minute miss in one-minute buckets is under the 5 minute floor and stays connected; a 6 minute miss breaks */
+out.smallMiss = marks(draw(seq(0, 30).concat(seq(34, 60)), { series: [{ key: "a", label: "A", color: "#f00" }] }));
+out.bigMiss = marks(draw(seq(0, 30).concat(seq(37, 60)), { series: [{ key: "a", label: "A", color: "#f00" }] }));
+/* a 15 minute collector with one missed cycle (30 minutes) stays connected: 3x the median is 45 minutes */
+out.slowCollector = marks(draw(seq(0, 300, 15).concat(seq(330, 600, 15)), { series: [{ key: "a", label: "A", color: "#f00" }] }));
+out.slowCollectorBreak = marks(draw(seq(0, 300, 15).concat(seq(420, 600, 15)), { series: [{ key: "a", label: "A", color: "#f00" }] }));
+
+out.threshold = {
+ empty: String(charts.gapThresholdMs([])),
+ one: String(charts.gapThresholdMs([0])),
+ two: String(charts.gapThresholdMs([0, 600 * MIN])),
+ oneMinute: charts.gapThresholdMs(noGap.map((m) => m * MIN)),
+ fifteenMinute: charts.gapThresholdMs(seq(0, 300, 15).map((m) => m * MIN)),
+};
+out.split = charts.splitAtGaps([0, 1, 2, 3, 4], [0, 1, 2, 100, 101], 10).map((r) => r.join(","));
+
+fs.rmSync(scratch, { recursive: true, force: true });
+console.log(JSON.stringify(out));
diff --git a/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/charts.js b/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/charts.js
index d265f2157a..75ff7393f2 100644
--- a/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/charts.js
+++ b/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/charts.js
@@ -185,6 +185,44 @@ function watchPlotWidth(host, draw, key, drawnW) {
observer.observe(host);
}
+/* Collection gaps (#5644 W7). The service host is off overnight, so a series has a stretch with no samples; a line drawn
+ point to point would run a straight diagonal across it and read as data. A gap is a jump between two neighbouring
+ timestamps of more than GAP_FACTOR times the series' own median interval, never less than GAP_FLOOR_MS (a collector can
+ miss a cycle without that being worth a break). Fewer than three points have no normal interval to compare against, so
+ they never break. Every time-series line, area and stacked chart goes through this, so no caller inserts null points. */
+const GAP_FACTOR = 3;
+const GAP_FLOOR_MS = 5 * 60 * 1000;
+
+/* The break threshold, in ms, for ascending timestamps (epoch ms); Infinity when there is nothing to compare against. */
+export function gapThresholdMs(times) {
+ const deltas = [];
+ for (let i = 1; i < times.length; i++) {
+ const d = times[i] - times[i - 1];
+ if (d > 0) deltas.push(d);
+ }
+ if (deltas.length < 2) return Infinity;
+ deltas.sort((a, b) => a - b);
+ const mid = deltas.length >> 1;
+ const median = deltas.length % 2 ? deltas[mid] : (deltas[mid - 1] + deltas[mid]) / 2;
+ return Math.max(GAP_FACTOR * median, GAP_FLOOR_MS);
+}
+
+/* Splits ascending positions (indices into times) into runs of neighbours no further apart than thresholdMs. */
+export function splitAtGaps(indices, times, thresholdMs) {
+ const runs = [];
+ let run = null;
+ let prev = -1;
+ for (const i of indices) {
+ if (run === null || times[i] - times[prev] > thresholdMs) {
+ run = [];
+ runs.push(run);
+ }
+ run.push(i);
+ prev = i;
+ }
+ return runs;
+}
+
/**
* Render a multi-series time chart into a returned `.chart` node.
* spec: { points, xKey, series:[{key,label,color}], formatValue?, clampMax?, unit?, mode?, thresholds? }
@@ -281,6 +319,8 @@ export function renderLineChart(spec) {
const tMin = hasWindow ? windowStart : dataTMin;
const tMax = hasWindow ? windowEnd : dataTMax;
const spanMs = tMax - tMin;
+ const rowTimes = rows.map((p) => p.t.getTime());
+ const gapMs = gapThresholdMs(rowTimes);
/* A numeric reader: null/NaN reads as null for line/area (a gap), or 0 for a stacked mode (a continuous baseline). */
const readVal = (r, key) => {
@@ -485,76 +525,91 @@ export function renderLineChart(spec) {
} else {
/* Filled bands drawn top series first so lower bands paint over the seams; each band is bounded above by its
own cumulative top and below by the previous series' cumulative top (the x-axis for series 0). */
+ /* A collection gap (#5644 W7) ends one run of bands and starts the next, so the bands do not ramp across it;
+ a run of one bucket has no extent, so it is dots at the stack tops, as the single-bucket case above. */
+ const runs = splitAtGaps(rows.map((_, i) => i), rowTimes, gapMs);
for (let k = series.length - 1; k >= 0; k--) {
- const top = [];
- const bottom = [];
- for (let i = 0; i < rows.length; i++) {
- top.push(xs[i] + "," + plotY(stackTops[i][k]));
- bottom.push(xs[i] + "," + plotY(k === 0 ? 0 : stackTops[i][k - 1]));
+ for (const run of runs) {
+ if (run.length === 1) {
+ root.appendChild(svg("circle", { class: "series-dot", cx: xs[run[0]], cy: plotY(stackTops[run[0]][k]), r: 4, fill: normalizeColor(series[k].color) }));
+ continue;
+ }
+ const top = [];
+ const bottom = [];
+ for (const i of run) {
+ top.push(xs[i] + "," + plotY(stackTops[i][k]));
+ bottom.push(xs[i] + "," + plotY(k === 0 ? 0 : stackTops[i][k - 1]));
+ }
+ bottom.reverse();
+ root.appendChild(
+ svg("polygon", {
+ class: "series-area",
+ "clip-path": clipAttr,
+ points: top.concat(bottom).join(" "),
+ fill: normalizeColor(series[k].color),
+ "fill-opacity": "0.72",
+ })
+ );
}
- bottom.reverse();
- root.appendChild(
- svg("polygon", {
- class: "series-area",
- "clip-path": clipAttr,
- points: top.concat(bottom).join(" "),
- fill: normalizeColor(series[k].color),
- "fill-opacity": "0.72",
- })
- );
}
}
} else {
/* Area fill (each series to the baseline) then the line on top; or just the line. Nulls drop the gap. */
for (const s of series) {
- const linePts = [];
+ const plottable = [];
for (let i = 0; i < rows.length; i++) {
const v = readVal(rows[i].r, s.key);
if (v == null) continue;
- linePts.push(xs[i] + "," + plotY(v));
+ plottable.push(i);
}
- /* A lone plottable point has no segment to stroke, so draw it as a dot — one bucket still shows its
- reading. (Its resting marker matches the hover dot; static class so it does not vanish on mouseout.) */
- if (linePts.length === 1) {
- const [cx, cy] = linePts[0].split(",");
- root.appendChild(svg("circle", { class: "series-dot", cx, cy, r: 4, fill: normalizeColor(s.color) }));
- continue;
- }
- if (linePts.length < 2) continue;
- if (filled) {
- const first = linePts[0].split(",")[0];
- const last = linePts[linePts.length - 1].split(",")[0];
- root.appendChild(
- svg("polygon", {
- class: "series-area",
- "clip-path": clipAttr,
- points: `${first},${baseY} ${linePts.join(" ")} ${last},${baseY}`,
- fill: normalizeColor(s.color),
- "fill-opacity": "0.15",
- })
- );
+ /* One run per stretch with samples (#5644 W7): a collection gap ends a run, so no line or fill crosses it. */
+ for (const run of splitAtGaps(plottable, rowTimes, gapMs)) {
+ const linePts = run.map((i) => xs[i] + "," + plotY(readVal(rows[i].r, s.key)));
+ /* A lone plottable point has no segment to stroke, so draw it as a dot — one bucket still shows its
+ reading. (Its resting marker matches the hover dot; static class so it does not vanish on mouseout.) */
+ if (linePts.length === 1) {
+ const [cx, cy] = linePts[0].split(",");
+ root.appendChild(svg("circle", { class: "series-dot", cx, cy, r: 4, fill: normalizeColor(s.color) }));
+ continue;
+ }
+ if (filled) {
+ const first = linePts[0].split(",")[0];
+ const last = linePts[linePts.length - 1].split(",")[0];
+ root.appendChild(
+ svg("polygon", {
+ class: "series-area",
+ "clip-path": clipAttr,
+ points: `${first},${baseY} ${linePts.join(" ")} ${last},${baseY}`,
+ fill: normalizeColor(s.color),
+ "fill-opacity": "0.15",
+ })
+ );
+ }
+ root.appendChild(svg("polyline", { class: "series-line", "clip-path": clipAttr, points: linePts.join(" "), stroke: normalizeColor(s.color) }));
}
- root.appendChild(svg("polyline", { class: "series-line", "clip-path": clipAttr, points: linePts.join(" "), stroke: normalizeColor(s.color) }));
}
}
/* The dual-axis overlay line (#1606): plotted against ITS axis (scaleY2), clamped into the plot box,
nulls dropped as gaps — same discipline as a primary line. Always a plain line (never filled/stacked). */
if (series2 && scaleY2) {
- const pts2 = [];
+ const plottable2 = [];
for (let i = 0; i < rows.length; i++) {
const v = rows[i].r[series2.key];
if (v == null || isNaN(v)) continue;
- const y2 = Math.max(M.t, Math.min(M.t + PLOT_H, scaleY2(Number(v))));
- pts2.push(xs[i] + "," + y2);
+ plottable2.push(i);
}
- if (pts2.length === 1) {
- /* Same single-bucket rule as the primary series: a lone overlay reading draws as a dot, not a dropped
- series, so "a dot per series" holds for the right axis too. */
- const [cx, cy] = pts2[0].split(",");
- root.appendChild(svg("circle", { class: "series-dot", cx, cy, r: 4, fill: normalizeColor(series2.color) }));
- } else if (pts2.length >= 2) {
- root.appendChild(svg("polyline", { class: "series-line series-line-overlay", "clip-path": clipAttr, points: pts2.join(" "), stroke: normalizeColor(series2.color) }));
+ /* Broken at collection gaps like a primary line (#5644 W7). */
+ for (const run of splitAtGaps(plottable2, rowTimes, gapMs)) {
+ const pts2 = run.map((i) => xs[i] + "," + Math.max(M.t, Math.min(M.t + PLOT_H, scaleY2(Number(rows[i].r[series2.key])))));
+ if (pts2.length === 1) {
+ /* Same single-bucket rule as the primary series: a lone overlay reading draws as a dot, not a dropped
+ series, so "a dot per series" holds for the right axis too. */
+ const [cx, cy] = pts2[0].split(",");
+ root.appendChild(svg("circle", { class: "series-dot", cx, cy, r: 4, fill: normalizeColor(series2.color) }));
+ } else {
+ root.appendChild(svg("polyline", { class: "series-line series-line-overlay", "clip-path": clipAttr, points: pts2.join(" "), stroke: normalizeColor(series2.color) }));
+ }
}
}
From efc0b7b255c97381d03f1b490777725f3e857e02 Mon Sep 17 00:00:00 2001
From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com>
Date: Mon, 12 Oct 2026 15:07:11 -0400
Subject: [PATCH 02/42] FinOps top-by-average ranks on the unrounded CPU
average; Version Store reads tempdb's live size (#5644 W4, W5)
---
.../FinOpsDatabaseResourcesViewTests.cs | 2 +-
.../Darling.Tests/FinOpsTopAvgRankingTests.cs | 67 +++++++++++++++++++
...DarlingMcpFinOpsTools.DatabaseResources.cs | 6 +-
.../wwwroot/js/pages/finops/utilization.js | 2 +-
.../DarlingFinOpsDatabaseResourcesReader.cs | 15 +++--
.../FinOpsTab.xaml | 2 +-
.../ViewerDataService.FinOps.cs | 4 +-
.../PvsStatsCollectorDefinitionTests.cs | 17 ++++-
Lite/Controls/FinOpsTab.xaml | 2 +-
.../LocalDataService.FinOps.Workload.cs | 6 +-
Lite/Services/LocalDataService.FinOps.cs | 2 +
.../PvsStatsCollector.cs | 30 +++++++--
12 files changed, 133 insertions(+), 22 deletions(-)
create mode 100644 Darling/Darling.Tests/FinOpsTopAvgRankingTests.cs
diff --git a/Darling/Darling.Tests/FinOpsDatabaseResourcesViewTests.cs b/Darling/Darling.Tests/FinOpsDatabaseResourcesViewTests.cs
index 225f011d51..5e0e02afde 100644
--- a/Darling/Darling.Tests/FinOpsDatabaseResourcesViewTests.cs
+++ b/Darling/Darling.Tests/FinOpsDatabaseResourcesViewTests.cs
@@ -51,7 +51,7 @@ public void GuideTail_CarriesTheViewsKeyFacts()
Assert.Contains("top_by_total can include databases with 0 CPU that only had file I/O", tail, StringComparison.Ordinal);
Assert.Contains("top_by_avg is empty when no database has executions in the window", tail, StringComparison.Ordinal);
Assert.Contains("Above 72 hours, rows reads the per-database rollup and the top lists read the query-grain rollup", tail, StringComparison.Ordinal);
- Assert.Contains("avg_cpu_ms is rounded to a whole millisecond, avg_io_mb has 4 decimals, io_total_mb has 2, and total_cpu_ms is the total behind the average", tail, StringComparison.Ordinal);
+ Assert.Contains("avg_cpu_ms has 2 decimals and top_by_avg is ranked on it before any rounding, avg_io_mb has 4 decimals, io_total_mb has 2, and total_cpu_ms is the total behind the average", tail, StringComparison.Ordinal);
Assert.Contains("the desktop's top-consumer grids always cover 24 hours, so send hours_back=24 to match them", tail, StringComparison.Ordinal);
Assert.Contains("database_count", tail, StringComparison.Ordinal);
Assert.Contains("truncated", tail, StringComparison.Ordinal);
diff --git a/Darling/Darling.Tests/FinOpsTopAvgRankingTests.cs b/Darling/Darling.Tests/FinOpsTopAvgRankingTests.cs
new file mode 100644
index 0000000000..8d99957393
--- /dev/null
+++ b/Darling/Darling.Tests/FinOpsTopAvgRankingTests.cs
@@ -0,0 +1,67 @@
+/*
+ * 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 System.Text.Json;
+using PerformanceMonitor.Darling.Service.Mcp;
+using PerformanceMonitor.Darling.Storage.FinOps;
+using Xunit;
+
+namespace Darling.Tests;
+
+///
+/// #5644 W4: the "Top Databases by Avg CPU / Execution" ranking ran on avg_cpu_ms after Convert.ToInt64 had turned
+/// 2.3, 1.95 and 1.56 into 2, so the tie broke on database name and the top-N cut dropped the busiest database.
+/// The average now travels unrounded (AvgCpuMs) and is what the ranking and the wire row use.
+///
+public sealed class FinOpsTopAvgRankingTests
+{
+ private static string Read(string relative, [CallerFilePath] string thisFile = "") =>
+ File.ReadAllText(Path.Combine(Path.GetDirectoryName(thisFile)!, "..", "..", relative)).ReplaceLineEndings("\n");
+
+ [Fact]
+ public void TopByAvgRow_CarriesTheUnroundedAverage()
+ {
+ var row = new TopResourceConsumer("busiest", 2L, 1_066_691L, 10m, 0m, 0m, 2_498_148L, 0.5m, 2.34m);
+
+ var json = JsonSerializer.Serialize(DarlingMcpFinOpsTools.TopByAvgRow(row));
+
+ Assert.Contains("\"avg_cpu_ms\":2.34", json, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void StorageReader_RanksTheAvgGridOnTheUnroundedAverage()
+ {
+ var source = Read("Darling/PerformanceMonitor.Darling.Storage/FinOps/DarlingFinOpsDatabaseResourcesReader.cs");
+
+ Assert.Contains("avgCandidates.OrderByDescending(r => r.AvgCpuMs)", source, StringComparison.Ordinal);
+ Assert.DoesNotContain("avgCandidates.OrderByDescending(r => r.CpuTimeMs)", source, StringComparison.Ordinal);
+ Assert.DoesNotContain("new TopResourceConsumer(dbName, Convert.ToInt64(reader.GetValue(6))", source, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void LiteTwin_RanksTheAvgGridOnTheUnroundedAverage()
+ {
+ var source = Read("Lite/Services/LocalDataService.FinOps.Workload.cs");
+
+ Assert.Contains("avgCandidates.OrderByDescending(r => r.AvgCpuMs)", source, StringComparison.Ordinal);
+ Assert.DoesNotContain("avgCandidates.OrderByDescending(r => r.CpuTimeMs)", source, StringComparison.Ordinal);
+ Assert.DoesNotContain("CpuTimeMs = ToInt64(reader.GetValue(6))", source, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void Grids_BindTheAverageNotTheRoundedField()
+ {
+ Assert.Contains("Header=\"Avg CPU (ms)\" Binding=\"{Binding AvgCpuMs", Read("Darling/PerformanceMonitor.Darling.Viewer/FinOpsTab.xaml"), StringComparison.Ordinal);
+ Assert.Contains("Header=\"Avg CPU (ms)\" Binding=\"{Binding AvgCpuMs", Read("Lite/Controls/FinOpsTab.xaml"), StringComparison.Ordinal);
+ Assert.Contains("{ key: \"avg_cpu_ms\", label: \"Avg CPU (ms)\", format: \"num2\" }",
+ Read("Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/finops/utilization.js"), StringComparison.Ordinal);
+ }
+}
diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpFinOpsTools.DatabaseResources.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpFinOpsTools.DatabaseResources.cs
index ee74b6d2f0..3e33bf477b 100644
--- a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpFinOpsTools.DatabaseResources.cs
+++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpFinOpsTools.DatabaseResources.cs
@@ -25,7 +25,7 @@ public sealed partial class DarlingMcpFinOpsTools
"database_resources: CPU, reads, writes and I/O per database.";
internal const string DatabaseResourcesViewGuide =
- "database_resources gives one row per database: CPU ms, logical and physical reads, logical writes and executions from the query-stats window, and read and write MB and stall ms from the file-I/O window. cpu_share_pct and io_share_pct are the database's share of the server's CPU and I/O, rounded to 0.01. rows lists every database up to 500, ordered by CPU, then I/O, then name; database_count is the number of databases and truncated says when there were more. Older windows read the hourly or daily rollups, chosen by the same rule as the desktop viewer. top_by_total and top_by_avg rank databases by total CPU and by CPU per execution, from the query-grain window, and limit caps only these two lists. top_by_total can include databases with 0 CPU that only had file I/O; a database with no executions is left out of top_by_avg, so top_by_avg is empty when no database has executions in the window; an unattributed one is left out of top_by_total. Above 72 hours, rows reads the per-database rollup and the top lists read the query-grain rollup, so one database's CPU can differ between them. avg_cpu_ms is rounded to a whole millisecond, avg_io_mb has 4 decimals, io_total_mb has 2, and total_cpu_ms is the total behind the average. The top lists follow hours_back, while the desktop's top-consumer grids always cover 24 hours, so send hours_back=24 to match them. No cost fields.";
+ "database_resources gives one row per database: CPU ms, logical and physical reads, logical writes and executions from the query-stats window, and read and write MB and stall ms from the file-I/O window. cpu_share_pct and io_share_pct are the database's share of the server's CPU and I/O, rounded to 0.01. rows lists every database up to 500, ordered by CPU, then I/O, then name; database_count is the number of databases and truncated says when there were more. Older windows read the hourly or daily rollups, chosen by the same rule as the desktop viewer. top_by_total and top_by_avg rank databases by total CPU and by CPU per execution, from the query-grain window, and limit caps only these two lists. top_by_total can include databases with 0 CPU that only had file I/O; a database with no executions is left out of top_by_avg, so top_by_avg is empty when no database has executions in the window; an unattributed one is left out of top_by_total. Above 72 hours, rows reads the per-database rollup and the top lists read the query-grain rollup, so one database's CPU can differ between them. avg_cpu_ms has 2 decimals and top_by_avg is ranked on it before any rounding, avg_io_mb has 4 decimals, io_total_mb has 2, and total_cpu_ms is the total behind the average. The top lists follow hours_back, while the desktop's top-consumer grids always cover 24 hours, so send hours_back=24 to match them. No cost fields.";
/// The fixed ceiling on rows; limit caps only the two top lists.
internal const int MaxDatabaseRows = 500;
@@ -104,11 +104,11 @@ private static async Task ReadDatabaseResourcesAsync(
io_share_pct = r.PctIo,
};
- /// One top-by-CPU-per-execution row in the wire shape; CpuTimeMs holds the average on these rows.
+ /// One top-by-CPU-per-execution row in the wire shape; AvgCpuMs holds the unrounded average on these rows (#5644 W4).
internal static object TopByAvgRow(TopResourceConsumer r) => new
{
database_name = r.DatabaseName,
- avg_cpu_ms = r.CpuTimeMs,
+ avg_cpu_ms = r.AvgCpuMs,
execution_count = r.ExecutionCount,
total_cpu_ms = r.TotalCpuTimeMs,
io_total_mb = r.IoTotalMb,
diff --git a/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/finops/utilization.js b/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/finops/utilization.js
index 1065489b4a..0303b83691 100644
--- a/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/finops/utilization.js
+++ b/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/finops/utilization.js
@@ -100,7 +100,7 @@ const TOP_TOTAL_COLUMNS = [
const TOP_AVG_COLUMNS = [
{ key: "database_name", label: "Database" },
- { key: "avg_cpu_ms", label: "Avg CPU (ms)", format: "int" },
+ { key: "avg_cpu_ms", label: "Avg CPU (ms)", format: "num2" },
{ key: "avg_io_mb", label: "Avg IO (MB)", format: "num2" },
{ key: "execution_count", label: "Execs", format: "int" },
];
diff --git a/Darling/PerformanceMonitor.Darling.Storage/FinOps/DarlingFinOpsDatabaseResourcesReader.cs b/Darling/PerformanceMonitor.Darling.Storage/FinOps/DarlingFinOpsDatabaseResourcesReader.cs
index 0a2f81a673..b14ea73fe0 100644
--- a/Darling/PerformanceMonitor.Darling.Storage/FinOps/DarlingFinOpsDatabaseResourcesReader.cs
+++ b/Darling/PerformanceMonitor.Darling.Storage/FinOps/DarlingFinOpsDatabaseResourcesReader.cs
@@ -21,10 +21,12 @@ public sealed record DatabaseResourceUsage(
decimal IoReadMb, decimal IoWriteMb, long IoStallMs, decimal PctCpuShare, decimal PctIoShare);
/// One database in a top-consumer list. By-total rows carry CPU in ; by-average rows
-/// carry the average CPU per execution there and the total in .
+/// carry the total in and the average CPU per execution, unrounded, in
+/// (#5644 W4: on those rows is only the whole-ms rounding of it, kept
+/// for older callers; rank and display on ).
public sealed record TopResourceConsumer(
string DatabaseName, long CpuTimeMs, long ExecutionCount, decimal IoTotalMb, decimal PctCpu, decimal PctIo,
- long TotalCpuTimeMs, decimal AvgIoMb);
+ long TotalCpuTimeMs, decimal AvgIoMb, decimal AvgCpuMs = 0m);
///
/// The FinOps Database Resources reads (per-database usage and the top consumers), shared by the viewer and the
@@ -363,9 +365,12 @@ public static async Task> GetDatabaseResourceUsageAs
NULL exactly for those, so testing it here reproduces the old HAVING. */
if (!reader.IsDBNull(6))
{
- avgCandidates.Add(new TopResourceConsumer(dbName, Convert.ToInt64(reader.GetValue(6)), executionCount, ioTotalMb, 0m, 0m,
+ /* #5644 W4: carry the average unrounded. Convert.ToInt64 here turned 2.3, 1.95 and 1.56 into 2, 2 and 2,
+ so the tie broke on database name and the top-N cut dropped the busiest database. */
+ var avgCpuMs = Convert.ToDecimal(reader.GetValue(6));
+ avgCandidates.Add(new TopResourceConsumer(dbName, (long)Math.Round(avgCpuMs), executionCount, ioTotalMb, 0m, 0m,
cpuTimeMs,
- reader.IsDBNull(7) ? 0m : Convert.ToDecimal(reader.GetValue(7))));
+ reader.IsDBNull(7) ? 0m : Convert.ToDecimal(reader.GetValue(7)), avgCpuMs));
}
}
@@ -375,7 +380,7 @@ public static async Task> GetDatabaseResourceUsageAs
tie's order to whichever plan Postgres picked (never a documented contract, and cpu_time_ms /
avg_cpu_ms are sums of independent per-database deltas, so an exact tie is not expected in practice). */
var byTotal = totalCandidates.OrderByDescending(r => r.CpuTimeMs).Take(topN).ToList();
- var byAvg = avgCandidates.OrderByDescending(r => r.CpuTimeMs).Take(topN).ToList();
+ var byAvg = avgCandidates.OrderByDescending(r => r.AvgCpuMs).Take(topN).ToList();
return (byTotal, byAvg);
}
diff --git a/Darling/PerformanceMonitor.Darling.Viewer/FinOpsTab.xaml b/Darling/PerformanceMonitor.Darling.Viewer/FinOpsTab.xaml
index 99db5904ee..1dafbf18bd 100644
--- a/Darling/PerformanceMonitor.Darling.Viewer/FinOpsTab.xaml
+++ b/Darling/PerformanceMonitor.Darling.Viewer/FinOpsTab.xaml
@@ -456,7 +456,7 @@
-
+
diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.cs
index 91d3d99c17..f234140b08 100644
--- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.cs
+++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.cs
@@ -97,11 +97,13 @@ public sealed class TopResourceConsumerRow
public decimal PctIo { get; set; }
public long TotalCpuTimeMs { get; set; }
public decimal AvgIoMb { get; set; }
+ /// The by-average rows' CPU per execution, unrounded (#5644 W4).
+ public decimal AvgCpuMs { get; set; }
public static TopResourceConsumerRow From(PerformanceMonitor.Darling.Storage.FinOps.TopResourceConsumer d) => new()
{
DatabaseName = d.DatabaseName, CpuTimeMs = d.CpuTimeMs, ExecutionCount = d.ExecutionCount, IoTotalMb = d.IoTotalMb,
- PctCpu = d.PctCpu, PctIo = d.PctIo, TotalCpuTimeMs = d.TotalCpuTimeMs, AvgIoMb = d.AvgIoMb
+ PctCpu = d.PctCpu, PctIo = d.PctIo, TotalCpuTimeMs = d.TotalCpuTimeMs, AvgIoMb = d.AvgIoMb, AvgCpuMs = d.AvgCpuMs
};
}
diff --git a/Lite.Tests/PvsStatsCollectorDefinitionTests.cs b/Lite.Tests/PvsStatsCollectorDefinitionTests.cs
index ede2b2d024..7aa3e7e0a6 100644
--- a/Lite.Tests/PvsStatsCollectorDefinitionTests.cs
+++ b/Lite.Tests/PvsStatsCollectorDefinitionTests.cs
@@ -153,10 +153,25 @@ public void OnPrem_IsInstanceWide_AndUsesMasterFiles()
var query = PvsStatsCollector.Instance.BuildQuery(MakeContext());
Assert.Contains("sys.master_files", query.Text, StringComparison.Ordinal);
- Assert.DoesNotContain("sys.database_files", query.Text, StringComparison.Ordinal);
+ /* Only the tempdb-qualified form (#5644 W5, below) may appear; the unqualified view is the Azure path's. */
+ Assert.DoesNotContain("sys.database_files", query.Text.Replace("tempdb.sys.database_files", "", StringComparison.Ordinal), StringComparison.Ordinal);
Assert.DoesNotContain("DB_ID()", query.Text, StringComparison.Ordinal);
}
+ [Fact]
+ public void OnPrem_SizesTempdbFromItsLiveFiles_NotTheStartupSizeInMasterFiles()
+ {
+ /* #5644 W5: sys.master_files carries tempdb's size at startup (32 GB against a live 44 GB), so the
+ denominator for database_id 2 comes from tempdb.sys.database_files. */
+ var text = PvsStatsCollector.Instance.BuildQuery(MakeContext()).Text;
+
+ Assert.Contains("FROM tempdb.sys.database_files AS tdbf", text, StringComparison.Ordinal);
+ Assert.Equal(1, CountOccurrences(text, "OUTER APPLY"));
+ Assert.Contains("AND mf.database_id <> 2", text, StringComparison.Ordinal);
+ Assert.Contains("WHERE pvss.database_id = 2", text, StringComparison.Ordinal);
+ Assert.DoesNotContain("tempdb.sys.database_files", PvsStatsCollector.Instance.BuildQuery(MakeContext(isAzureSqlDb: true)).Text, StringComparison.Ordinal);
+ }
+
[Fact]
public void ManagedInstance_TakesTheOnPremPath()
{
diff --git a/Lite/Controls/FinOpsTab.xaml b/Lite/Controls/FinOpsTab.xaml
index a5c5149796..35fafece78 100644
--- a/Lite/Controls/FinOpsTab.xaml
+++ b/Lite/Controls/FinOpsTab.xaml
@@ -569,7 +569,7 @@
LoadingRow="MarkedGrid_LoadingRow">
-
+
diff --git a/Lite/Services/LocalDataService.FinOps.Workload.cs b/Lite/Services/LocalDataService.FinOps.Workload.cs
index effcff2661..3d05f34217 100644
--- a/Lite/Services/LocalDataService.FinOps.Workload.cs
+++ b/Lite/Services/LocalDataService.FinOps.Workload.cs
@@ -293,7 +293,9 @@ GetTopResourceConsumersByTotalAsync statement's WHERE c.database_name IS NOT NUL
avgCandidates.Add(new TopResourceConsumerRow
{
DatabaseName = dbName,
- CpuTimeMs = ToInt64(reader.GetValue(6)),
+ /* #5644 W4: the average stays unrounded for the ranking; CpuTimeMs is only its whole-ms rounding. */
+ CpuTimeMs = (long)Math.Round(Convert.ToDecimal(reader.GetValue(6))),
+ AvgCpuMs = Convert.ToDecimal(reader.GetValue(6)),
ExecutionCount = executionCount,
IoTotalMb = ioTotalMb,
TotalCpuTimeMs = cpuTimeMs,
@@ -307,7 +309,7 @@ GetTopResourceConsumersByTotalAsync statement's WHERE c.database_name IS NOT NUL
statement's ORDER BY c.database_name — deterministic, where the old per-grid statements left a tie's
order to whichever plan DuckDB picked (never a documented contract). */
var byTotal = totalCandidates.OrderByDescending(r => r.CpuTimeMs).Take(topN).ToList();
- var byAvg = avgCandidates.OrderByDescending(r => r.CpuTimeMs).Take(topN).ToList();
+ var byAvg = avgCandidates.OrderByDescending(r => r.AvgCpuMs).Take(topN).ToList();
return (byTotal, byAvg);
}
diff --git a/Lite/Services/LocalDataService.FinOps.cs b/Lite/Services/LocalDataService.FinOps.cs
index f8bbeae0bf..6ddaffc019 100644
--- a/Lite/Services/LocalDataService.FinOps.cs
+++ b/Lite/Services/LocalDataService.FinOps.cs
@@ -52,6 +52,8 @@ public class TopResourceConsumerRow
public decimal PctIo { get; set; }
public long TotalCpuTimeMs { get; set; }
public decimal AvgIoMb { get; set; }
+ /// The by-average rows' CPU per execution, unrounded (#5644 W4).
+ public decimal AvgCpuMs { get; set; }
}
public class DatabaseSizeSummaryRow
diff --git a/PerformanceMonitor.Collectors/PvsStatsCollector.cs b/PerformanceMonitor.Collectors/PvsStatsCollector.cs
index 038653eb69..0b551c9f16 100644
--- a/PerformanceMonitor.Collectors/PvsStatsCollector.cs
+++ b/PerformanceMonitor.Collectors/PvsStatsCollector.cs
@@ -205,7 +205,7 @@ public readonly record struct Row(
online_index_version_store_size_mb =
CONVERT(decimal(19,2), pvss.online_index_version_store_size_kb / 1024.0),
database_data_size_mb =
- CONVERT(decimal(19,2), df.total_db_size_kb / 1024.0),
+ CONVERT(decimal(19,2), df.total_db_size_kb / 1024.0),,
current_aborted_transaction_count =
pvss.current_aborted_transaction_count,
oldest_active_transaction_id =
@@ -243,13 +243,31 @@ JOIN sys.databases AS d
ON d.database_id = pvss.database_id
OUTER APPLY
(
+ /* #5644 W5: sys.master_files holds tempdb's size at startup (32 GB against a live 44 GB in the audit), so
+ database_id 2 reads its live size from tempdb.sys.database_files instead. The two legs are exclusive
+ on database_id, and the aggregate stays scalar: exactly one row, so this cannot multiply the PVS rows. */
SELECT
total_db_size_kb =
- SUM(mf.size * 8.0)
- FROM sys.master_files AS mf
- WHERE mf.database_id = pvss.database_id
- AND mf.state = 0 /*ONLINE*/
- AND mf.type = 0 /*ROWS*/
+ SUM(sz.size * 8.0)
+ FROM
+ (
+ SELECT
+ mf.size
+ FROM sys.master_files AS mf
+ WHERE mf.database_id = pvss.database_id
+ AND mf.database_id <> 2
+ AND mf.state = 0 /*ONLINE*/
+ AND mf.type = 0 /*ROWS*/
+
+ UNION ALL
+
+ SELECT
+ tdbf.size
+ FROM tempdb.sys.database_files AS tdbf
+ WHERE pvss.database_id = 2
+ AND tdbf.state = 0 /*ONLINE*/
+ AND tdbf.type = 0 /*ROWS*/
+ ) AS sz
) AS df
WHERE 1 = 1
/*EXCLUSION_FILTER*/
From 3139747fd4c66aa8ce5a2fc07e9488c43bdbb188 Mon Sep 17 00:00:00 2001
From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com>
Date: Mon, 12 Oct 2026 15:14:22 -0400
Subject: [PATCH 03/42] Web audit labels W9, W14, W16, W19: peak_granted_at,
Triage/Mute first, Deadlock trend, honest labels (#5644)
---
.../FinOpsTabUtilizationPageTests.cs | 2 +-
.../McpLatestSnapshotStampTests.cs | 7 +-
Darling/Darling.Tests/MuteRulesPageTests.cs | 2 +-
.../Darling.Tests/WebAuditLaneDLabelTests.cs | 74 +++++++++++++++++++
.../Mcp/DarlingMcpMemoryGrantTools.cs | 5 +-
.../Mcp/DarlingMemoryGrantReader.cs | 37 ++++++++--
.../wwwroot/js/pages/alerts.js | 8 +-
.../js/pages/finops/server-inventory.js | 2 +-
.../wwwroot/js/pages/finops/utilization.js | 12 +--
.../wwwroot/js/pages/fleet.js | 13 +++-
.../wwwroot/js/pages/server-tabs.js | 24 +++---
Lite.Tests/McpLatestSnapshotStampTests.cs | 8 +-
Lite/Mcp/McpMemoryTools.cs | 5 +-
.../Services/LocalDataService.MemoryGrants.cs | 36 +++++++--
14 files changed, 192 insertions(+), 43 deletions(-)
create mode 100644 Darling/Darling.Tests/WebAuditLaneDLabelTests.cs
diff --git a/Darling/Darling.Tests/FinOpsTabUtilizationPageTests.cs b/Darling/Darling.Tests/FinOpsTabUtilizationPageTests.cs
index 48e1f0e9f1..dc4afae583 100644
--- a/Darling/Darling.Tests/FinOpsTabUtilizationPageTests.cs
+++ b/Darling/Darling.Tests/FinOpsTabUtilizationPageTests.cs
@@ -106,7 +106,7 @@ public void TheSummaryNamesItsWindowAndFormatsMaxCpuAsAnInteger()
var tab = Tab();
Assert.Contains("const SUMMARY_SUBTITLE = \"Last 24 hours\";", tab);
Assert.Contains("el(\"span\", { class: \"panel-sub\", text: \" \" + SUMMARY_SUBTITLE })", tab);
- Assert.Contains("{ key: \"cpu.max_cpu_pct\", label: \"Max CPU %\", format: \"int\" }", tab);
+ Assert.Contains("{ key: \"cpu.max_cpu_pct\", label: \"Max SQL Server CPU %\", format: \"int\" }", tab);
Assert.True(tab.IndexOf("SUMMARY_SUBTITLE })", System.StringComparison.Ordinal) < tab.IndexOf("VIZ.stat(view", System.StringComparison.Ordinal));
}
diff --git a/Darling/Darling.Tests/McpLatestSnapshotStampTests.cs b/Darling/Darling.Tests/McpLatestSnapshotStampTests.cs
index 111777c2e9..587bbb1a6a 100644
--- a/Darling/Darling.Tests/McpLatestSnapshotStampTests.cs
+++ b/Darling/Darling.Tests/McpLatestSnapshotStampTests.cs
@@ -925,8 +925,8 @@ public async Task LatestReads_SayWhenTheyWereCaptured_AgainstLivePostgres()
var anchor = @base.AddMinutes(5).ToString("o") + "Z";
var runningJobsLatest = DarlingMcpTestData.TruncateToSeconds(DateTime.UtcNow).AddMinutes(-2);
- /* ── memory grants: a storm 30 minutes before a calm latest snapshot ── */
- foreach (var (t, waiters, timeouts, granted) in new[] { (@base.AddMinutes(-30), 12, 3L, 6000m), (@base, 0, 0L, 500m) })
+ /* ── memory grants: a storm 30 minutes before a calm latest snapshot; the largest grant (#5644 W9) is the calm one ── */
+ foreach (var (t, waiters, timeouts, granted) in new[] { (@base.AddMinutes(-30), 12, 3L, 500m), (@base, 0, 0L, 6000m) })
{
await DarlingMcpTestData.ExecAsync(connection, ct,
@"INSERT INTO memory_grant_stats (collection_id, collection_time, server_id, server_name, resource_semaphore_id, pool_id, target_memory_mb, max_target_memory_mb, total_memory_mb, available_memory_mb, granted_memory_mb, used_memory_mb, grantee_count, waiter_count, timeout_error_count, forced_grant_count, timeout_error_count_delta, forced_grant_count_delta)
@@ -945,6 +945,7 @@ await DarlingMcpTestData.ExecAsync(connection, ct,
Assert.Equal(Stamp(@base.AddMinutes(-30)), windowRow.GetProperty("peak_waiters_at").GetString());
Assert.Equal(3, windowRow.GetProperty("timeout_errors_in_window").GetInt64());
Assert.Equal(6000d, windowRow.GetProperty("peak_granted_memory_mb").GetDouble());
+ Assert.Equal(Stamp(@base), windowRow.GetProperty("peak_granted_at").GetString());
Assert.Equal(2000d, windowRow.GetProperty("min_available_memory_mb").GetDouble());
Assert.Equal(Stamp(@base), windowRow.GetProperty("last_snapshot_at").GetString());
@@ -954,6 +955,8 @@ await DarlingMcpTestData.ExecAsync(connection, ct,
var poolWindow = Assert.Single(grants.GetProperty("window").EnumerateArray());
Assert.Equal(JsonValueKind.Null, poolWindow.GetProperty("resource_semaphore_id").ValueKind);
Assert.Equal(12, poolWindow.GetProperty("peak_waiter_count").GetInt64());
+ Assert.Equal(Stamp(@base), poolWindow.GetProperty("peak_granted_at").GetString());
+ Assert.Equal(Stamp(@base.AddMinutes(-30)), poolWindow.GetProperty("peak_waiters_at").GetString());
/* ── scheduler: one snapshot three hours before the anchor — inside a 4 h search, outside a 1 h one ── */
var schedulerAt = @base.AddMinutes(5).AddHours(-3);
diff --git a/Darling/Darling.Tests/MuteRulesPageTests.cs b/Darling/Darling.Tests/MuteRulesPageTests.cs
index c9dd48f37d..943ec409b3 100644
--- a/Darling/Darling.Tests/MuteRulesPageTests.cs
+++ b/Darling/Darling.Tests/MuteRulesPageTests.cs
@@ -64,7 +64,7 @@ public void AlertHistoryOffersMuteActionsOnlyToAnEditingSeat()
{
var alerts = Wwwroot("js", "pages", "alerts.js");
Assert.Contains("canMute = !!(await getSession()).can_edit;", alerts);
- Assert.Contains("return canMute ? [SELECT_COLUMN].concat(ALERT_COLUMNS, [MUTE_COLUMN]) : ALERT_COLUMNS;", alerts);
+ Assert.Contains("return canMute ? [SELECT_COLUMN, TRIAGE_COLUMN, MUTE_COLUMN].concat(ALERT_COLUMNS) : [TRIAGE_COLUMN].concat(ALERT_COLUMNS);", alerts);
Assert.Contains("link(\"Mute this alert\", mutePrefillParams(a))", alerts);
Assert.DoesNotContain("server_name: a.server_name", alerts);
Assert.Contains("stored_server_name = r.StoredServerName", File.ReadAllText(PathTo("Darling", "PerformanceMonitor.Darling.Service", "Mcp", "DarlingMcpAlertTools.cs")));
diff --git a/Darling/Darling.Tests/WebAuditLaneDLabelTests.cs b/Darling/Darling.Tests/WebAuditLaneDLabelTests.cs
new file mode 100644
index 0000000000..ad51d2d750
--- /dev/null
+++ b/Darling/Darling.Tests/WebAuditLaneDLabelTests.cs
@@ -0,0 +1,74 @@
+/*
+ * Copyright (c) 2026 Erik Darling, Darling Data LLC
+ *
+ * This file is part of the SQL Server Performance Monitor.
+ *
+ * Licensed under the MIT License. See LICENSE file in the project root for full license information.
+ */
+
+using System;
+using Xunit;
+using static Darling.Tests.RepoFile;
+
+namespace Darling.Tests;
+
+///
+/// Source pins for the #5644 web UI audit labels W9, W14, W16 and W19: each label says what its value is. No JS runner
+/// is available to the C# tests, so these read the page sources, the way MuteRulesPageTests does.
+///
+public sealed class WebAuditLaneDLabelTests
+{
+ private static string Js(params string[] parts) =>
+ ReadRepoFile([.. new[] { "Darling", "PerformanceMonitor.Darling.Service", "wwwroot", "js" }, .. parts]).ReplaceLineEndings("\n");
+
+ [Fact]
+ public void W9_TheMemoryGrantWindowShowsTheTimeOfThePeakGrantBesideThePeakGrant()
+ {
+ var tabs = Js("pages", "server-tabs.js");
+ Assert.Contains("{ key: \"peak_waiters_at\", label: \"Peak waiters at\", format: \"time\" },", tabs, StringComparison.Ordinal);
+ Assert.Contains(
+ "{ key: \"peak_granted_memory_mb\", label: \"Peak granted\", format: \"mb\" },\n { key: \"peak_granted_at\", label: \"Peak granted at\", format: \"time\" },",
+ tabs, StringComparison.Ordinal);
+ Assert.DoesNotContain("label: \"Peak at\"", tabs, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void W14_TriageAndMuteAreTheFirstAlertHistoryColumns()
+ {
+ var alerts = Js("pages", "alerts.js");
+ Assert.Contains("[SELECT_COLUMN, TRIAGE_COLUMN, MUTE_COLUMN].concat(ALERT_COLUMNS)", alerts, StringComparison.Ordinal);
+ Assert.Contains(": [TRIAGE_COLUMN].concat(ALERT_COLUMNS)", alerts, StringComparison.Ordinal);
+ Assert.DoesNotContain("{ key: \"triage\", label: \"Triage\", csv: false, render: (a) => triageCell(a) },\n];", alerts, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void W16_TheDeadlockTrendChartAndTheDeadlocksTableAreNamedApartAndEmptyTheSameWay()
+ {
+ var tabs = Js("pages", "server-tabs.js");
+ Assert.Contains("line(\"Deadlock trend\", \"get_deadlock_trend\", { server, hours: ctx.hours }, \"trend\", \"time\", COUNT_SERIES, {\n subtitle: ctx.label,\n format: \"int\",\n atTimeItem: \"deadlocks\",\n emptyText: DEADLOCKS_EMPTY_TEXT,", tabs, StringComparison.Ordinal);
+ Assert.Contains("const DEADLOCKS_EMPTY_TEXT = \"No deadlocks in this window.\";", tabs, StringComparison.Ordinal);
+ Assert.Contains(" ctx.label,\n DEADLOCKS_EMPTY_TEXT,\n 2,\n \"separately_monitored_note\"", tabs, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void W19_EachMisleadingLabelNamesItsValue()
+ {
+ var tabs = Js("pages", "server-tabs.js");
+ Assert.Contains("{ key: \"hyperthread_ratio\", label: \"Logical CPUs per socket\"", tabs, StringComparison.Ordinal);
+ Assert.DoesNotContain("label: \"HT ratio\"", tabs, StringComparison.Ordinal);
+ Assert.Contains("{ key: \"plan_cache_mb\", label: \"Plan cache (memory clerk)\", format: \"mb\" },", tabs, StringComparison.Ordinal);
+ Assert.Contains("{ key: \"summary.total_size_mb\", label: \"Cached plans size\", format: \"mb\" },", tabs, StringComparison.Ordinal);
+ Assert.Contains("{ key: \"pct_of_sweep_budget_per_run\", label: \"% of run budget\"", tabs, StringComparison.Ordinal);
+ /* fmtMs prints seconds above a second ("32.9 s"), so the header carries no unit. */
+ Assert.Contains("{ key: \"duration_ms\", label: \"Duration\", format: \"ms\" },\n { key: \"signal_duration_ms\", label: \"Signal duration\", format: \"ms\" },", tabs, StringComparison.Ordinal);
+
+ var utilization = Js("pages", "finops", "utilization.js");
+ foreach (var label in new[] { "Avg SQL Server CPU %", "P95 SQL Server CPU %", "Max SQL Server CPU %" })
+ Assert.Contains("label: \"" + label + "\"", utilization, StringComparison.Ordinal);
+ Assert.Contains("label: \"Avg SQL Server CPU %\"", Js("pages", "finops", "server-inventory.js"), StringComparison.Ordinal);
+
+ var fleet = Js("pages", "fleet.js");
+ Assert.Contains("chip(c.total_cpu_percent != null ? \"Total CPU\" : \"CPU\", cpuValue, c.cpu_severity, cpuDetail)", fleet, StringComparison.Ordinal);
+ Assert.Contains("fmtInt(threadsInUse) + \" in use / \" + fmtInt(c.total_threads) + \" threads\"", fleet, StringComparison.Ordinal);
+ }
+}
diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpMemoryGrantTools.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpMemoryGrantTools.cs
index 9648511d24..0ac9055fb5 100644
--- a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpMemoryGrantTools.cs
+++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpMemoryGrantTools.cs
@@ -43,7 +43,7 @@ namespace PerformanceMonitor.Darling.Service.Mcp;
[McpServerToolType]
public sealed class DarlingMcpMemoryGrantTools
{
- [McpServerTool(Name = "get_resource_semaphore"), Description("Resource semaphore stats: granted/available memory vs target/max-target ceiling, waiters, timeout/forced pressure, window ends at as_of. TWO READS, per semaphore+pool: grants[] is the NEWEST snapshot (moment: captured_at/age_seconds); window[] aggregates EVERY snapshot (peak waiters+when, peak/min memory, summed timeout/forced deltas). Read window[] for 'was there pressure', grants[] for 'is there pressure now'. No rows: unavailable (not_collected first). sample_interval_seconds/interval_known null/false on a restart-marker or pre-column row; its zero deltas aren't 'no timeouts'. <> Gets resource semaphore statistics showing granted vs available workspace memory against the target/max-target ceiling, waiter counts, and timeout/forced grant pressure indicators. High waiter counts or rising timeout/forced deltas indicate memory grant pressure affecting query performance. TWO READS UNDER ONE WINDOW: grants[] is the NEWEST snapshot in the window (one row per resource semaphore and pool), stamped once as captured_at with age_seconds against the window's end - it is a moment, not the window. window[] aggregates EVERY snapshot in the window per (resource_semaphore_id, pool_id): peak_waiter_count and peak_waiters_at (the most sessions ever seen waiting for a grant and when), peak_granted_memory_mb, min_available_memory_mb, and timeout_errors_in_window / forced_grants_in_window (the SUM of the per-interval deltas across the window). Each grants[] row also carries sample_interval_seconds, the measured seconds its two deltas accrued over; it is null with interval_known false when the row is a restart marker (no delta was knowable, so the zero deltas beside it are not 'no timeouts') or predates the column. interval_seconds is the interval these deltas cover, null when it can't be known - the same name the latch and spinlock tools use; sample_interval_seconds carries the same value and is kept for existing readers. " + McpToolGuideTopics.MemoryGrantWindowReadOrder)]
+ [McpServerTool(Name = "get_resource_semaphore"), Description("Resource semaphore stats: granted/available memory vs target/max-target ceiling, waiters, timeout/forced pressure, window ends at as_of. TWO READS, per semaphore+pool: grants[] is the NEWEST snapshot (moment: captured_at/age_seconds); window[] aggregates EVERY snapshot (peak waiters+when, peak/min memory, summed timeout/forced deltas). Read window[] for 'was there pressure', grants[] for 'is there pressure now'. No rows: unavailable (not_collected first). sample_interval_seconds/interval_known null/false on a restart-marker or pre-column row; its zero deltas aren't 'no timeouts'. <> Gets resource semaphore statistics showing granted vs available workspace memory against the target/max-target ceiling, waiter counts, and timeout/forced grant pressure indicators. High waiter counts or rising timeout/forced deltas indicate memory grant pressure affecting query performance. TWO READS UNDER ONE WINDOW: grants[] is the NEWEST snapshot in the window (one row per resource semaphore and pool), stamped once as captured_at with age_seconds against the window's end - it is a moment, not the window. window[] aggregates EVERY snapshot in the window per (resource_semaphore_id, pool_id): peak_waiter_count and peak_waiters_at (the most sessions ever seen waiting for a grant and when), peak_granted_memory_mb and peak_granted_at (the largest granted memory and when - not the time of peak waiters), min_available_memory_mb, and timeout_errors_in_window / forced_grants_in_window (the SUM of the per-interval deltas across the window). Each grants[] row also carries sample_interval_seconds, the measured seconds its two deltas accrued over; it is null with interval_known false when the row is a restart marker (no delta was knowable, so the zero deltas beside it are not 'no timeouts') or predates the column. interval_seconds is the interval these deltas cover, null when it can't be known - the same name the latch and spinlock tools use; sample_interval_seconds carries the same value and is kept for existing readers. " + McpToolGuideTopics.MemoryGrantWindowReadOrder)]
public static async Task GetResourceSemaphore(
NpgsqlDataSource postgres,
[Description("Server name or display name.")] string? server_name = null,
@@ -121,7 +121,7 @@ the original sample_interval_seconds rather than in place of it. */
}
}
- [McpServerTool(Name = "get_memory_grants"), Description("Gets resource semaphore stats: granted vs available workspace memory per resource pool, waiters, timeout/forced grant deltas, over a window ending at as_of (default 1h). TWO READS: grants[] is the NEWEST snapshot in the window (a moment: captured_at/age_seconds), one row per pool; window[] aggregates EVERY snapshot in it, per pool (peak waiters+when, peak/min memory, summed timeout/forced deltas). Read window[] for 'was there pressure', grants[] for 'is there pressure now'. No rows: unavailable (or not_collected first). <> Gets resource semaphore statistics showing granted vs available workspace memory per resource pool, waiter counts, and timeout/forced grant deltas. High waiter counts or rising timeout deltas indicate memory grant pressure affecting query performance. TWO READS UNDER ONE WINDOW: grants[] is the NEWEST snapshot in the window (one row per pool, summed across its semaphores), stamped once as captured_at with age_seconds against the window's end - it is a moment, not the window. window[] aggregates EVERY snapshot in the window per pool: peak_waiter_count and peak_waiters_at (the most sessions ever seen waiting on the pool at one instant and when), peak_granted_memory_mb, min_available_memory_mb, and timeout_errors_in_window / forced_grants_in_window (the SUM of the per-interval deltas across the window). " + McpToolGuideTopics.MemoryGrantWindowReadOrder)]
+ [McpServerTool(Name = "get_memory_grants"), Description("Gets resource semaphore stats: granted vs available workspace memory per resource pool, waiters, timeout/forced grant deltas, over a window ending at as_of (default 1h). TWO READS: grants[] is the NEWEST snapshot in the window (a moment: captured_at/age_seconds), one row per pool; window[] aggregates EVERY snapshot in it, per pool (peak waiters+when, peak/min memory, summed timeout/forced deltas). Read window[] for 'was there pressure', grants[] for 'is there pressure now'. No rows: unavailable (or not_collected first). <> Gets resource semaphore statistics showing granted vs available workspace memory per resource pool, waiter counts, and timeout/forced grant deltas. High waiter counts or rising timeout deltas indicate memory grant pressure affecting query performance. TWO READS UNDER ONE WINDOW: grants[] is the NEWEST snapshot in the window (one row per pool, summed across its semaphores), stamped once as captured_at with age_seconds against the window's end - it is a moment, not the window. window[] aggregates EVERY snapshot in the window per pool: peak_waiter_count and peak_waiters_at (the most sessions ever seen waiting on the pool at one instant and when), peak_granted_memory_mb and peak_granted_at (the largest granted memory and when - not the time of peak waiters), min_available_memory_mb, and timeout_errors_in_window / forced_grants_in_window (the SUM of the per-interval deltas across the window). " + McpToolGuideTopics.MemoryGrantWindowReadOrder)]
public static async Task GetMemoryGrants(
NpgsqlDataSource postgres,
[Description("Server name or display name.")] string? server_name = null,
@@ -195,6 +195,7 @@ public static async Task GetMemoryGrants(
peak_waiter_count = w.PeakWaiterCount,
peak_waiters_at = w.PeakWaitersAt.ToString("o"),
peak_granted_memory_mb = Math.Round(w.PeakGrantedMemoryMb, 2),
+ peak_granted_at = w.PeakGrantedAt.ToString("o"),
min_available_memory_mb = Math.Round(w.MinAvailableMemoryMb, 2),
timeout_errors_in_window = w.TimeoutErrorsInWindow,
forced_grants_in_window = w.ForcedGrantsInWindow
diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMemoryGrantReader.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMemoryGrantReader.cs
index aa22acced8..ae3e0e73d2 100644
--- a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMemoryGrantReader.cs
+++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMemoryGrantReader.cs
@@ -74,13 +74,14 @@ public sealed record MemoryGrantRow(
/// One (resource_semaphore_id, pool_id) — or, for the pool lens, one pool with
/// null — aggregated over EVERY snapshot in the window (#3541 A10). /
/// is the storm detector: the most sessions ever seen waiting for a grant in the
- /// window and the snapshot it happened at. The two *InWindow figures are SUMs of the stored per-interval
+ /// window and the snapshot it happened at. is the snapshot of the largest
+ /// granted memory (), a different moment when nothing waited then (#5644 W9). The two *InWindow figures are SUMs of the stored per-interval
/// deltas — how many grants timed out / were forced across the whole window, not just the last interval.
///
public sealed record MemoryGrantWindowRow(
short? ResourceSemaphoreId, int PoolId, long SnapshotsInWindow, DateTime FirstSnapshotAt, DateTime LastSnapshotAt,
long PeakWaiterCount, DateTime PeakWaitersAt, double PeakGrantedMemoryMb, double MinAvailableMemoryMb,
- long TimeoutErrorsInWindow, long ForcedGrantsInWindow);
+ long TimeoutErrorsInWindow, long ForcedGrantsInWindow, DateTime PeakGrantedAt);
/* ─────────────────────────── resource semaphore (latest snapshot, per semaphore) ─────────────────────────── */
@@ -275,6 +276,15 @@ SELECT DISTINCT ON (resource_semaphore_id, pool_id)
collection_time AS peak_waiters_at
FROM windowed
ORDER BY resource_semaphore_id, pool_id, waiter_count DESC, collection_time DESC
+ ),
+ peak_grant AS
+ (
+ SELECT DISTINCT ON (resource_semaphore_id, pool_id)
+ resource_semaphore_id,
+ pool_id,
+ collection_time AS peak_granted_at
+ FROM windowed
+ ORDER BY resource_semaphore_id, pool_id, granted_memory_mb DESC NULLS LAST, collection_time DESC
)
SELECT
a.resource_semaphore_id,
@@ -287,11 +297,15 @@ FROM windowed
a.peak_granted_memory_mb,
a.min_available_memory_mb,
a.timeout_errors_in_window,
- a.forced_grants_in_window
+ a.forced_grants_in_window,
+ pg.peak_granted_at
FROM agg AS a
JOIN peak AS p
ON p.resource_semaphore_id = a.resource_semaphore_id
AND p.pool_id = a.pool_id
+ JOIN peak_grant AS pg
+ ON pg.resource_semaphore_id = a.resource_semaphore_id
+ AND pg.pool_id = a.pool_id
ORDER BY a.pool_id, a.resource_semaphore_id
""";
@@ -341,6 +355,14 @@ SELECT DISTINCT ON (pool_id)
collection_time AS peak_waiters_at
FROM per_snapshot
ORDER BY pool_id, waiter_count DESC, collection_time DESC
+ ),
+ peak_grant AS
+ (
+ SELECT DISTINCT ON (pool_id)
+ pool_id,
+ collection_time AS peak_granted_at
+ FROM per_snapshot
+ ORDER BY pool_id, granted_memory_mb DESC NULLS LAST, collection_time DESC
)
SELECT
CAST(NULL AS smallint) AS resource_semaphore_id,
@@ -353,9 +375,11 @@ FROM per_snapshot
a.peak_granted_memory_mb,
a.min_available_memory_mb,
a.timeout_errors_in_window,
- a.forced_grants_in_window
+ a.forced_grants_in_window,
+ pg.peak_granted_at
FROM agg AS a
JOIN peak AS p ON p.pool_id = a.pool_id
+ JOIN peak_grant AS pg ON pg.pool_id = a.pool_id
ORDER BY a.pool_id
""";
@@ -367,7 +391,7 @@ public static Task> GetMemoryGrantsWindowAsync(
NpgsqlDataSource postgres, int serverId, DateTime startUtc, DateTime endUtc, CancellationToken cancellationToken = default) =>
ReadWindowAsync(postgres, MemoryGrantsWindowSql, serverId, startUtc, endUtc, cancellationToken);
- /// The two window reads project the SAME eleven columns in the same order (the pool lens fills
+ /// The two window reads project the SAME twelve columns in the same order (the pool lens fills
/// resource_semaphore_id with a typed NULL), so one materialiser serves both.
private static async Task> ReadWindowAsync(
NpgsqlDataSource postgres, string sql, int serverId, DateTime startUtc, DateTime endUtc, CancellationToken cancellationToken)
@@ -390,7 +414,8 @@ private static async Task> ReadWindowAsync(
reader.IsDBNull(7) ? 0 : reader.GetDouble(7),
reader.IsDBNull(8) ? 0 : reader.GetDouble(8),
reader.IsDBNull(9) ? 0 : reader.GetInt64(9),
- reader.IsDBNull(10) ? 0 : reader.GetInt64(10)));
+ reader.IsDBNull(10) ? 0 : reader.GetInt64(10),
+ reader.GetDateTime(11)));
}
return rows;
diff --git a/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/alerts.js b/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/alerts.js
index e9f4c26e9d..c13a7647ab 100644
--- a/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/alerts.js
+++ b/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/alerts.js
@@ -50,9 +50,13 @@ const ALERT_COLUMNS = [
{ key: "threshold_value", label: "Threshold", format: "num1" },
{ key: "status", label: "Status", render: (a) => statusCell(a) },
{ key: "detail_text", label: "Detail", render: (a) => detailCell(a), copyValue: (a) => detailFullText(a) },
- { key: "triage", label: "Triage", csv: false, render: (a) => triageCell(a) },
];
+/* W14 (#5644): Triage and Mute are the grid's FIRST columns. At 1600 px the 1,791 px grid sat in a 1,298 px box and the
+ two actions were the last things reached by horizontal scroll. The checkbox column (a seat that can edit) stays
+ ahead of them. */
+const TRIAGE_COLUMN = { key: "triage", label: "Triage", csv: false, render: (a) => triageCell(a) };
+
/* The Mute column exists only for a seat whose session reports can_edit (the server enforces the write gate;
this is only the affordance). Both links open the Mute Rules create form pre-filled from the row. "Mute this
alert" keys on the row's store id when it has one and otherwise on the stored server spelling, and fills the
@@ -61,7 +65,7 @@ const ALERT_COLUMNS = [
let canMute = false;
const MUTE_COLUMN = { key: "mute", label: "Mute", csv: false, render: (a) => muteCell(a) };
function alertColumns() {
- return canMute ? [SELECT_COLUMN].concat(ALERT_COLUMNS, [MUTE_COLUMN]) : ALERT_COLUMNS;
+ return canMute ? [SELECT_COLUMN, TRIAGE_COLUMN, MUTE_COLUMN].concat(ALERT_COLUMNS) : [TRIAGE_COLUMN].concat(ALERT_COLUMNS);
}
/* Dismiss (the web twin of the Viewer's Dismiss Selected / Dismiss All), for a seat that can edit. The checked
diff --git a/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/finops/server-inventory.js b/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/finops/server-inventory.js
index d80641d0e3..cdab8b594b 100644
--- a/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/finops/server-inventory.js
+++ b/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/finops/server-inventory.js
@@ -37,7 +37,7 @@ const COLUMNS = [
{ key: "cores_per_socket", label: "Cores/Socket", format: "int" },
{ key: "hardware_note", label: "Hardware note", wrap: true },
{ key: "provisioning_status", label: "Status", sevKey: "provisioning_sev" },
- { key: "avg_cpu_pct", label: "Avg CPU %", format: "num1" },
+ { key: "avg_cpu_pct", label: "Avg SQL Server CPU %", format: "num1" },
{ key: "storage_total_gb", label: "Storage GB", format: "num1" },
{ key: "idle_db_count", label: "Idle DBs", format: "int" },
{ key: "sqlserver_start_time_local", label: "Start time (server clock)" },
diff --git a/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/finops/utilization.js b/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/finops/utilization.js
index 1065489b4a..aef6cf7405 100644
--- a/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/finops/utilization.js
+++ b/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/finops/utilization.js
@@ -62,9 +62,9 @@ const SUMMARY_STATS = [
{ key: "health_score", label: "Health", format: "int" },
{ key: "monthly_cost_text", label: "Budget", format: "text" },
{ key: "annual_cost_text", label: "Annual", format: "text" },
- { key: "cpu.avg_cpu_pct", label: "Avg CPU %", format: "num1" },
- { key: "cpu.p95_cpu_pct", label: "P95 CPU %", format: "num1" },
- { key: "cpu.max_cpu_pct", label: "Max CPU %", format: "int" },
+ { key: "cpu.avg_cpu_pct", label: "Avg SQL Server CPU %", format: "num1" },
+ { key: "cpu.p95_cpu_pct", label: "P95 SQL Server CPU %", format: "num1" },
+ { key: "cpu.max_cpu_pct", label: "Max SQL Server CPU %", format: "int" },
{ key: "cpu.cpu_count", label: "vCores", format: "int", nullKey: "cpu.cpu_count_reason", showWhen: { key: "cpu.cpu_count_unit", equals: "vcores" } },
{ key: "cpu.cpu_count", label: "CPUs", format: "int", nullKey: "cpu.cpu_count_reason", hideWhen: { key: "cpu.cpu_count_unit", equals: "vcores" } },
{ key: "cpu.max_workers", label: "Max workers", format: "int" },
@@ -82,9 +82,9 @@ const SUMMARY_STATS = [
const TREND_COLUMNS = [
{ key: "day_label", label: "Day" },
{ key: "verdict_label", label: "Status", sevKey: "verdict_sev" },
- { key: "avg_cpu_pct", label: "Avg CPU %", format: "num1" },
- { key: "p95_cpu_pct", label: "P95 CPU %", format: "num1" },
- { key: "max_cpu_pct", label: "Max CPU %", format: "int" },
+ { key: "avg_cpu_pct", label: "Avg SQL Server CPU %", format: "num1" },
+ { key: "p95_cpu_pct", label: "P95 SQL Server CPU %", format: "num1" },
+ { key: "max_cpu_pct", label: "Max SQL Server CPU %", format: "int" },
{ key: "memory_ratio", label: "Mem Ratio", format: "num2" },
];
/* utilization-keys:end */
diff --git a/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/fleet.js b/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/fleet.js
index 61550e8235..067d68f852 100644
--- a/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/fleet.js
+++ b/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/fleet.js
@@ -725,10 +725,15 @@ export function metricBands(c) {
: c.available_threads != null
? fmtInt(c.available_threads) + " free"
: "ok";
- const threadsDetail =
+ /* W19 (#5644): the tile value reads "N free", so the line under it must not repeat N over the total (that read as
+ N in use). It says how many are in use, of the total. */
+ const threadsInUse =
c.total_threads != null
- ? fmtInt(c.available_threads ?? c.total_threads - (c.current_workers ?? 0)) + " / " + fmtInt(c.total_threads) + " threads"
+ ? c.available_threads != null
+ ? c.total_threads - c.available_threads
+ : c.current_workers ?? 0
: null;
+ const threadsDetail = c.total_threads != null ? fmtInt(threadsInUse) + " in use / " + fmtInt(c.total_threads) + " threads" : null;
const cpuValue =
c.total_cpu_percent != null || c.cpu_percent != null ? fmtPct(c.total_cpu_percent ?? c.cpu_percent) : "n/a";
@@ -804,7 +809,9 @@ export function metricBands(c) {
}
return el("div", { class: "metric-bands" }, [
- chip("CPU", cpuValue, c.cpu_severity, cpuDetail),
+ /* W19 (#5644): total_cpu_percent is the whole box (SQL Server plus other processes); FinOps reads the SQL Server
+ process alone, so this chip says which one it shows. The cpu_percent fallback (a PostgreSQL target) stays "CPU". */
+ chip(c.total_cpu_percent != null ? "Total CPU" : "CPU", cpuValue, c.cpu_severity, cpuDetail),
chip("Threads", threadsValue, c.threads_severity, threadsDetail),
chip("Memory", memValue, c.memory_severity, memDetail),
chip("Blocking", fmtInt(c.blocking_count), c.blocking_severity, blockingDetail),
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 97cb90a3a9..c957ebc7ef 100644
--- a/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/server-tabs.js
+++ b/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/server-tabs.js
@@ -1322,11 +1322,11 @@ export const SERVER_TABS = [
sourceKey: "source",
emptyText: "No blocking events in this window — an empty trend here means none happened, not that nothing was collected.",
}),
- line("Deadlocks", "get_deadlock_trend", { server, hours: ctx.hours }, "trend", "time", COUNT_SERIES, {
+ line("Deadlock trend", "get_deadlock_trend", { server, hours: ctx.hours }, "trend", "time", COUNT_SERIES, {
subtitle: ctx.label,
format: "int",
atTimeItem: "deadlocks",
- emptyText: "No deadlocks in this window — an empty trend here means none happened, not that nothing was collected.",
+ emptyText: DEADLOCKS_EMPTY_TEXT,
}),
table(
"Blocking",
@@ -1348,7 +1348,7 @@ export const SERVER_TABS = [
"deadlocks",
[...DEADLOCK_COLUMNS, deadlockPlanColumn(server)],
ctx.label,
- "No deadlocks in this window.",
+ DEADLOCKS_EMPTY_TEXT,
2,
"separately_monitored_note"
),
@@ -2956,7 +2956,7 @@ const PROPERTY_STATS = [
{ key: "cpu_count", label: "Logical CPUs", format: "int" },
{ key: "socket_count", label: "Sockets", format: "int", hideWhen: AZURE_SQL_DATABASE },
{ key: "cores_per_socket", label: "Cores/socket", format: "int", hideWhen: AZURE_SQL_DATABASE },
- { key: "hyperthread_ratio", label: "HT ratio", format: "int", hideWhen: AZURE_SQL_DATABASE },
+ { key: "hyperthread_ratio", label: "Logical CPUs per socket", format: "int", hideWhen: AZURE_SQL_DATABASE },
{ key: "physical_memory_mb", label: "Physical memory", format: "mb", hideWhen: AZURE_SQL_DATABASE },
{ key: "is_clustered", label: "Clustered", format: "bool" },
{ key: "is_hadr_enabled", label: "Always On", format: "bool" },
@@ -3033,17 +3033,20 @@ const MEMORY_STATS = [
{ key: "total_server_memory_mb", label: "Total server", format: "mb" },
{ key: "target_server_memory_mb", label: "Target server", format: "mb" },
{ key: "buffer_pool_mb", label: "Buffer pool", format: "mb" },
- { key: "plan_cache_mb", label: "Plan cache", format: "mb" },
+ { key: "plan_cache_mb", label: "Plan cache (memory clerk)", format: "mb" },
{ key: "system_memory_state", label: "System state", format: "text", small: true, nullKey: "system_memory_state_note" },
{ key: "sql_memory_model", label: "Memory model", format: "text", small: true },
];
+/* W16 (#5644): the Blocking tab's Deadlock trend chart and Deadlocks table say the same thing when empty. */
+const DEADLOCKS_EMPTY_TEXT = "No deadlocks in this window.";
+
const PLAN_CACHE_STATS = [
{ key: "summary.bloat_level", label: "Bloat", format: "text", small: true },
{ key: "summary.total_plans", label: "Plans", format: "int" },
{ key: "summary.single_use_plans", label: "Single-use", format: "int" },
{ key: "summary.single_use_percent", label: "Single-use %", format: "num1" },
- { key: "summary.total_size_mb", label: "Cache size", format: "mb" },
+ { key: "summary.total_size_mb", label: "Cached plans size", format: "mb" },
{ key: "summary.single_use_size_mb", label: "Single-use size", format: "mb" },
{ key: "summary.wasted_percent", label: "Wasted %", format: "num1" },
{ key: "summary.bloat_recommendation", label: "Recommendation", format: "text", small: true },
@@ -3898,8 +3901,9 @@ const GRANT_WINDOW_COLUMNS = [
{ key: "pool_id", label: "Pool", format: "int" },
{ key: "snapshots_in_window", label: "Snapshots", format: "int" },
{ key: "peak_waiter_count", label: "Peak waiters", format: "int" },
- { key: "peak_waiters_at", label: "Peak at", format: "time" },
+ { key: "peak_waiters_at", label: "Peak waiters at", format: "time" },
{ key: "peak_granted_memory_mb", label: "Peak granted", format: "mb" },
+ { key: "peak_granted_at", label: "Peak granted at", format: "time" },
{ key: "min_available_memory_mb", label: "Min available", format: "mb" },
{ key: "timeout_errors_in_window", label: "Timeouts", format: "int" },
{ key: "forced_grants_in_window", label: "Forced grants", format: "int" },
@@ -4712,8 +4716,8 @@ const MEMORY_BROKER_COLUMNS = [
const SIGNIFICANT_WAIT_COLUMNS = [
{ key: "event_time", label: "Event Time", format: "time" },
{ key: "wait_type", label: "Wait Type" },
- { key: "duration_ms", label: "Duration ms", format: "ms" },
- { key: "signal_duration_ms", label: "Signal ms", format: "ms" },
+ { key: "duration_ms", label: "Duration", format: "ms" },
+ { key: "signal_duration_ms", label: "Signal duration", format: "ms" },
{ key: "wait_resource", label: "Wait Resource", wrap: true },
{ key: "session_id", label: "Session", format: "int" },
{ key: "query_text", label: "Query", valueList: false, render: (r) => codeDisclosure(r.query_text) },
@@ -4926,7 +4930,7 @@ const HEAVIEST_COLUMNS = [
row has one, so a server with no run times shows no column of dashes. */
{ key: "run_at", label: "Run at", hideWhenEmpty: true },
{ key: "amortized_ms_per_minute", label: "ms/min", format: "num1" },
- { key: "pct_of_sweep_budget_per_run", label: "% of sweep", format: "num1" },
+ { key: "pct_of_sweep_budget_per_run", label: "% of run budget", format: "num1" },
];
/* ─────────────────────────── PostgreSQL stat descriptors ─────────────────────────── */
diff --git a/Lite.Tests/McpLatestSnapshotStampTests.cs b/Lite.Tests/McpLatestSnapshotStampTests.cs
index 1b320072cd..e2a443469a 100644
--- a/Lite.Tests/McpLatestSnapshotStampTests.cs
+++ b/Lite.Tests/McpLatestSnapshotStampTests.cs
@@ -87,8 +87,10 @@ public void Dispose()
public async Task GetResourceSemaphore_ReadsTheWindowBesideTheStampedSnapshot()
{
var (@base, anchor) = Past();
- await SeedGrantAsync(@base.AddMinutes(-30), waiters: 12, timeoutsDelta: 3, grantedMb: 6000);
- await SeedGrantAsync(@base, waiters: 0, timeoutsDelta: 0, grantedMb: 500);
+ /* #5644 W9: the largest grant (6000) is the calm latest snapshot, the storm (12 waiters) is 30 minutes
+ earlier at 500, so the two peaks are two different moments. */
+ await SeedGrantAsync(@base.AddMinutes(-30), waiters: 12, timeoutsDelta: 3, grantedMb: 500);
+ await SeedGrantAsync(@base, waiters: 0, timeoutsDelta: 0, grantedMb: 6000);
var root = Parse(await McpMemoryTools.GetResourceSemaphore(_dataService, _serverManager, ServerName, 1, anchor));
@@ -109,6 +111,7 @@ public async Task GetResourceSemaphore_ReadsTheWindowBesideTheStampedSnapshot()
Assert.Equal(3, window.GetProperty("timeout_errors_in_window").GetInt64());
Assert.Equal(0, window.GetProperty("forced_grants_in_window").GetInt64());
Assert.Equal(6000d, window.GetProperty("peak_granted_memory_mb").GetDouble());
+ Assert.Equal(Stamp(@base), window.GetProperty("peak_granted_at").GetString());
Assert.Equal(2000d, window.GetProperty("min_available_memory_mb").GetDouble());
Assert.Equal(Stamp(@base.AddMinutes(-30)), window.GetProperty("first_snapshot_at").GetString());
/* The window's last snapshot IS the stamped one — one span, one set of rows, two halves. */
@@ -144,6 +147,7 @@ public async Task GetMemoryGrants_ReadsThePoolWindow_WithANullSemaphoreId()
Assert.Equal(12, window.GetProperty("peak_waiter_count").GetInt64());
Assert.Equal(3, window.GetProperty("timeout_errors_in_window").GetInt64());
Assert.Equal(6000d, window.GetProperty("peak_granted_memory_mb").GetDouble());
+ Assert.Equal(Stamp(@base.AddMinutes(-30)), window.GetProperty("peak_granted_at").GetString());
Assert.Equal(2, window.GetProperty("snapshots_in_window").GetInt64());
}
diff --git a/Lite/Mcp/McpMemoryTools.cs b/Lite/Mcp/McpMemoryTools.cs
index e3c1caf82c..09a6604d26 100644
--- a/Lite/Mcp/McpMemoryTools.cs
+++ b/Lite/Mcp/McpMemoryTools.cs
@@ -257,7 +257,7 @@ public static async Task GetMemoryPressureEvents(
}
}
- [McpServerTool(Name = "get_resource_semaphore"), Description("Resource semaphore stats: granted/available memory vs target/max-target ceiling, waiters, timeout/forced pressure, window ends at as_of. TWO READS, per semaphore+pool: grants[] is the NEWEST snapshot (moment: captured_at/age_seconds); window[] aggregates EVERY snapshot (peak waiters+when, peak/min memory, summed timeout/forced deltas). Read window[] for 'was there pressure', grants[] for 'is there pressure now'. No rows: unavailable (not_collected first). sample_interval_seconds/interval_known null/false on a restart-marker or pre-column row; its zero deltas aren't 'no timeouts'. <> Gets resource semaphore statistics showing granted vs available workspace memory against the target/max-target ceiling, waiter counts, and timeout/forced grant pressure indicators. High waiter counts or rising timeout/forced deltas indicate memory grant pressure affecting query performance. TWO READS UNDER ONE WINDOW: grants[] is the NEWEST snapshot in the window (one row per resource semaphore and pool), stamped once as captured_at with age_seconds against the window's end - it is a moment, not the window. window[] aggregates EVERY snapshot in the window per (resource_semaphore_id, pool_id): peak_waiter_count and peak_waiters_at (the most sessions ever seen waiting for a grant and when), peak_granted_memory_mb, min_available_memory_mb, and timeout_errors_in_window / forced_grants_in_window (the SUM of the per-interval deltas across the window). Each grants[] row also carries sample_interval_seconds, the measured seconds its two deltas accrued over; it is null with interval_known false when the row is a restart marker (no delta was knowable, so the zero deltas beside it are not 'no timeouts') or predates the column. interval_seconds is the interval these deltas cover, null when it can't be known - the same name the latch and spinlock tools use; sample_interval_seconds carries the same value and is kept for existing readers. " + McpToolGuideTopics.MemoryGrantWindowReadOrder)]
+ [McpServerTool(Name = "get_resource_semaphore"), Description("Resource semaphore stats: granted/available memory vs target/max-target ceiling, waiters, timeout/forced pressure, window ends at as_of. TWO READS, per semaphore+pool: grants[] is the NEWEST snapshot (moment: captured_at/age_seconds); window[] aggregates EVERY snapshot (peak waiters+when, peak/min memory, summed timeout/forced deltas). Read window[] for 'was there pressure', grants[] for 'is there pressure now'. No rows: unavailable (not_collected first). sample_interval_seconds/interval_known null/false on a restart-marker or pre-column row; its zero deltas aren't 'no timeouts'. <> Gets resource semaphore statistics showing granted vs available workspace memory against the target/max-target ceiling, waiter counts, and timeout/forced grant pressure indicators. High waiter counts or rising timeout/forced deltas indicate memory grant pressure affecting query performance. TWO READS UNDER ONE WINDOW: grants[] is the NEWEST snapshot in the window (one row per resource semaphore and pool), stamped once as captured_at with age_seconds against the window's end - it is a moment, not the window. window[] aggregates EVERY snapshot in the window per (resource_semaphore_id, pool_id): peak_waiter_count and peak_waiters_at (the most sessions ever seen waiting for a grant and when), peak_granted_memory_mb and peak_granted_at (the largest granted memory and when - not the time of peak waiters), min_available_memory_mb, and timeout_errors_in_window / forced_grants_in_window (the SUM of the per-interval deltas across the window). Each grants[] row also carries sample_interval_seconds, the measured seconds its two deltas accrued over; it is null with interval_known false when the row is a restart marker (no delta was knowable, so the zero deltas beside it are not 'no timeouts') or predates the column. interval_seconds is the interval these deltas cover, null when it can't be known - the same name the latch and spinlock tools use; sample_interval_seconds carries the same value and is kept for existing readers. " + McpToolGuideTopics.MemoryGrantWindowReadOrder)]
public static async Task GetResourceSemaphore(
LocalDataService dataService,
ServerManager serverManager,
@@ -331,7 +331,7 @@ the original sample_interval_seconds rather than in place of it. */
}
}
- [McpServerTool(Name = "get_memory_grants"), Description("Gets resource semaphore stats: granted vs available workspace memory per resource pool, waiters, timeout/forced grant deltas, over a window ending at as_of (default 1h). TWO READS: grants[] is the NEWEST snapshot in the window (a moment: captured_at/age_seconds), one row per pool; window[] aggregates EVERY snapshot in it, per pool (peak waiters+when, peak/min memory, summed timeout/forced deltas). Read window[] for 'was there pressure', grants[] for 'is there pressure now'. No rows: unavailable (or not_collected first). <> Gets resource semaphore statistics showing granted vs available workspace memory per resource pool, waiter counts, and timeout/forced grant deltas. High waiter counts or rising timeout deltas indicate memory grant pressure affecting query performance. TWO READS UNDER ONE WINDOW: grants[] is the NEWEST snapshot in the window (one row per pool, summed across its semaphores), stamped once as captured_at with age_seconds against the window's end - it is a moment, not the window. window[] aggregates EVERY snapshot in the window per pool: peak_waiter_count and peak_waiters_at (the most sessions ever seen waiting on the pool at one instant and when), peak_granted_memory_mb, min_available_memory_mb, and timeout_errors_in_window / forced_grants_in_window (the SUM of the per-interval deltas across the window). " + McpToolGuideTopics.MemoryGrantWindowReadOrder)]
+ [McpServerTool(Name = "get_memory_grants"), Description("Gets resource semaphore stats: granted vs available workspace memory per resource pool, waiters, timeout/forced grant deltas, over a window ending at as_of (default 1h). TWO READS: grants[] is the NEWEST snapshot in the window (a moment: captured_at/age_seconds), one row per pool; window[] aggregates EVERY snapshot in it, per pool (peak waiters+when, peak/min memory, summed timeout/forced deltas). Read window[] for 'was there pressure', grants[] for 'is there pressure now'. No rows: unavailable (or not_collected first). <> Gets resource semaphore statistics showing granted vs available workspace memory per resource pool, waiter counts, and timeout/forced grant deltas. High waiter counts or rising timeout deltas indicate memory grant pressure affecting query performance. TWO READS UNDER ONE WINDOW: grants[] is the NEWEST snapshot in the window (one row per pool, summed across its semaphores), stamped once as captured_at with age_seconds against the window's end - it is a moment, not the window. window[] aggregates EVERY snapshot in the window per pool: peak_waiter_count and peak_waiters_at (the most sessions ever seen waiting on the pool at one instant and when), peak_granted_memory_mb and peak_granted_at (the largest granted memory and when - not the time of peak waiters), min_available_memory_mb, and timeout_errors_in_window / forced_grants_in_window (the SUM of the per-interval deltas across the window). " + McpToolGuideTopics.MemoryGrantWindowReadOrder)]
public static async Task GetMemoryGrants(
LocalDataService dataService,
ServerManager serverManager,
@@ -406,6 +406,7 @@ public static async Task GetMemoryGrants(
peak_waiter_count = w.PeakWaiterCount,
peak_waiters_at = w.PeakWaitersAt.ToString("o"),
peak_granted_memory_mb = Math.Round(w.PeakGrantedMemoryMb, 2),
+ peak_granted_at = w.PeakGrantedAt.ToString("o"),
min_available_memory_mb = Math.Round(w.MinAvailableMemoryMb, 2),
timeout_errors_in_window = w.TimeoutErrorsInWindow,
forced_grants_in_window = w.ForcedGrantsInWindow
diff --git a/Lite/Services/LocalDataService.MemoryGrants.cs b/Lite/Services/LocalDataService.MemoryGrants.cs
index 928a86fce9..9ecf4f0b59 100644
--- a/Lite/Services/LocalDataService.MemoryGrants.cs
+++ b/Lite/Services/LocalDataService.MemoryGrants.cs
@@ -368,6 +368,15 @@ SELECT DISTINCT ON (resource_semaphore_id, pool_id)
collection_time AS peak_waiters_at
FROM windowed
ORDER BY resource_semaphore_id, pool_id, waiter_count DESC, collection_time DESC
+),
+peak_grant AS
+(
+ SELECT DISTINCT ON (resource_semaphore_id, pool_id)
+ resource_semaphore_id,
+ pool_id,
+ collection_time AS peak_granted_at
+ FROM windowed
+ ORDER BY resource_semaphore_id, pool_id, granted_memory_mb DESC NULLS LAST, collection_time DESC
)
SELECT
a.resource_semaphore_id,
@@ -380,11 +389,15 @@ FROM windowed
a.peak_granted_memory_mb,
a.min_available_memory_mb,
a.timeout_errors_in_window,
- a.forced_grants_in_window
+ a.forced_grants_in_window,
+ pg.peak_granted_at
FROM agg AS a
JOIN peak AS p
ON p.resource_semaphore_id = a.resource_semaphore_id
AND p.pool_id = a.pool_id
+JOIN peak_grant AS pg
+ ON pg.resource_semaphore_id = a.resource_semaphore_id
+ AND pg.pool_id = a.pool_id
ORDER BY a.pool_id, a.resource_semaphore_id";
///
@@ -393,7 +406,7 @@ JOIN peak AS p
/// serves), THEN the window's peak / floor / total is taken over
/// those per-snapshot pool figures, so peak waiters is the most sessions waiting on the pool at one instant,
/// not the largest single semaphore's count. resource_semaphore_id is a typed NULL so both reads
- /// project the same eleven columns. $1 server_id, $2 window start, $3 window end.
+ /// project the same twelve columns. $1 server_id, $2 window start, $3 window end.
///
public const string MemoryGrantsWindowSql = @"
WITH per_snapshot AS
@@ -434,6 +447,14 @@ SELECT DISTINCT ON (pool_id)
collection_time AS peak_waiters_at
FROM per_snapshot
ORDER BY pool_id, waiter_count DESC, collection_time DESC
+),
+peak_grant AS
+(
+ SELECT DISTINCT ON (pool_id)
+ pool_id,
+ collection_time AS peak_granted_at
+ FROM per_snapshot
+ ORDER BY pool_id, granted_memory_mb DESC NULLS LAST, collection_time DESC
)
SELECT
CAST(NULL AS INTEGER) AS resource_semaphore_id,
@@ -446,9 +467,11 @@ FROM per_snapshot
a.peak_granted_memory_mb,
a.min_available_memory_mb,
a.timeout_errors_in_window,
- a.forced_grants_in_window
+ a.forced_grants_in_window,
+ pg.peak_granted_at
FROM agg AS a
JOIN peak AS p ON p.pool_id = a.pool_id
+JOIN peak_grant AS pg ON pg.pool_id = a.pool_id
ORDER BY a.pool_id";
/// Every snapshot in the window aggregated per (resource_semaphore_id, pool_id) — the window half
@@ -463,7 +486,7 @@ public Task> GetResourceSemaphoreWindowAsync(int serv
public Task> GetMemoryGrantsWindowAsync(int serverId, int hoursBack = 24, DateTime? fromDate = null, DateTime? toDate = null, DateTime? asOfUtc = null) =>
ReadMemoryGrantWindowAsync(MemoryGrantsWindowSql, "GetMemoryGrantsWindowAsync", serverId, hoursBack, fromDate, toDate, asOfUtc);
- /// The two window reads project the SAME eleven columns in the same order, so one materialiser
+ /// The two window reads project the SAME twelve columns in the same order, so one materialiser
/// serves both. DuckDB widens SUM(INTEGER) to HUGEINT and COUNT(*) to BIGINT, so every count goes through
/// ToInt64 and every MB figure through ToDouble, like the readers above.
private async Task> ReadMemoryGrantWindowAsync(
@@ -496,7 +519,8 @@ private async Task> ReadMemoryGrantWindowAsync(
PeakGrantedMemoryMb = reader.IsDBNull(7) ? 0 : ToDouble(reader.GetValue(7)),
MinAvailableMemoryMb = reader.IsDBNull(8) ? 0 : ToDouble(reader.GetValue(8)),
TimeoutErrorsInWindow = reader.IsDBNull(9) ? 0 : ToInt64(reader.GetValue(9)),
- ForcedGrantsInWindow = reader.IsDBNull(10) ? 0 : ToInt64(reader.GetValue(10))
+ ForcedGrantsInWindow = reader.IsDBNull(10) ? 0 : ToInt64(reader.GetValue(10)),
+ PeakGrantedAt = reader.GetDateTime(11)
});
}
return items;
@@ -521,6 +545,8 @@ public class MemoryGrantWindowRow
public long PeakWaiterCount { get; set; }
public DateTime PeakWaitersAt { get; set; }
public double PeakGrantedMemoryMb { get; set; }
+ /// The snapshot of the largest granted memory (#5644 W9) - not the time of peak waiters.
+ public DateTime PeakGrantedAt { get; set; }
public double MinAvailableMemoryMb { get; set; }
public long TimeoutErrorsInWindow { get; set; }
public long ForcedGrantsInWindow { get; set; }
From 43f54355b9bee6784a008cad0bb07057fde2fc87 Mon Sep 17 00:00:00 2001
From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com>
Date: Mon, 12 Oct 2026 15:16:12 -0400
Subject: [PATCH 04/42] CPU collector no longer stores the newest ring-buffer
record twice when SYSDATETIME and ms_ticks drift (#5644 W3)
---
.../CpuUtilizationCollectorDefinitionTests.cs | 30 +++++++++++++++++++
.../CpuUtilizationCollector.cs | 22 ++++++++++++--
2 files changed, 50 insertions(+), 2 deletions(-)
diff --git a/Lite.Tests/CpuUtilizationCollectorDefinitionTests.cs b/Lite.Tests/CpuUtilizationCollectorDefinitionTests.cs
index fa8e95a8c7..6a909eb1b8 100644
--- a/Lite.Tests/CpuUtilizationCollectorDefinitionTests.cs
+++ b/Lite.Tests/CpuUtilizationCollectorDefinitionTests.cs
@@ -261,6 +261,36 @@ public async Task ReadAsync_RingBuffer_UnderAUtcWatermark_DedupsAtOrBelowItOnThe
Assert.True(row.SampleTime < watermarkUtc, "the fixture must put every LOCAL stamp below the UTC watermark, or this pin cannot tell the two frames apart");
}
+ ///
+ /// #5644 W3: the ring buffer record that is already stored comes back on the next poll with a recomputed
+ /// stamp a few milliseconds ABOVE the stored one (SYSDATETIME() and ms_ticks drift apart: 8 ms in 68 s on the
+ /// test box), so a strict `<= watermark` test stored it again, 82-87 samples an hour against 60 records.
+ /// The same record, drifted 8 ms and then 3 s above the watermark, is dropped in either frame; the next
+ /// record, 60 s later, still lands.
+ ///
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public async Task ReadAsync_RingBuffer_AlreadyStoredRecordWithDriftedStamp_IsNotStoredAgain(bool fromUtc)
+ {
+ var utcBase = new DateTime(2026, 7, 1, 16, 0, 0, DateTimeKind.Utc);
+ var localBase = utcBase.AddHours(-4); /* the server sits at UTC-4 */
+ object[] Row(TimeSpan at, int cpu) => new object[] { localBase + at, cpu, 1, utcBase + at };
+ using var reader = new FakeCollectorDataReader(
+ Row(TimeSpan.FromSeconds(60.008), 50), /* the genuinely new record */
+ Row(TimeSpan.FromMilliseconds(8), 40), /* the stored record, recomputed 8 ms later */
+ Row(TimeSpan.FromSeconds(3), 41), /* a larger drift still inside the window */
+ Row(TimeSpan.Zero, 30)); /* the stored record exactly */
+
+ var rows = await CpuUtilizationCollector.Instance.ReadAsync(
+ reader,
+ CollectorTestContext.Make(s_deltas, isAzureSqlDb: false, watermark: fromUtc ? utcBase : localBase, watermarkFromUtcColumn: fromUtc),
+ CancellationToken.None);
+
+ var row = Assert.Single(rows);
+ Assert.Equal(50, row.SqlServerCpuUtilization);
+ }
+
/* ── #3778: the three acceptance sequences, through the REAL ReadAsync dedup rather than WritePayload ── */
///
diff --git a/PerformanceMonitor.Collectors/CpuUtilizationCollector.cs b/PerformanceMonitor.Collectors/CpuUtilizationCollector.cs
index fb4602e50b..f1fe956c5a 100644
--- a/PerformanceMonitor.Collectors/CpuUtilizationCollector.cs
+++ b/PerformanceMonitor.Collectors/CpuUtilizationCollector.cs
@@ -181,7 +181,7 @@ EXEC sys.sp_executesql
sub-second remainder of the elapsed-ticks offset, and that remainder is different on every poll
(SYSDATETIME() keeps moving while a given ring-buffer entry's own timestamp does not). Since this
query re-reads the ring buffer's whole retained history every cycle and dedups client-side on this
- COMPUTED value (a physical entry has no stable key), a recomputed sample_time that drifts forward
+ COMPUTED value (the store keeps no column for the record id; see RingBufferResampleTolerance), a recomputed sample_time that drifts forward
by a fraction of a second on a later poll can land just above the watermark and re-insert the same
entry as a fresh row - a near-duplicate a moment after the original. Issue #2749: this duplicate
pattern (two samples ~200ms-1s apart, then a real ~60s gap to the next) collapses
@@ -304,6 +304,23 @@ public override CollectorQuery BuildQuery(CollectorContext context)
: new CollectorQuery(RingBufferQueryText);
}
+ ///
+ /// #5644 W3: how far past the watermark a ring-buffer stamp may sit and still be the record the watermark
+ /// already holds. A SCHEDULER_MONITOR record IS stable across reads (measured on SQL Server 2022, a minute
+ /// apart: the same Record/@id and the same ring-buffer timestamp), but the query's
+ /// sample_time is recomputed every poll from SYSDATETIME() minus ms_ticks - timestamp, two
+ /// clocks that run slightly apart (8 ms in 68 s on the test box). Re-reading the newest stored record
+ /// therefore lands a few milliseconds ABOVE its stored stamp, clears a strict <= watermark test,
+ /// and is stored again: 82-87 samples an hour where the ring buffer holds 60. The store has no column for the
+ /// record id, so the watermark is the only memory; records are 60 s apart, so anything within this window of
+ /// it is the same record. 20 s is far above any clock drift between polls and far below the spacing, so a
+ /// genuinely new record still lands.
+ ///
+ internal static readonly TimeSpan RingBufferResampleTolerance = TimeSpan.FromSeconds(20);
+
+ private static DateTime WithRingBufferTolerance(DateTime watermark) =>
+ watermark > DateTime.MaxValue - RingBufferResampleTolerance ? watermark : watermark + RingBufferResampleTolerance;
+
public override async ValueTask> ReadAsync(DbDataReader reader, CollectorContext context, CancellationToken cancellationToken)
{
var rows = new List();
@@ -328,7 +345,8 @@ old rule would not have. A row with no twin under a UTC watermark cannot be plac
than compared across frames or dropped on a guess. No arm produces one — the pins say so —
and if one ever did, a visible duplicate beats a silent hole. */
var stampInWatermarkFrame = context.WatermarkFromUtcColumn ? sampleTimeUtc : sampleTime;
- if (!context.Target.IsAzureSqlDb && context.Watermark.HasValue && stampInWatermarkFrame <= context.Watermark.Value)
+ if (!context.Target.IsAzureSqlDb && context.Watermark.HasValue
+ && stampInWatermarkFrame <= WithRingBufferTolerance(context.Watermark.Value))
{
continue;
}
From de985f18baf74bd0030599d2aa78b8118bca3328 Mon Sep 17 00:00:00 2001
From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com>
Date: Mon, 12 Oct 2026 15:24:47 -0400
Subject: [PATCH 05/42] Blocking events count waits, not blocked-process
reports (#5644 W1, W2)
---
.../PgFactCollector.Waits.cs | 34 ++--
.../Mcp/DarlingFleetReader.cs | 30 +++-
.../Mcp/DarlingHealthReader.cs | 15 +-
.../ViewerDataService.AzureMasterScope.cs | 15 +-
.../ViewerDataService.Fleet.cs | 27 ++-
.../ViewerDataService.Overview.cs | 18 +-
Lite/Analysis/DuckDbFactCollector.Waits.cs | 13 +-
Lite/Services/LocalDataService.Overview.cs | 16 +-
.../AlertContextBuilders.cs | 29 +++-
PerformanceMonitor.Alerting/AlertEngine.cs | 36 ++--
PerformanceMonitor.Common/BlockingWaits.cs | 163 ++++++++++++++++++
11 files changed, 325 insertions(+), 71 deletions(-)
create mode 100644 PerformanceMonitor.Common/BlockingWaits.cs
diff --git a/Darling/PerformanceMonitor.Darling.Analysis/PgFactCollector.Waits.cs b/Darling/PerformanceMonitor.Darling.Analysis/PgFactCollector.Waits.cs
index e63493ad0b..d65281769d 100644
--- a/Darling/PerformanceMonitor.Darling.Analysis/PgFactCollector.Waits.cs
+++ b/Darling/PerformanceMonitor.Darling.Analysis/PgFactCollector.Waits.cs
@@ -13,6 +13,7 @@
using Npgsql;
using PerformanceMonitor.Analysis;
using PerformanceMonitor.Collectors;
+using PerformanceMonitor.Common;
namespace PerformanceMonitor.Darling.Analysis;
@@ -264,19 +265,28 @@ the window is when the report HAPPENED (event_time), the same column the blockin
EventWindowFloor for $2: blocked_process_reports is partitioned on collection_time, which an
event_time bound gives the planner nothing to exclude chunks on. The bucket moves with the window:
a bucket origin on one column over rows windowed on the other would split the peak across buckets. */
- private const string BlockingSqlHead = @"
-WITH reports AS (
+ /* #5644 W1: the rows the fact counts are WAITS, not blocked-process reports. SQL Server writes a report for
+ a still-blocked session every threshold seconds, so one one-minute wait was about eleven events and the
+ graded rate overstated blocking by the same factor. BlockingWaits.WaitsCteSql is the one definition (a
+ blocked session, its blocker and the wait's derived start; the longest wait time kept) that the alert
+ engine and the fleet tile share, so this fact's event_count, the alert value and the Blocking tile agree.
+ The wait lands in the 4-hour bucket of its longest-wait (latest) report. The (10, 50) grading pair was
+ measured on reports; it now meets waits, and the PR notes that. */
+ private static string BuildBlockingSql(string extraWhere) => @"
+WITH " + BlockingWaits.WaitsCteSql(
+ BlockingWaits.SqlDialect.Postgres, "waits",
+ @"SELECT blocked_spid, blocking_spid, event_time, wait_time_ms, blocking_status
+ FROM blocked_process_reports
+ WHERE server_id = $1
+ AND event_time >= $2
+ AND event_time <= $3" + extraWhere) + @",
+reports AS (
SELECT
wait_time_ms,
blocking_spid,
blocking_status,
date_bin('4 hours', event_time, $2) AS bucket_start
- FROM blocked_process_reports
- WHERE server_id = $1
- AND event_time >= $2
- AND event_time <= $3";
-
- private const string BlockingSqlTail = @"
+ FROM waits
),
buckets AS (
SELECT COUNT(*) AS bucket_event_count
@@ -292,16 +302,16 @@ GROUP BY bucket_start
(SELECT COALESCE(MAX(bucket_event_count), 0) FROM buckets) AS peak_4h_event_count
FROM reports";
- public const string BlockingSql = BlockingSqlHead + @"
- AND collection_time >= $4" + BlockingSqlTail;
+ public static string BlockingSql => BuildBlockingSql(@"
+ AND collection_time >= $4");
/// The same read for an Azure SQL Database master target: $4 is the names of the databases monitored
/// as their own targets, whose events are skipped (a NULL database still counts), and $5 the event-window
/// floor. The list keeps its number from the scoped read and the floor takes the next one, so the floor is
/// $4 in and $5 here.
- public const string BlockingSqlSkippingSeparate = BlockingSqlHead + @"
+ public static string BlockingSqlSkippingSeparate => BuildBlockingSql(@"
AND collection_time >= $5
- AND (database_name IS NULL OR NOT (lower(database_name) = ANY(SELECT lower(x) FROM unnest($4::text[]) x)))" + BlockingSqlTail;
+ AND (database_name IS NULL OR NOT (lower(database_name) = ANY(SELECT lower(x) FROM unnest($4::text[]) x)))");
/// The peak sub-window's width in hours — the grain the (10, 50) grading pair was measured
/// on (#3871). The peak rate divides by min(this, observed hours), never by the constant
diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingFleetReader.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingFleetReader.cs
index 2134982224..c13fd04fd4 100644
--- a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingFleetReader.cs
+++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingFleetReader.cs
@@ -342,7 +342,25 @@ WHERE s.is_enabled
/// $3 is the partition-column bound the event window cannot supply (#3895): without it both scans
/// open every retained chunk to count the last hour. It cannot drop a row the window wants —
/// says why.
- public const string FleetBlockingSql = @"
+ /* #5644 W1: each side counts WAITS (a blocked session, its blocker and the wait's derived start), not report
+ rows, with the longest wait time as its max: SQL Server writes a report for a still-blocked session every
+ threshold seconds, so one one-minute wait was about eleven tile counts. BlockingWaits.WaitsCteSql is the
+ one definition the alert engine and the analysis fact share. The DMV side applies the same rule to its
+ snapshot rows (a wait seen in several snapshots is one wait). */
+ public static readonly string FleetBlockingSql = @"
+WITH " + BlockingWaits.WaitsCteSql(
+ BlockingWaits.SqlDialect.Postgres, "xe_waits",
+ @"SELECT server_id, blocked_spid, blocking_spid, event_time, wait_time_ms
+ FROM v_blocked_process_reports
+ WHERE event_time >= $1
+ AND event_time <= $2
+ AND collection_time >= $3", new[] { "server_id" }) + "," + BlockingWaits.WaitsCteSql(
+ BlockingWaits.SqlDialect.Postgres, "dmv_waits",
+ @"SELECT server_id, blocked_spid, blocking_spid, event_time, wait_time_ms
+ FROM v_dmv_blocking_snapshots
+ WHERE event_time >= $1
+ AND event_time <= $2
+ AND collection_time >= $3", new[] { "server_id" }) + @"
SELECT
COALESCE(xe.server_id, dmv.server_id) AS server_id,
COALESCE(xe.cnt, 0) AS xe_count,
@@ -352,19 +370,13 @@ WHERE s.is_enabled
FROM
(
SELECT server_id, COUNT(*) AS cnt, MAX(wait_time_ms) AS max_wait
- FROM v_blocked_process_reports
- WHERE event_time >= $1
- AND event_time <= $2
- AND collection_time >= $3
+ FROM xe_waits
GROUP BY server_id
) AS xe
FULL OUTER JOIN
(
SELECT server_id, COUNT(*) AS cnt, MAX(wait_time_ms) AS max_wait
- FROM v_dmv_blocking_snapshots
- WHERE event_time >= $1
- AND event_time <= $2
- AND collection_time >= $3
+ FROM dmv_waits
GROUP BY server_id
) AS dmv ON xe.server_id = dmv.server_id";
diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingHealthReader.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingHealthReader.cs
index a5a07921bc..56d4f0f241 100644
--- a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingHealthReader.cs
+++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingHealthReader.cs
@@ -87,10 +87,19 @@ ORDER BY collection_time DESC
/// $3 the for $2 — the partition-column bound the event window cannot supply
/// (#3895, the fleet card's DarlingFleetReader.FleetBlockingSql carries the same one), so the counts
/// open the window's chunks rather than every retained one.
- public const string ServerSummaryBlockingSql = @"
+ /// #5644 W1: both counts are WAITS, not report rows (, the
+ /// definition the alert engine, the analysis fact and the fleet tile share), so the summary agrees with the tile.
+ public static readonly string ServerSummaryBlockingSql = @"
+WITH " + BlockingWaits.WaitsCteSql(
+ BlockingWaits.SqlDialect.Postgres, "xe_waits",
+ @"SELECT blocked_spid, blocking_spid, event_time, wait_time_ms FROM v_blocked_process_reports
+ WHERE server_id = $1 AND event_time >= $2 AND collection_time >= $3") + "," + BlockingWaits.WaitsCteSql(
+ BlockingWaits.SqlDialect.Postgres, "dmv_waits",
+ @"SELECT blocked_spid, blocking_spid, event_time, wait_time_ms FROM v_dmv_blocking_snapshots
+ WHERE server_id = $1 AND event_time >= $2 AND collection_time >= $3") + @"
SELECT
- (SELECT COUNT(*) FROM v_blocked_process_reports WHERE server_id = $1 AND event_time >= $2 AND collection_time >= $3),
- (SELECT COUNT(*) FROM v_dmv_blocking_snapshots WHERE server_id = $1 AND event_time >= $2 AND collection_time >= $3)";
+ (SELECT COUNT(*) FROM xe_waits),
+ (SELECT COUNT(*) FROM dmv_waits)";
/// Deadlock count in the window. $1 server_id, $2 window start, $3 the
/// for $2 (#3895).
diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.AzureMasterScope.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.AzureMasterScope.cs
index b38afbe44a..9b64ec35ff 100644
--- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.AzureMasterScope.cs
+++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.AzureMasterScope.cs
@@ -12,6 +12,7 @@
using System.Threading;
using System.Threading.Tasks;
using Npgsql;
+using PerformanceMonitor.Common;
using PerformanceMonitor.Alerting;
using PerformanceMonitor.Darling.Analysis;
using PerformanceMonitor.Darling.Storage;
@@ -182,10 +183,18 @@ private async Task ReadScopedDeadlocksAsync(
serverId, start, end, separate, cancellationToken, ViewerCommandDeadlines.CurrentInteractiveReadSeconds);
}
- private const string FleetMasterUnscopedSql = @"
+ /* #5644 W1: waits, not report rows (BlockingWaits.WaitsCteSql), so it subtracts like for like from the fleet totals. */
+ private static readonly string FleetMasterUnscopedSql = @"
+WITH " + BlockingWaits.WaitsCteSql(
+ BlockingWaits.SqlDialect.Postgres, "xe_waits",
+ @"SELECT blocked_spid, blocking_spid, event_time, wait_time_ms FROM v_blocked_process_reports
+ WHERE server_id = $1 AND event_time >= $2 AND event_time <= $3 AND collection_time >= $4") + "," + BlockingWaits.WaitsCteSql(
+ BlockingWaits.SqlDialect.Postgres, "dmv_waits",
+ @"SELECT blocked_spid, blocking_spid, event_time, wait_time_ms FROM v_dmv_blocking_snapshots
+ WHERE server_id = $1 AND event_time >= $2 AND event_time <= $3 AND collection_time >= $4") + @"
SELECT
- (SELECT COUNT(*) FROM v_blocked_process_reports WHERE server_id = $1 AND event_time >= $2 AND event_time <= $3 AND collection_time >= $4),
- (SELECT COUNT(*) FROM v_dmv_blocking_snapshots WHERE server_id = $1 AND event_time >= $2 AND event_time <= $3 AND collection_time >= $4),
+ (SELECT COUNT(*) FROM xe_waits),
+ (SELECT COUNT(*) FROM dmv_waits),
(SELECT COUNT(*) FROM v_deadlocks WHERE server_id = $1 AND deadlock_time >= $2 AND deadlock_time <= $3 AND collection_time >= $4)";
///
diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Fleet.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Fleet.cs
index 3b799881e8..b092904080 100644
--- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Fleet.cs
+++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Fleet.cs
@@ -86,7 +86,22 @@ public sealed partial class ViewerDataService
/// chunk to count the last hour — 69.8 ms on DARLING01, 1.1 ms with it. The PostgreSQL arm is already
/// windowed on collection_time and needs no floor.
///
- public const string FleetTotalsSql = @"
+ /* #5644 W1: the blocking total sums WAITS (BlockingWaits.WaitsCteSql), not report rows, as the service's fleet
+ tile does, so the viewer and the web UI show the same number. */
+ public static readonly string FleetTotalsSql = @"
+WITH " + BlockingWaits.WaitsCteSql(
+ BlockingWaits.SqlDialect.Postgres, "xe_waits",
+ @"SELECT server_id, blocked_spid, blocking_spid, event_time, wait_time_ms
+ FROM v_blocked_process_reports
+ WHERE event_time >= $1
+ AND event_time <= $2
+ AND collection_time >= $3", new[] { "server_id" }) + "," + BlockingWaits.WaitsCteSql(
+ BlockingWaits.SqlDialect.Postgres, "dmv_waits",
+ @"SELECT server_id, blocked_spid, blocking_spid, event_time, wait_time_ms
+ FROM v_dmv_blocking_snapshots
+ WHERE event_time >= $1
+ AND event_time <= $2
+ AND collection_time >= $3", new[] { "server_id" }) + @"
SELECT
(
SELECT COALESCE(SUM(CASE WHEN per_server.xe_count > 0 THEN per_server.xe_count ELSE per_server.dmv_count END), 0)
@@ -99,19 +114,13 @@ SELECT COALESCE(SUM(CASE WHEN per_server.xe_count > 0 THEN per_server.xe_count E
FROM
(
SELECT server_id, COUNT(*) AS cnt
- FROM v_blocked_process_reports
- WHERE event_time >= $1
- AND event_time <= $2
- AND collection_time >= $3
+ FROM xe_waits
GROUP BY server_id
) AS xe
FULL OUTER JOIN
(
SELECT server_id, COUNT(*) AS cnt
- FROM v_dmv_blocking_snapshots
- WHERE event_time >= $1
- AND event_time <= $2
- AND collection_time >= $3
+ FROM dmv_waits
GROUP BY server_id
) AS dmv ON xe.server_id = dmv.server_id
) AS per_server
diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Overview.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Overview.cs
index 58ab9675b8..e58cf58639 100644
--- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Overview.cs
+++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Overview.cs
@@ -159,12 +159,20 @@ FROM v_cpu_scheduler_stats
/// and probe the window's chunks instead of every retained one. The two "ever" reads keep no bound:
/// they ask for the newest event in all of history, which a floor would change the answer to.
///
- public const string ServerSummaryBlockingSql = @"
+ /* #5644 W1: the two counts are WAITS (BlockingWaits.WaitsCteSql), not report rows, as the fleet tile's are. */
+ public static readonly string ServerSummaryBlockingSql = @"
+WITH " + BlockingWaits.WaitsCteSql(
+ BlockingWaits.SqlDialect.Postgres, "xe_waits",
+ @"SELECT blocked_spid, blocking_spid, event_time, wait_time_ms FROM v_blocked_process_reports
+ WHERE server_id = $1 AND event_time >= $2 AND collection_time >= $3") + "," + BlockingWaits.WaitsCteSql(
+ BlockingWaits.SqlDialect.Postgres, "dmv_waits",
+ @"SELECT blocked_spid, blocking_spid, event_time, wait_time_ms FROM v_dmv_blocking_snapshots
+ WHERE server_id = $1 AND event_time >= $2 AND collection_time >= $3") + @"
SELECT
- (SELECT COUNT(*) FROM v_blocked_process_reports WHERE server_id = $1 AND event_time >= $2 AND collection_time >= $3),
- (SELECT MAX(wait_time_ms) FROM v_blocked_process_reports WHERE server_id = $1 AND event_time >= $2 AND collection_time >= $3),
- (SELECT COUNT(*) FROM v_dmv_blocking_snapshots WHERE server_id = $1 AND event_time >= $2 AND collection_time >= $3),
- (SELECT MAX(wait_time_ms) FROM v_dmv_blocking_snapshots WHERE server_id = $1 AND event_time >= $2 AND collection_time >= $3),
+ (SELECT COUNT(*) FROM xe_waits),
+ (SELECT MAX(wait_time_ms) FROM xe_waits),
+ (SELECT COUNT(*) FROM dmv_waits),
+ (SELECT MAX(wait_time_ms) FROM dmv_waits),
(SELECT MAX(event_time) FROM v_blocked_process_reports WHERE server_id = $1),
(SELECT MAX(event_time) FROM v_dmv_blocking_snapshots WHERE server_id = $1)";
diff --git a/Lite/Analysis/DuckDbFactCollector.Waits.cs b/Lite/Analysis/DuckDbFactCollector.Waits.cs
index e65251cbf8..5e1b32869a 100644
--- a/Lite/Analysis/DuckDbFactCollector.Waits.cs
+++ b/Lite/Analysis/DuckDbFactCollector.Waits.cs
@@ -6,6 +6,7 @@
using DuckDB.NET.Data;
using PerformanceMonitor.Analysis;
using PerformanceMonitor.Collectors;
+using PerformanceMonitor.Common;
using PerformanceMonitor.PlanAnalysis;
using PerformanceMonitorLite.Database;
@@ -246,14 +247,22 @@ private async Task CollectBlockingFactsAsync(AnalysisContext context, List
using var command = connection.CreateCommand();
var scopeList = context.SeparatelyMonitoredDatabases;
var scopeFilter = SeparatelyMonitoredScope.BprFilter(scopeList, 4);
+ /* #5644 W1: the rows the fact counts are WAITS (BlockingWaits.WaitsCteSql: a blocked session, its blocker
+ and the wait's derived start, the longest wait time kept), not blocked-process reports, which SQL
+ Server writes for a still-blocked session every threshold seconds. The PG collector counts the same
+ way, from the same definition. */
command.CommandText = (@"
-WITH reports AS (
+WITH " + BlockingWaits.WaitsCteSql(
+ BlockingWaits.SqlDialect.DuckDb, "waits",
+ @"SELECT blocked_spid, blocking_spid, event_time, wait_time_ms, blocking_status
+ FROM " + StoredEventCopies.BlockedProcessReports("server_id = $1 AND event_time >= $2 AND event_time <= $3{SCOPE}") + @" AS ev") + @",
+reports AS (
SELECT
wait_time_ms,
blocking_spid,
blocking_status,
time_bucket(INTERVAL '4 hours', event_time, $2) AS bucket_start
- FROM " + StoredEventCopies.BlockedProcessReports("server_id = $1 AND event_time >= $2 AND event_time <= $3{SCOPE}") + @" AS ev
+ FROM waits
),
buckets AS (
SELECT COUNT(*) AS bucket_event_count
diff --git a/Lite/Services/LocalDataService.Overview.cs b/Lite/Services/LocalDataService.Overview.cs
index e9d361ba64..68100e79aa 100644
--- a/Lite/Services/LocalDataService.Overview.cs
+++ b/Lite/Services/LocalDataService.Overview.cs
@@ -119,11 +119,21 @@ A null or empty list leaves today's SQL untouched. */
/* Blocking count in last hour - uses XE blocked process reports */
using (var cmd = connection.CreateCommand())
{
- /* Prefer the blocked-process-report; fall back to the always-on DMV snapshot (AWS RDS). */
+ /* Prefer the blocked-process-report; fall back to the always-on DMV snapshot (AWS RDS).
+ #5644 W1: both sides count WAITS, not report rows (BlockingWaits.WaitsCteSql, the definition the
+ alert engine, the analysis fact and Darling's fleet tile share): SQL Server writes a report for a
+ still-blocked session every threshold seconds, so one one-minute wait counted about eleven times. */
cmd.CommandText = @"
+WITH " + BlockingWaits.WaitsCteSql(
+ BlockingWaits.SqlDialect.DuckDb, "xe_waits",
+ @"SELECT blocked_spid, blocking_spid, event_time, wait_time_ms
+ FROM " + StoredEventCopies.BlockedProcessReports("server_id = $1 AND event_time >= $2" + SeparatelyMonitoredScope.BprFilter(separate, 3)) + @" AS ev") + "," + BlockingWaits.WaitsCteSql(
+ BlockingWaits.SqlDialect.DuckDb, "dmv_waits",
+ @"SELECT blocked_spid, blocking_spid, event_time, wait_time_ms
+ FROM v_dmv_blocking_snapshots WHERE server_id = $1 AND event_time >= $2" + SeparatelyMonitoredScope.BprFilter(separate, 3)) + @"
SELECT COALESCE(NULLIF(
- (SELECT COUNT(*) FROM " + StoredEventCopies.BlockedProcessReports("server_id = $1 AND event_time >= $2" + SeparatelyMonitoredScope.BprFilter(separate, 3)) + @" AS ev), 0),
- (SELECT COUNT(*) FROM v_dmv_blocking_snapshots WHERE server_id = $1 AND event_time >= $2" + SeparatelyMonitoredScope.BprFilter(separate, 3) + @"))";
+ (SELECT COUNT(*) FROM xe_waits), 0),
+ (SELECT COUNT(*) FROM dmv_waits))";
cmd.Parameters.Add(new DuckDBParameter { Value = serverId });
cmd.Parameters.Add(new DuckDBParameter { Value = DateTime.UtcNow.AddHours(-1) });
SeparatelyMonitoredScope.AddParameters(cmd, separate);
diff --git a/PerformanceMonitor.Alerting/AlertContextBuilders.cs b/PerformanceMonitor.Alerting/AlertContextBuilders.cs
index 32985abb21..3592010296 100644
--- a/PerformanceMonitor.Alerting/AlertContextBuilders.cs
+++ b/PerformanceMonitor.Alerting/AlertContextBuilders.cs
@@ -61,7 +61,7 @@ public static class AlertContextBuilders
{
if (events == null || events.Count == 0) return null;
- var filtered = FilterBlocking(events, excludedDatabases);
+ var filtered = BlockingWaitRows(events, excludedDatabases);
if (filtered.Count == 0) return null;
var groups = GroupBlocking(serverName, filtered);
@@ -222,7 +222,7 @@ public static IReadOnlyList BlockingIncidents(
{
if (events == null || events.Count == 0) return Array.Empty();
- var filtered = FilterBlocking(events, excludedDatabases);
+ var filtered = BlockingWaitRows(events, excludedDatabases);
if (filtered.Count == 0) return Array.Empty();
return GroupBlocking(serverName, filtered).Select(g => g.Incident).ToList();
@@ -466,6 +466,31 @@ alert on it. */
return context;
}
+ ///
+ /// #5644 W1/W2: THE set of blocking events a blocking alert is made of, so the alert's value and its detail
+ /// cannot disagree. Excluded databases drop their rows, the reports of one wait collapse to one row carrying
+ /// the longest wait time (), and the count is XE-preferred as Lite's overview has
+ /// always been: the blocked-process-report waits when there are any, else the DMV-snapshot waits. The alert
+ /// value is this set's Count; the card's "Blocking chain (xN)" groups are drawn from the same list, so
+ /// the chain counts add up to the value.
+ ///
+ public static IReadOnlyList BlockingWaitRows(
+ IReadOnlyList? events, IReadOnlyList excludedDatabases)
+ {
+ if (events == null || events.Count == 0) return Array.Empty();
+
+ var waits = BlockingWaits.Collapse(
+ FilterBlocking(events, excludedDatabases),
+ r => r.Source ?? "",
+ r => r.BlockedSpid,
+ r => r.BlockingSpid,
+ r => r.EventTime,
+ r => r.WaitTimeMs);
+
+ var xeWaits = waits.Where(r => r.Source == BlockedProcessAlertRow.XeReportSource).ToList();
+ return xeWaits.Count > 0 ? xeWaits : waits;
+ }
+
/* Excluded databases drop their rows; rows with no database always pass. Shared by the render path and
#2216's observation path so the two can never disagree about which rows exist. */
private static IReadOnlyList FilterBlocking(
diff --git a/PerformanceMonitor.Alerting/AlertEngine.cs b/PerformanceMonitor.Alerting/AlertEngine.cs
index 2d43d084ea..0d8e69924f 100644
--- a/PerformanceMonitor.Alerting/AlertEngine.cs
+++ b/PerformanceMonitor.Alerting/AlertEngine.cs
@@ -1196,29 +1196,19 @@ private async Task CheckBlockingAsync(
and the fired alert's context (:172) — see class remarks adaptation (1). */
blockingRows = await _readAdapter.GetRecentBlockedProcessReportsAsync(key, hoursBack: RollingCountWindowHours, ct);
- /* Lite's overview count semantics (LocalDataService.Overview.cs:74-77): prefer the
- XE blocked-process-report count; fall back to the DMV snapshot count when the XE
- count is zero (AWS RDS / unset blocked-process threshold). The merged adapter
- list contains all XE rows plus only uncovered DMV rows, so when no XE row exists
- the merged count IS the DMV count. */
- int xeCount = blockingRows.Count(r => r.Source == BlockedProcessAlertRow.XeReportSource);
- effectiveBlockingCount = xeCount > 0 ? xeCount : blockingRows.Count;
-
- /* :118-127 — with excluded databases configured and the raw count at/over the
- threshold, recount only rows outside the excluded set (no-database rows pass). */
- if (excludedDatabases.Count > 0
- && effectiveBlockingCount >= _settings.BlockingCountThreshold)
- {
- /* The recount applies the raw count's own rule to the same row set: XE rows when any
- exist, otherwise every row. */
- var recountRows = xeCount > 0
- ? blockingRows.Where(r => r.Source == BlockedProcessAlertRow.XeReportSource)
- : blockingRows;
- effectiveBlockingCount = recountRows
- .Count(r => string.IsNullOrEmpty(r.DatabaseName) ||
- !excludedDatabases.Any(e =>
- string.Equals(e, r.DatabaseName, StringComparison.OrdinalIgnoreCase)));
- }
+ /* #5644 W1/W2: the value is the number of blocking WAITS, not blocked-process reports.
+ SQL Server reports a still-blocked session every threshold seconds, so a one-minute wait
+ is about eleven report rows; counting rows alerted on one wait eleven times over. One
+ event is one blocked session, blocker and wait start, with its longest wait time.
+ BlockingWaitRows is the one place that set is built, and the card's "Blocking chain (xN)"
+ groups are drawn from the SAME list, so the value is the sum of the chain counts. It also
+ carries Lite's overview semantics (LocalDataService.Overview.cs:74-77): the XE
+ blocked-process-report waits when there are any, else the DMV-snapshot waits (AWS RDS /
+ unset blocked-process threshold), and the excluded-database filter (:118-127), whose rows
+ with no database always pass. The old shape counted every row, then recounted outside the
+ excluded set only once the raw count reached the threshold; filtering before counting
+ gives the same fire decision and a watermark that tracks the filtered count throughout. */
+ effectiveBlockingCount = AlertContextBuilders.BlockingWaitRows(blockingRows, excludedDatabases).Count;
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
diff --git a/PerformanceMonitor.Common/BlockingWaits.cs b/PerformanceMonitor.Common/BlockingWaits.cs
new file mode 100644
index 0000000000..11a7f07dd1
--- /dev/null
+++ b/PerformanceMonitor.Common/BlockingWaits.cs
@@ -0,0 +1,163 @@
+/*
+ * 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;
+
+namespace PerformanceMonitor.Common;
+
+///
+/// #5644 W1: what ONE blocking event is, defined once for the alert engine, the fleet "Blocking N" tile and the
+/// analysis BLOCKING_EVENTS fact, in Darling and in Lite.
+///
+/// SQL Server writes a blocked-process report for a still-blocked session every
+/// blocked process threshold seconds (5 at the lowest), so one one-minute wait is about eleven report rows.
+/// Counting rows counted that wait eleven times. Erik's ruling: one event per blocked session, blocker and wait
+/// start, reporting the longest wait time.
+///
+/// The wait start is derived: the report's time minus its wait_time_ms. Every report of one
+/// wait derives the same instant, give or take the milliseconds between the event timestamp and the wait time
+/// being read, so two reports of one (blocked, blocker) pair belong to the same wait when their derived starts are
+/// within of the previous one (reports sorted by derived start). A different
+/// wait of the same pair that was reported at all lasted at least the threshold (5 s) before its first report,
+/// so its start is at least that far from the previous wait's start; the tolerance sits well below it.
+///
+/// The same rule is written twice because the counts live in two places: for rows
+/// already in memory (the alert engine's read), and for the counts that run in the
+/// store (the fleet tile and the analysis fact). A test runs both over the same rows and checks they agree.
+///
+public static class BlockingWaits
+{
+ /// How far apart (milliseconds) two reports' derived wait starts may be and still be one wait. The
+ /// threshold is at least 5 s, so 2 s cannot merge two waits and is far above the few milliseconds of
+ /// timestamp jitter between reports of one wait (and the whole-second rounding of a store that keeps only
+ /// seconds).
+ public const long WaitStartToleranceMs = 2000;
+
+ /// The wait start of one report in epoch milliseconds: event time minus its wait time.
+ public static double WaitStartEpochMs(DateTime eventTime, long waitTimeMs) =>
+ (eventTime - DateTime.UnixEpoch).TotalMilliseconds - waitTimeMs;
+
+ ///
+ /// Collapses reports to one row per wait: the report with the longest wait time (the latest one on a tie).
+ /// keeps rows that must never merge apart (the report source, for one). A row with
+ /// no event time cannot be placed in a wait, so it stays a wait of its own. The result is ordered by the
+ /// representative's event time, newest first, as the alert reads are.
+ ///
+ public static List Collapse(
+ IEnumerable reports,
+ Func partition,
+ Func blockedSpid,
+ Func blockingSpid,
+ Func eventTime,
+ Func waitTimeMs)
+ {
+ var result = new List();
+ var placeable = new List<(T Row, double Start, DateTime Time, long Wait)>();
+ foreach (var row in reports)
+ {
+ var time = eventTime(row);
+ if (time is null)
+ {
+ result.Add(row);
+ continue;
+ }
+
+ placeable.Add((row, WaitStartEpochMs(time.Value, waitTimeMs(row)), time.Value, waitTimeMs(row)));
+ }
+
+ foreach (var pair in placeable.GroupBy(p => (partition(p.Row), blockedSpid(p.Row), blockingSpid(p.Row))))
+ {
+ var ordered = pair.OrderBy(p => p.Start).ThenBy(p => p.Time).ToList();
+ var best = ordered[0];
+ var previousStart = ordered[0].Start;
+ for (var i = 1; i < ordered.Count; i++)
+ {
+ var current = ordered[i];
+ if (current.Start - previousStart > WaitStartToleranceMs)
+ {
+ result.Add(best.Row);
+ best = current;
+ }
+ else if (current.Wait > best.Wait || (current.Wait == best.Wait && current.Time >= best.Time))
+ {
+ best = current;
+ }
+
+ previousStart = current.Start;
+ }
+
+ result.Add(best.Row);
+ }
+
+ return result
+ .OrderByDescending(r => eventTime(r) ?? DateTime.MinValue)
+ .ToList();
+ }
+
+ /// Which store's SQL dialect writes the epoch conversion in.
+ public enum SqlDialect
+ {
+ /// PostgreSQL (Darling's store).
+ Postgres,
+
+ /// DuckDB (Lite's store).
+ DuckDb,
+ }
+
+ ///
+ /// The CTE chain that turns a report relation into one row per wait, written for both stores from this one
+ /// text. is a SELECT that must return blocked_spid, blocking_spid,
+ /// event_time and wait_time_ms, plus whatever else the caller reads (kept as the representative
+ /// report's values), plus any column named in (for example
+ /// server_id). The chain ends in a CTE named , one row per wait, whose
+ /// wait_time_ms is the longest wait of that wait's reports and whose other columns are that
+ /// report's. The caller continues the WITH list after it (the text ends with the closing parenthesis, no
+ /// trailing comma).
+ ///
+ public static string WaitsCteSql(
+ SqlDialect dialect, string name, string source, IReadOnlyList? partitionColumns = null)
+ {
+ var partition = string.Join(
+ ", ", (partitionColumns ?? Array.Empty()).Concat(new[] { "blocked_spid", "blocking_spid" }));
+ var epochMs = dialect == SqlDialect.DuckDb
+ ? "CAST(epoch_ms(s.event_time) AS DOUBLE)"
+ : "(date_part('epoch', s.event_time) * 1000.0)";
+ var tolerance = WaitStartToleranceMs.ToString(System.Globalization.CultureInfo.InvariantCulture);
+ return $@"
+{name}_marked AS (
+ SELECT s.*, {epochMs} - s.wait_time_ms AS wait_start_ms
+ FROM ({source}) AS s
+),
+{name}_gapped AS (
+ SELECT m.*,
+ CASE
+ WHEN LAG(m.wait_start_ms) OVER (PARTITION BY {partition} ORDER BY m.wait_start_ms, m.event_time) IS NULL
+ OR m.wait_start_ms - LAG(m.wait_start_ms) OVER (PARTITION BY {partition} ORDER BY m.wait_start_ms, m.event_time) > {tolerance}
+ THEN 1 ELSE 0
+ END AS starts_wait
+ FROM {name}_marked AS m
+),
+{name}_numbered AS (
+ SELECT g.*,
+ SUM(g.starts_wait) OVER (PARTITION BY {partition} ORDER BY g.wait_start_ms, g.event_time ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS wait_no
+ FROM {name}_gapped AS g
+),
+{name}_ranked AS (
+ SELECT n.*,
+ ROW_NUMBER() OVER (PARTITION BY {partition}, n.wait_no ORDER BY n.wait_time_ms DESC, n.event_time DESC) AS wait_rank
+ FROM {name}_numbered AS n
+),
+{name} AS (
+ SELECT r.*
+ FROM {name}_ranked AS r
+ WHERE r.wait_rank = 1
+)";
+ }
+}
From de6cf7abea88221703437445056817284eed79bf Mon Sep 17 00:00:00 2001
From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com>
Date: Mon, 12 Oct 2026 15:30:51 -0400
Subject: [PATCH 06/42] Web wording: caller-only clauses, latch/spinlock rate
labels, plural, zero-normal collectors (#5644 W8 W10 W13 W15)
---
.../CollectionOutputBesideCostTests.cs | 5 +-
Darling/Darling.Tests/WebPlainTextTests.cs | 51 ++++++++++
Darling/Darling.Tests/WebWordingFixesTests.cs | 50 ++++++++++
.../ZeroIsNormalCollectorTests.cs | 92 +++++++++++++++++++
.../Mcp/DarlingDataReader.cs | 3 +-
.../wwwroot/js/pages/finops/index-analysis.js | 3 +-
.../wwwroot/js/pages/finops/utilization.js | 3 +-
.../wwwroot/js/pages/server-tabs.js | 21 +++--
.../wwwroot/js/plain-text.js | 24 +++++
.../wwwroot/js/util.js | 11 ++-
Lite.Tests/CollectionOutputBesideCostTests.cs | 5 +-
Lite.Tests/ZeroIsNormalCollectorTests.cs | 43 +++++++++
.../LocalDataService.CollectionHealth.cs | 3 +-
.../ServerHealthBands.cs | 50 +++++++++-
14 files changed, 343 insertions(+), 21 deletions(-)
create mode 100644 Darling/Darling.Tests/WebWordingFixesTests.cs
create mode 100644 Darling/Darling.Tests/ZeroIsNormalCollectorTests.cs
create mode 100644 Lite.Tests/ZeroIsNormalCollectorTests.cs
diff --git a/Darling/Darling.Tests/CollectionOutputBesideCostTests.cs b/Darling/Darling.Tests/CollectionOutputBesideCostTests.cs
index ce6ce8ab9b..d3930130ee 100644
--- a/Darling/Darling.Tests/CollectionOutputBesideCostTests.cs
+++ b/Darling/Darling.Tests/CollectionOutputBesideCostTests.cs
@@ -220,10 +220,11 @@ public void TheFindingSignature_TakesTheNoteCount_AndNoNoteText()
/* The precondition, named so a signature change reports itself rather than turning the assertions
below into a vacuous pass over a list that no longer means what this test thinks. */
- Assert.Equal(6, parameters.Length);
+ /* #5644: the zero-is-normal bool joined the event bool, computed from a name list the same way. */
+ Assert.Equal(7, parameters.Length);
Assert.Equal(
- new[] { "rowsStored", "totalRuns", "deniedSinceLastSuccess", "noteCount", "faultedRuns", "isEventCollector" },
+ new[] { "rowsStored", "totalRuns", "deniedSinceLastSuccess", "noteCount", "faultedRuns", "isEventCollector", "isZeroNormal" },
parameters.Select(p => p.Name!).ToArray());
Assert.DoesNotContain(parameters, p => p.ParameterType == typeof(string));
diff --git a/Darling/Darling.Tests/WebPlainTextTests.cs b/Darling/Darling.Tests/WebPlainTextTests.cs
index f87c80755f..eae57e27bc 100644
--- a/Darling/Darling.Tests/WebPlainTextTests.cs
+++ b/Darling/Darling.Tests/WebPlainTextTests.cs
@@ -97,6 +97,57 @@ public void CollectorMeasurementCounts_ReadAsWords()
Assert.Equal("latest run: shred gated: 1, events read: 0, report xml empty: 0", o[0]);
}
+ /* #5644 W8: the clauses that point an MCP caller at a parameter the page has no control for. */
+ [Fact]
+ public void ParameterClauses_ForAnMcpCaller_AreDroppedOrSaidInThePagesWords()
+ {
+ var o = Run(
+ [
+ "The width was chosen to keep this answer near 1500 points; pass bucket_minutes (1-1440) for another width, or narrow hours_back for finer points.",
+ "TRUNCATED: 300 indexes match and 200 were returned. Rows are ordered by total wait time descending; raise limit to see more.",
+ "TRUNCATED: 9 indexes match database 'x' and 5 were returned. This says nothing about databases outside the filter — raise limit to see more.",
+ "TRUNCATED: 9 indexes match and 5 were returned. This is not evidence that a database was not collected — pass database_name to ask about one, or raise limit.",
+ " truncated: 5 of 9 returned; raise limit (now 5) for more.",
+ "The row limit of 50 was REACHED: databases[] holds the 50 biggest spillers and the totals above still cover every database - raise limit to see the rest of the rows.",
+ "At least one database's statistics WERE reset in this window - see stats_reset_count per database, and raise limit if none of the databases returned shows one - so its totals are lower bounds.",
+ "No active query snapshots on SQL2025 in the last 1 hour(s) matched blocking_only. The filters were applied in SQL over the whole window.",
+ "No active query snapshots on SQL2025 in the last 1 hour(s) matched database_name 'Sales' with blocking_only with wait_type 'LCK_M_X'.",
+ "3 sp_server_diagnostics event(s) were captured for SQL2025 in the last 1 hour(s) and none qualified. Events ARE being captured, so this is the healthy answer for this read rather than missing data.",
+ "TRUNCATED: 40 groups were in scope; only the top 25 (by name) are returned. Scope by server_name, or raise limit, to see the rest.",
+ ], out _);
+
+ Assert.Equal("The width was chosen to keep this answer near 1500 points; narrow the time range for finer points.", o[0]);
+ Assert.Equal("TRUNCATED: 300 indexes match and 200 were returned. Rows are ordered by total wait time descending.", o[1]);
+ Assert.EndsWith("outside the filter.", o[2]);
+ Assert.EndsWith("filter by database to ask about one.", o[3]);
+ Assert.Equal(" truncated: 5 of 9 returned.", o[4]);
+ Assert.EndsWith("still cover every database.", o[5]);
+ Assert.Contains("per database - so its totals", o[6]);
+ Assert.Equal("No active query snapshots on SQL2025 in the last 1 hour(s) matched blocking queries only. The filters were applied in SQL over the whole window.", o[7]);
+ Assert.Contains("matched database 'Sales' with blocking queries only with wait type 'LCK_M_X'.", o[8]);
+ Assert.Contains("so this is a normal result, not missing data.", o[9]);
+ Assert.EndsWith("Select a single server to see the rest.", o[10]);
+ foreach (var line in o)
+ {
+ Assert.DoesNotContain("raise limit", line);
+ Assert.DoesNotContain("bucket_minutes", line);
+ Assert.DoesNotContain("blocking_only", line);
+ Assert.DoesNotContain("healthy answer for this read", line);
+ }
+ }
+
+ [Fact]
+ public void AParameterNamedInRealText_IsLeftAlone()
+ {
+ string[] same =
+ [
+ "Column limit_value was reset",
+ "relation database_name_idx does not exist",
+ "blocking_only_tbl",
+ ];
+ Assert.Equal(same, Run(same, out _));
+ }
+
[Fact]
public void UserDataAndRealErrors_ReachThePageAsWritten()
{
diff --git a/Darling/Darling.Tests/WebWordingFixesTests.cs b/Darling/Darling.Tests/WebWordingFixesTests.cs
new file mode 100644
index 0000000000..bdc3c3f843
--- /dev/null
+++ b/Darling/Darling.Tests/WebWordingFixesTests.cs
@@ -0,0 +1,50 @@
+using System;
+using System.Text.RegularExpressions;
+using Xunit;
+using static Darling.Tests.RepoFile;
+namespace Darling.Tests;
+
+///
+/// #5644 W8, W10 and W15: wording fixes in the shipped page script, pinned against the source (the page itself needs a browser).
+///
+public sealed class WebWordingFixesTests
+{
+ private static string ServerTabs() =>
+ ReadRepoFile("Darling", "PerformanceMonitor.Darling.Service", "wwwroot", "js", "pages", "server-tabs.js").ReplaceLineEndings("\n");
+
+ [Fact]
+ public void ThePickerPlurals_AreWrittenOut_NotBuiltWithAnS()
+ {
+ var js = ServerTabs();
+
+ /* W15: "latch class" + "s" read "latch classs" in the panel subtitle and the picker's defaults label. */
+ Assert.Contains("plural: \"latch classes\"", js, StringComparison.Ordinal);
+ Assert.Contains("plural: \"spinlocks\"", js, StringComparison.Ordinal);
+ Assert.DoesNotContain("spec.noun + \"s\"", js, StringComparison.Ordinal);
+ Assert.Contains("\"Top \" + spec.plural", js, StringComparison.Ordinal);
+ Assert.Contains("spec.plural + \" you check\"", js, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void TheLatchAndSpinlockRateColumns_SayTheyAreTheLatestInterval()
+ {
+ var js = ServerTabs();
+
+ /* W10: the panels read "last 24 hours" and so do their totals; the per-second rate is the latest interval's. */
+ Assert.Matches(new Regex(@"key: ""wait_ms_per_second"", label: ""ms/s, latest interval"""), js);
+ Assert.Matches(new Regex(@"key: ""collisions_per_second"", label: ""Coll/s, latest interval"""), js);
+ }
+
+ [Fact]
+ public void TheNoticeStrips_AndTheTrendNotes_RunThroughPlainText()
+ {
+ var util = ReadRepoFile("Darling", "PerformanceMonitor.Darling.Service", "wwwroot", "js", "util.js").ReplaceLineEndings("\n");
+ var tabs = ServerTabs();
+
+ /* W8: a payload's own note (aggregate_note, a truncation note) is written for an MCP caller. */
+ Assert.Contains("[plainText(message)]", util, StringComparison.Ordinal);
+ Assert.Contains("text: plainText(text)", util, StringComparison.Ordinal);
+ Assert.DoesNotContain("class: \"mp-metric-note\", text: res.data.aggregate_note", tabs, StringComparison.Ordinal);
+ Assert.DoesNotContain("class: \"mp-metric-note\", text: trend.data.aggregate_note", tabs, StringComparison.Ordinal);
+ }
+}
diff --git a/Darling/Darling.Tests/ZeroIsNormalCollectorTests.cs b/Darling/Darling.Tests/ZeroIsNormalCollectorTests.cs
new file mode 100644
index 0000000000..4ab84ca00d
--- /dev/null
+++ b/Darling/Darling.Tests/ZeroIsNormalCollectorTests.cs
@@ -0,0 +1,92 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using PerformanceMonitor.Collectors;
+using PerformanceMonitor.Common;
+using PerformanceMonitor.Darling.Service.Mcp;
+using Xunit;
+namespace Darling.Tests;
+
+///
+/// #5644 W13: Collection Health said a persistent zero "needs a look" for a trace-flag list with none enabled and for a
+/// running-job snapshot with no job running, sources whose zero is the normal answer. They now get their own sentence, and
+/// the set that earns it is pinned against the catalog.
+///
+public sealed class ZeroIsNormalCollectorTests
+{
+ private static CollectorHealth Zero(string collector, long notes = 0, long errors = 0) => new()
+ {
+ CollectorName = collector,
+ TotalRuns = 400,
+ SuccessCount = 400 - errors,
+ ErrorCount = errors,
+ RowsStored = 0,
+ NoteCount = notes,
+ LastSuccessTime = DateTime.UtcNow.AddMinutes(-1),
+ LastRunTime = DateTime.UtcNow.AddMinutes(-1),
+ };
+
+ [Theory]
+ [InlineData("trace_flags")]
+ [InlineData("running_jobs")]
+ public void AZeroFromASourceThatIsEmptyByDefault_IsNotSentToALook(string collector)
+ {
+ var finding = Zero(collector).OutputFinding;
+
+ Assert.NotNull(finding);
+ Assert.Contains("normal answer", finding, StringComparison.Ordinal);
+ Assert.Contains("needs no action", finding, StringComparison.Ordinal);
+ Assert.DoesNotContain("needs a look", finding, StringComparison.Ordinal);
+ /* It is not an event capture, so the event sentence's claim is not made of it. */
+ Assert.DoesNotContain("only when an event occurs", finding, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void ASnapshotCollectorNotOnTheList_StillNeedsALook()
+ {
+ /* The positive control for the test above: the same row shape on a collector whose zero is not a normal answer. */
+ var finding = Zero("database_scoped_config").OutputFinding;
+
+ Assert.NotNull(finding);
+ Assert.Contains("needs a look", finding, StringComparison.Ordinal);
+ Assert.DoesNotContain("normal answer", finding, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void ANoteOrAFault_KeepsItsOwnSentence_OverTheNormalZero()
+ {
+ var noted = Zero("trace_flags", notes: 3).OutputFinding;
+ Assert.NotNull(noted);
+ Assert.DoesNotContain("normal answer", noted, StringComparison.Ordinal);
+ Assert.Contains("recorded a note", noted, StringComparison.Ordinal);
+
+ var faulted = Zero("running_jobs", errors: 5).OutputFinding;
+ Assert.NotNull(faulted);
+ Assert.DoesNotContain("normal answer", faulted, StringComparison.Ordinal);
+ Assert.Contains("UNABLE to read", faulted, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void TheZeroIsNormalSet_IsReal_AndDisjointFromTheEventSet()
+ {
+ var catalog = new HashSet(CollectorCatalog.All.Select(c => c.Name), StringComparer.OrdinalIgnoreCase);
+ var set = CollectorHealthClassifier.ZeroIsNormalCollectorNamesForPinning;
+
+ Assert.NotEmpty(set);
+ foreach (var name in set)
+ {
+ Assert.Contains(name, catalog);
+ Assert.True(CollectorHealthClassifier.IsZeroNormalCollector(name), name);
+ /* The event sentence is checked first and says something else, so an overlap would never reach this one. */
+ Assert.False(CollectorHealthClassifier.IsEventCollector(name), name);
+ }
+
+ foreach (var described in new[] { "trace_flags", "running_jobs" })
+ {
+ Assert.True(CollectorHealthClassifier.IsZeroNormalCollector(described), described);
+ }
+
+ Assert.False(CollectorHealthClassifier.IsZeroNormalCollector("wait_stats"));
+ Assert.False(CollectorHealthClassifier.IsZeroNormalCollector(null));
+ }
+}
diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingDataReader.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingDataReader.cs
index 78c7ae2458..96c2f2cce2 100644
--- a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingDataReader.cs
+++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingDataReader.cs
@@ -5324,7 +5324,8 @@ defaulted zero reaching a surface as "stored nothing" is the #2804 hazard exactl
: CollectorHealthClassifier.FormatOutputFinding(
RowsStored, TotalRuns, DeniedSinceLastSuccess, NoteCount,
faultedRuns: ErrorCount + SessionMissingCount,
- isEventCollector: CollectorHealthClassifier.IsEventCollector(CollectorName))
+ isEventCollector: CollectorHealthClassifier.IsEventCollector(CollectorName),
+ isZeroNormal: CollectorHealthClassifier.IsZeroNormalCollector(CollectorName))
is { Length: > 0 } finding
? finding
: null;
diff --git a/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/finops/index-analysis.js b/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/finops/index-analysis.js
index b1917ef698..6d1c255b1e 100644
--- a/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/finops/index-analysis.js
+++ b/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/finops/index-analysis.js
@@ -20,6 +20,7 @@
import { VIZ } from "../../panels.js";
import { databaseBox, newBoxChoice } from "./database-box.js";
import { el, mount, loadingStrip, noticeStrip, readErrorStrip, errorStrip, readTool, fmtInt, fmtNum } from "../../util.js";
+import { plainText } from "../../plain-text.js";
import { gatedEmptyStrip } from "./gate.js";
// The most recommendations the view lists (#5238). The desktop grid shows every finding, so the tab asks for the ceiling, as the
@@ -163,7 +164,7 @@ export const tab = {
if (more.length) {
parts.push(el("details", {}, [
el("summary", { text: "Analyzer notes (" + more.length + ")" }),
- ...more.map((t) => el("p", { class: "finops-note", text: t })),
+ ...more.map((t) => el("p", { class: "finops-note", text: plainText(t) })),
]));
}
parts.push(el("h3", { text: "Reclaimable Space (overall + per database)" }));
diff --git a/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/finops/utilization.js b/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/finops/utilization.js
index 1065489b4a..e172987775 100644
--- a/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/finops/utilization.js
+++ b/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/finops/utilization.js
@@ -17,6 +17,7 @@
import { renderPanel, VIZ } from "../../panels.js";
import { READ_FIELDS } from "../../read-fields.js";
import { el, mount, loadingStrip, readErrorStrip, errorStrip, readTool, fmtInt } from "../../util.js";
+import { plainText } from "../../plain-text.js";
import { gatedEmptyStrip, PG_NOT_COLLECTED } from "./gate.js";
const CPU_HOURS = 24;
const TOP_HOURS = 24;
@@ -147,7 +148,7 @@ function loadUtilization(body, server, ctx) {
mount(body, [
el("h3", {}, [SUMMARY_TITLE, el("span", { class: "panel-sub", text: " " + SUMMARY_SUBTITLE })]),
VIZ.stat(view, { stats }),
- data.health_score_note ? el("p", { class: "finops-note", title: data.health_score_note, text: data.health_score_note }) : null,
+ data.health_score_note ? el("p", { class: "finops-note", title: plainText(data.health_score_note), text: plainText(data.health_score_note) }) : null,
el("p", { class: "finops-reason", text: data.verdict_reason }),
el("details", {}, [
el("summary", { text: "7-Day Provisioning Trend" }),
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 97cb90a3a9..364c6522af 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, makeActivatable, readTool, readToolWithinKeptHistory, keptWindowStrip, windowFloorStrip, mount, truncate, loadingStrip, errorStrip, readErrorStrip, emptyStrip, disclosure, noticeStrip, getPath, fmtMs, fmtRate, localTime, parseUtc, windowFromHours, daysText, dbScopeChip, dbFilteredEmptyText, sourceStrip, getActiveDatabaseFilter, queryWaitFilter, setQueryWaitFilter, waitIsLinked } from "../util.js";
+import { el, makeActivatable, readTool, readToolWithinKeptHistory, keptWindowStrip, windowFloorStrip, mount, truncate, loadingStrip, errorStrip, readErrorStrip, emptyStrip, disclosure, noticeStrip, metricNote, getPath, fmtMs, fmtRate, localTime, parseUtc, windowFromHours, daysText, dbScopeChip, dbFilteredEmptyText, sourceStrip, getActiveDatabaseFilter, queryWaitFilter, setQueryWaitFilter, waitIsLinked } from "../util.js";
import { renderPanel, setPanelSignal, getPanelSignal, VIZ, worstFirst, newestFirst } from "../panels.js";
import { zoomableLineChart, chartZoomScope, SERIES_COLORS, CATEGORICAL_COLORS } from "../charts.js";
import { multiPicker, mergeSeriesRows } from "../multi-picker.js";
@@ -3306,7 +3306,8 @@ const LATCH_COLUMNS = [
{ key: "total_delta_wait_time_ms", label: "Wait", format: "ms" },
{ key: "total_delta_waiting_requests", label: "Requests", format: "int" },
{ key: "avg_wait_ms_per_request", label: "Avg/req", format: "num2" },
- { key: "wait_ms_per_second", label: "ms/s", format: "num2" },
+ /* #5644 W10: the panel reads "last 24 hours" and the totals are, but this rate is the latest interval's only. */
+ { key: "wait_ms_per_second", label: "ms/s, latest interval", format: "num2" },
{ key: "description", label: "What it means", wrap: true },
];
@@ -3316,7 +3317,7 @@ const SPINLOCK_COLUMNS = [
{ key: "total_delta_spins", label: "Spins", format: "int" },
{ key: "total_delta_backoffs", label: "Backoffs", format: "int" },
{ key: "spins_per_collision", label: "Spins/coll", format: "num1" },
- { key: "collisions_per_second", label: "Coll/s", format: "num2" },
+ { key: "collisions_per_second", label: "Coll/s, latest interval", format: "num2" },
{ key: "description", label: "What it means", wrap: true },
];
@@ -4053,7 +4054,7 @@ export function serverTrendPanel(server, ctx, kind) {
unit: spec.unit,
...windowFromHours(res.keptHours || ctx.hours),
}, "server-trend|" + server + "|" + spec.metric, chartZoomScope(ctx.hours)),
- typeof res.data.aggregate_note === "string" && res.data.aggregate_note ? el("div", { class: "mp-metric-note", text: res.data.aggregate_note }) : null,
+ typeof res.data.aggregate_note === "string" && res.data.aggregate_note ? metricNote(res.data.aggregate_note) : null,
]);
})();
return panel;
@@ -4157,7 +4158,7 @@ export async function drawClerkTrends(slot, server, ctx, checked) {
unit: "MB",
...windowFromHours(trend.keptHours || ctx.hours),
}, "clerk-trend|" + server, chartZoomScope(ctx.hours)),
- typeof trend.data.aggregate_note === "string" && trend.data.aggregate_note ? el("div", { class: "mp-metric-note", text: trend.data.aggregate_note }) : null,
+ typeof trend.data.aggregate_note === "string" && trend.data.aggregate_note ? metricNote(trend.data.aggregate_note) : null,
]);
}
@@ -4176,6 +4177,7 @@ const NAMED_TRENDS = {
seriesKey: "latch_class",
unit: "ms/s",
noun: "latch class",
+ plural: "latch classes",
label: "Latch classes",
note: "Wait ms/s, one line per checked latch class.",
noOptions: "No latch classes accumulated waits in this window.",
@@ -4188,6 +4190,7 @@ const NAMED_TRENDS = {
seriesKey: "spinlock_name",
unit: "collisions/s",
noun: "spinlock",
+ plural: "spinlocks",
label: "Spinlocks",
note: "Collisions/s, one line per checked spinlock.",
noOptions: "No spinlocks recorded collisions in this window.",
@@ -4198,7 +4201,7 @@ const DEFAULT_NAMES_CHECKED = 5;
export function namedTrendPanel(server, ctx, kind) {
const spec = NAMED_TRENDS[kind];
- const { panel, body } = panelShell(spec.title, ctx.label + ", with a trend for the " + spec.noun + "s you check", 2, spec.optionsTool);
+ const { panel, body } = panelShell(spec.title, ctx.label + ", with a trend for the " + spec.plural + " you check", 2, spec.optionsTool);
(async () => {
const res = await readToolWithinKeptHistory(spec.optionsTool, { server, hours: ctx.hours, top: MAX_NAMES_CHARTED }, ctx && ctx.signal);
if (res.kind === "error") return mount(body, readErrorStrip(res.message));
@@ -4213,7 +4216,7 @@ export function namedTrendPanel(server, ctx, kind) {
options,
max: MAX_NAMES_CHARTED,
noun: spec.noun,
- defaultsLabel: "Top " + spec.noun + "s",
+ defaultsLabel: "Top " + spec.plural,
defaults: (o) => o.slice(0, DEFAULT_NAMES_CHECKED),
onChange: (checked) => drawNamedTrends(chartSlot, server, ctx, kind, checked),
});
@@ -4280,7 +4283,7 @@ export async function drawNamedTrends(slot, server, ctx, kind, checked) {
...windowFromHours(trend.keptHours || ctx.hours),
}, kind + "-trend|" + server, chartZoomScope(ctx.hours)),
el("div", { class: "mp-metric-note", text: spec.note }),
- typeof trend.data.aggregate_note === "string" && trend.data.aggregate_note ? el("div", { class: "mp-metric-note", text: trend.data.aggregate_note }) : null,
+ typeof trend.data.aggregate_note === "string" && trend.data.aggregate_note ? metricNote(trend.data.aggregate_note) : null,
]);
}
@@ -4323,7 +4326,7 @@ export function sessionStatsTrendPanel(server, ctx) {
...windowFromHours(res.keptHours || ctx.hours),
}, "server-trend|" + server + "|session_stats", chartZoomScope(ctx.hours)),
top.length ? el("div", { class: "mp-metric-note", text: "Top in the newest bucket: " + top.join("; ") + "." }) : null,
- typeof res.data.aggregate_note === "string" && res.data.aggregate_note ? el("div", { class: "mp-metric-note", text: res.data.aggregate_note }) : null,
+ typeof res.data.aggregate_note === "string" && res.data.aggregate_note ? metricNote(res.data.aggregate_note) : null,
]);
})();
return panel;
diff --git a/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/plain-text.js b/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/plain-text.js
index 0cd536ed73..a165249b54 100644
--- a/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/plain-text.js
+++ b/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/plain-text.js
@@ -179,6 +179,30 @@ export function plainText(text) {
t = t.replace(/\banswered_only: true\b/g, "the answered-only ranking");
t = t.replace(/\banswered_only\b/g, "the answered-only ranking");
+ /* #5644 W8: clauses that tell an MCP client which parameter to pass. The page has no such control, so a clause that points
+ at one is dropped (the sentence before it still stands) and a filter name is said as the page names it. hours_back and
+ days_back are already put into words by namedTokenText, which runs first. */
+ /* A trend's width note: "...near 1500 points; pass bucket_minutes (1-1440) for another width, or narrow hours_back for finer
+ points." The page picks the width, so only the narrower range is left to say. */
+ t = t.replace(/pass bucket_minutes \(1-\d+\) for another width, or /g, "");
+ /* "raise limit": the pages have no row-limit control. "...filter - raise limit to see more." and "...returned; raise limit
+ (now 50) for more." lose the clause; ", or raise limit," and ", and raise limit if none ... shows one" lose theirs. */
+ t = t.replace(/\s*[;—–-]+\s*raise (?:the )?limit(?: \(now [\d,]+\))?(?: (?:to see|for) (?:more|the rest(?: of the rows)?))?(?=\.)/g, "");
+ t = t.replace(/Scope by server_name(?:, or raise limit,)? (to see the rest|to see them)\./g, "Select a single server $1.");
+ t = t.replace(/, or raise (?:the )?limit(?=[.,])/g, "");
+ t = t.replace(/, and raise limit if none of the databases returned shows one/g, "");
+ t = t.replace(/\bpass database_name to ask about one\b/g, "filter by database to ask about one");
+ t = t.replace(/\bpass database_name to chart one database per file\b/g, "choose a database to chart its files");
+ t = t.replace(/\bomit database_name for every database\b/g, "clear the database filter for every database");
+ /* The filters a "matched ..." sentence names: "matched database_name 'x' with blocking_only with wait_type 'y'". */
+ t = t.replace(/\bdatabase_name '/g, "database '");
+ t = t.replace(/\bwait_type '/g, "wait type '");
+ t = t.replace(/\bcollector_name '/g, "collector '");
+ t = t.replace(/\bmin_duration_ms (?=\d)/g, "minimum duration (ms) ");
+ t = t.replace(/\bblocking_only\b/g, "blocking queries only");
+ /* The system-health "events were captured, none qualified" closing. */
+ t = t.replace(/so this is the healthy answer for this read rather than missing data/g, "so this is a normal result, not missing data");
+
/* A tool name: use get_collection_health -> use Collection Health. Only a real tool name; any other get_ word is the
reader's own text (an object, a server) and stays as written. */
t = t.replace(/(? (TOOL_NAME_SET.has(m) ? toolWords(rest) : m));
diff --git a/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/util.js b/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/util.js
index dd6c69f97a..6d712e80e7 100644
--- a/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/util.js
+++ b/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/util.js
@@ -135,11 +135,16 @@ export function errorStrip(message) {
return el("div", { class: "strip error", role: "alert" }, [message]);
}
export function emptyStrip(message) {
- return el("div", { class: "strip empty" }, [message]);
+ return el("div", { class: "strip empty" }, [plainText(message)]);
}
-/** A non-fatal caveat about otherwise-good data (e.g. the #1665 partial-window notice). */
+/** A non-fatal caveat about otherwise-good data (e.g. the #1665 partial-window notice). The sentence may be one the service
+ wrote for an MCP caller (a width note, a truncation note), so it passes through plainText like every message (#5644 W8). */
export function noticeStrip(message) {
- return el("div", { class: "strip notice", role: "status" }, [message]);
+ return el("div", { class: "strip notice", role: "status" }, [plainText(message)]);
+}
+/** A note line under a chart, from a payload's own `aggregate_note`: written for an MCP caller, said in the page's words (#5644 W8). */
+export function metricNote(text) {
+ return el("div", { class: "mp-metric-note", text: plainText(text) });
}
/**
* The two collectors the blocking reads draw from (#5244): the blocked-process-report XE session when it holds a row in the window
diff --git a/Lite.Tests/CollectionOutputBesideCostTests.cs b/Lite.Tests/CollectionOutputBesideCostTests.cs
index c038dc6534..69d5f4cae9 100644
--- a/Lite.Tests/CollectionOutputBesideCostTests.cs
+++ b/Lite.Tests/CollectionOutputBesideCostTests.cs
@@ -200,10 +200,11 @@ below into a vacuous pass over a list that no longer means what this test thinks
#3754: the faulted-run count (a RUN-CLASS count, like the note count beside it) and the
event-collector bool, computed by the caller from the name on IsEventCollector's own name-list
pattern, precisely so that this method still takes no string. */
- Assert.Equal(6, parameters.Length);
+ /* #5644: the zero-is-normal bool joined the event bool, computed from a name list the same way. */
+ Assert.Equal(7, parameters.Length);
Assert.Equal(
- new[] { "rowsStored", "totalRuns", "deniedSinceLastSuccess", "noteCount", "faultedRuns", "isEventCollector" },
+ new[] { "rowsStored", "totalRuns", "deniedSinceLastSuccess", "noteCount", "faultedRuns", "isEventCollector", "isZeroNormal" },
parameters.Select(p => p.Name!).ToArray());
Assert.DoesNotContain(parameters, p => p.ParameterType == typeof(string));
diff --git a/Lite.Tests/ZeroIsNormalCollectorTests.cs b/Lite.Tests/ZeroIsNormalCollectorTests.cs
new file mode 100644
index 0000000000..b28bb1a7e5
--- /dev/null
+++ b/Lite.Tests/ZeroIsNormalCollectorTests.cs
@@ -0,0 +1,43 @@
+using System;
+using PerformanceMonitorLite.Services;
+using Xunit;
+namespace Lite.Tests;
+
+///
+/// #5644 W13, the Lite twin of the Darling suite's test of the same name: a trace-flag list with none enabled and a
+/// running-job snapshot with no job running are sources whose zero is the normal answer, so the collection-health row does not
+/// say a persistent zero "needs a look". Lite's row reads the same shared classifier.
+///
+public sealed class ZeroIsNormalCollectorTests
+{
+ private static CollectorHealthRow Zero(string collector) => new()
+ {
+ CollectorName = collector,
+ TotalRuns = 400,
+ SuccessCount = 400,
+ RowsStored = 0,
+ LastSuccessTime = DateTime.UtcNow.AddMinutes(-1),
+ LastRunTime = DateTime.UtcNow.AddMinutes(-1),
+ };
+
+ [Theory]
+ [InlineData("trace_flags")]
+ [InlineData("running_jobs")]
+ public void AZeroFromASourceThatIsEmptyByDefault_IsNotSentToALook(string collector)
+ {
+ var finding = Zero(collector).OutputFinding;
+
+ Assert.NotNull(finding);
+ Assert.Contains("normal answer", finding, StringComparison.Ordinal);
+ Assert.DoesNotContain("needs a look", finding, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void ASnapshotCollectorNotOnTheList_StillNeedsALook()
+ {
+ var finding = Zero("database_scoped_config").OutputFinding;
+
+ Assert.NotNull(finding);
+ Assert.Contains("needs a look", finding, StringComparison.Ordinal);
+ }
+}
diff --git a/Lite/Services/LocalDataService.CollectionHealth.cs b/Lite/Services/LocalDataService.CollectionHealth.cs
index 39cf032083..810bd0ea54 100644
--- a/Lite/Services/LocalDataService.CollectionHealth.cs
+++ b/Lite/Services/LocalDataService.CollectionHealth.cs
@@ -1402,7 +1402,8 @@ hazard exactly. */
CollectorHealthClassifier.FormatOutputFinding(
RowsStored, TotalRuns, DeniedSinceLastSuccess, NoteCount,
faultedRuns: ErrorCount + SessionMissingCount,
- isEventCollector: CollectorHealthClassifier.IsEventCollector(CollectorName))
+ isEventCollector: CollectorHealthClassifier.IsEventCollector(CollectorName),
+ isZeroNormal: CollectorHealthClassifier.IsZeroNormalCollector(CollectorName))
is { Length: > 0 } finding
? finding
: null;
diff --git a/PerformanceMonitor.Common/ServerHealthBands.cs b/PerformanceMonitor.Common/ServerHealthBands.cs
index ac0ee18b74..840881d50d 100644
--- a/PerformanceMonitor.Common/ServerHealthBands.cs
+++ b/PerformanceMonitor.Common/ServerHealthBands.cs
@@ -1719,6 +1719,35 @@ public static bool ExpectsUserDatabases(string? collectorName) =>
public static bool IsEventCollector(string? collectorName) =>
collectorName is not null && EventCollectorNames.Contains(collectorName);
+ ///
+ /// The collectors whose source holds nothing UNLESS something is enabled, running or configured (#5644 W13): a
+ /// trace-flag list with no flag turned on, a SQL Agent running-job snapshot with no job in flight, a plan-correction
+ /// list with no tuning recommendation, a replication-slot or standby list on a primary with none. A zero is the
+ /// normal answer for them, so must not call a persistent zero "a look" (the
+ /// generic snapshot sentence the web audit flagged). Not event captures: nothing "occurred" - there is simply
+ /// nothing set up to report - which is why they are not in and why the
+ /// event sentence's "stores a row only when an event occurs" is not said of them. Same polarity rule as the event
+ /// list: a name is added only for a source that is genuinely empty by default, so an omission fails loud (the
+ /// "needs a look" sentence) rather than reassuring over a broken zero. Pinned against the catalog's real names by
+ /// both suites. The AG state collectors are handled separately (a not-applicable row where Always On is off).
+ ///
+ private static readonly HashSet ZeroIsNormalCollectorNames = new(StringComparer.OrdinalIgnoreCase)
+ {
+ "trace_flags",
+ "running_jobs",
+ "plan_correction",
+ "pg_replication_slots",
+ "pg_replication_stats",
+ };
+
+ /// True when a zero-row window is the normal answer for this collector's source (, #5644).
+ /// False for every other collector and for a null name.
+ public static bool IsZeroNormalCollector(string? collectorName) =>
+ collectorName is not null && ZeroIsNormalCollectorNames.Contains(collectorName);
+
+ /// The names in , so both suites can pin them against the catalog.
+ public static IReadOnlyCollection ZeroIsNormalCollectorNamesForPinning => ZeroIsNormalCollectorNames;
+
///
/// The names in , exposed read-only so both suites can pin the set
/// against the catalog's real collector names (every entry must be a collector that exists) and
@@ -2767,7 +2796,8 @@ public static string FormatOutputFinding(
bool deniedSinceLastSuccess,
long noteCount,
long faultedRuns,
- bool isEventCollector)
+ bool isEventCollector,
+ bool isZeroNormal = false)
{
if (rowsStored > 0 || totalRuns <= 0)
{
@@ -2863,6 +2893,24 @@ FormatCollectionNote already renders and which would then exist twice. */
totalRuns);
}
+ /* #5644 W13: a source that is empty unless something is enabled or running (ZeroIsNormalCollectorNames). Same
+ preconditions as the event sentence above (no denial, no fault, no note), so the reading rests on the same
+ facts; what differs is the reason a zero is fine - there is nothing set up to report - and it does not
+ claim the collector waits for an event. */
+ if (isZeroNormal)
+ {
+ return string.Format(
+ CultureInfo.InvariantCulture,
+ "Stored 0 rows across {0:N0} runs with no current denial (denied_since_last_success is "
+ + "false), so this collector read and found nothing rather than being unable to read. "
+ + "Its source is empty unless something is enabled or running - a trace flag turned on, a "
+ + "job in progress, a tuning recommendation, a replication slot - so zero is a normal answer "
+ + "and needs no action. No run recorded a note or a fault, which is what that reading rests "
+ + "on. "
+ + ZeroOutputCaveat,
+ totalRuns);
+ }
+
/* #3754: NOT an event collector, and nothing on the row explains the zero. A configuration or
snapshot read returns rows whenever its source has any, so every run returning nothing is
not a resting state - it is the source coming back empty, and the sentence says so. Worded
From 58c25c3d2bdff9132aea2f0c4864dbda0d04022a Mon Sep 17 00:00:00 2001
From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com>
Date: Mon, 12 Oct 2026 15:36:11 -0400
Subject: [PATCH 07/42] Long text cells clip to one line, capped panels say so,
ISO times print local (#5644 W11 W12 W20)
---
Darling/Darling.Tests/JobHistoryPageTests.cs | 3 +-
.../WebClampCellBehaviourTests.cs | 142 ++++++++++++++++++
.../WebPartialWindowBehaviourTests.cs | 136 +++++++++++++++++
.../Darling.Tests/web-clamp-cell-harness.mjs | 89 +++++++++++
.../web-partial-window-harness.mjs | 97 ++++++++++++
.../wwwroot/css/app.css | 7 +-
.../wwwroot/js/pages/job-history.js | 11 +-
.../wwwroot/js/pages/server-tabs.js | 23 +--
.../wwwroot/js/pages/views.js | 4 +-
.../wwwroot/js/panels.js | 18 ++-
.../wwwroot/js/util.js | 136 ++++++++++++++++-
11 files changed, 636 insertions(+), 30 deletions(-)
create mode 100644 Darling/Darling.Tests/WebClampCellBehaviourTests.cs
create mode 100644 Darling/Darling.Tests/WebPartialWindowBehaviourTests.cs
create mode 100644 Darling/Darling.Tests/web-clamp-cell-harness.mjs
create mode 100644 Darling/Darling.Tests/web-partial-window-harness.mjs
diff --git a/Darling/Darling.Tests/JobHistoryPageTests.cs b/Darling/Darling.Tests/JobHistoryPageTests.cs
index 9ad0072e86..b2e6387402 100644
--- a/Darling/Darling.Tests/JobHistoryPageTests.cs
+++ b/Darling/Darling.Tests/JobHistoryPageTests.cs
@@ -355,7 +355,8 @@ public void ARowClickOpensTheWholeMessage_AndTheCellClampsIt()
Assert.Contains("el(\"div\", { class: \"jh-message\"", tabs, StringComparison.Ordinal);
var css = Wwwroot("css", "app.css");
- Assert.Matches("\\.jh-message \\{[^}]*min-width: 2[0-9]rem;[^}]*-webkit-line-clamp: 3;", css);
+ Assert.Matches(@"\.jh-message \{[^}]*min-width: 2[0-9]rem;", css);
+ Assert.Matches(@"\.clamp-text \{[^}]*text-overflow: ellipsis;[^}]*white-space: nowrap;", css); // one line (#5644 W11), was 3 lines
}
[Fact]
diff --git a/Darling/Darling.Tests/WebClampCellBehaviourTests.cs b/Darling/Darling.Tests/WebClampCellBehaviourTests.cs
new file mode 100644
index 0000000000..494ae1e03f
--- /dev/null
+++ b/Darling/Darling.Tests/WebClampCellBehaviourTests.cs
@@ -0,0 +1,142 @@
+/*
+ * 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.Linq;
+using System.Text.Json;
+using System.Text.RegularExpressions;
+using Xunit;
+using static Darling.Tests.RepoFile;
+
+namespace Darling.Tests;
+
+///
+/// A long text cell (a collector's note, error or output; a job's message) shows on one line and opens the rest on a click
+/// (#5644 W11): the rows were hundreds of pixels tall. These run the shipped panels.js and util.js under Node
+/// (web-clamp-cell-harness.mjs), plus pins on the column lists and the stylesheet. Skipped when Node is not installed,
+/// except the source pins.
+///
+public sealed class WebClampCellBehaviourTests
+{
+ private static JsonElement Run()
+ {
+ var psi = new ProcessStartInfo("node") { RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false };
+ psi.ArgumentList.Add(PathTo("Darling", "Darling.Tests", "web-clamp-cell-harness.mjs"));
+ psi.ArgumentList.Add(PathTo("Darling", "PerformanceMonitor.Darling.Service", "wwwroot", "js"));
+
+ Process proc;
+ try
+ {
+ proc = Process.Start(psi)!;
+ }
+ catch (Win32Exception)
+ {
+ Assert.Skip("Node is not installed, so the shipped table script cannot be run.");
+ return default;
+ }
+
+ using (proc)
+ {
+ var error = proc.StandardError.ReadToEndAsync();
+ var output = proc.StandardOutput.ReadToEnd().Trim();
+ if (!proc.WaitForExit(20000))
+ {
+ proc.Kill(entireProcessTree: true);
+ Assert.Fail("the clamp cell harness did not finish in 20 s");
+ }
+
+ Assert.True(proc.ExitCode == 0, "the clamp cell harness failed: " + error.Result);
+ using var doc = JsonDocument.Parse(output.Split('\n')[0]);
+ return doc.RootElement.Clone();
+ }
+ }
+
+ private static string Wwwroot(params string[] parts) =>
+ System.IO.File.ReadAllText(PathTo(new[] { "Darling", "PerformanceMonitor.Darling.Service", "wwwroot" }.Concat(parts).ToArray()));
+
+ [Fact]
+ public void AClampColumn_KeepsTheFullText_AndHasTheClampClass()
+ {
+ var r = Run();
+ Assert.Equal("clamp", r.GetProperty("noteTdClass").GetString());
+ Assert.Equal("wrap", r.GetProperty("plainTdClass").GetString());
+ Assert.True(r.GetProperty("longHasFullText").GetBoolean());
+ Assert.True(r.GetProperty("longTitleFull").GetBoolean());
+ }
+
+ [Fact]
+ public void ALongCell_OpensAndClosesOnAClick_WithoutActivatingTheRow()
+ {
+ var r = Run();
+ Assert.Equal("button", r.GetProperty("longRole").GetString());
+ Assert.Equal("false", r.GetProperty("longExpandedBefore").GetString());
+ var first = r.GetProperty("afterClick");
+ Assert.Equal("true", first.GetProperty("expanded").GetString());
+ Assert.True(first.GetProperty("open").GetBoolean());
+ Assert.True(first.GetProperty("stopped").GetBoolean());
+ Assert.Equal(0, first.GetProperty("rowClicks").GetInt32());
+ var second = r.GetProperty("afterSecondClick");
+ Assert.Equal("false", second.GetProperty("expanded").GetString());
+ Assert.False(second.GetProperty("open").GetBoolean());
+ }
+
+ [Fact]
+ public void EnterOpensTheCell_AndOtherKeysDoNot()
+ {
+ var r = Run();
+ var enter = r.GetProperty("afterEnter");
+ Assert.Equal("true", enter.GetProperty("expanded").GetString());
+ Assert.True(enter.GetProperty("prevented").GetBoolean());
+ Assert.True(enter.GetProperty("stopped").GetBoolean());
+ var other = r.GetProperty("otherKey");
+ Assert.Equal("true", other.GetProperty("expanded").GetString());
+ Assert.False(other.GetProperty("stopped").GetBoolean());
+ }
+
+ [Fact]
+ public void AShortCell_AnEmptyCell_AndANonExpandableCell_AreInert()
+ {
+ var r = Run();
+ var s = r.GetProperty("short");
+ Assert.Equal(JsonValueKind.Null, s.GetProperty("role").ValueKind);
+ Assert.Equal("short", s.GetProperty("text").GetString());
+ var n = r.GetProperty("notExpandable");
+ Assert.Equal(JsonValueKind.Null, n.GetProperty("role").ValueKind);
+ Assert.True(n.GetProperty("text").GetBoolean());
+ Assert.True(n.GetProperty("title").GetBoolean());
+ Assert.Equal("", r.GetProperty("empty").GetProperty("text").GetString());
+ Assert.Equal(JsonValueKind.Null, r.GetProperty("empty").GetProperty("title").ValueKind);
+ Assert.Equal("button", r.GetProperty("multiline").GetString());
+ }
+
+ [Fact]
+ public void TheCollectorsLongTextColumns_AndBothJobMessageColumns_AreClamped()
+ {
+ var tabs = Wwwroot("js", "pages", "server-tabs.js").ReplaceLineEndings("\n");
+ var columns = tabs[tabs.IndexOf("const COLLECTOR_COLUMNS = [", StringComparison.Ordinal)..];
+ columns = columns[..columns.IndexOf("\n];", StringComparison.Ordinal)];
+ foreach (var key in new[] { "run_time_note", "last_error", "note_summary", "recovered_note", "output_finding", "regression_finding", "message" })
+ Assert.Matches("key: \"" + key + "\"[^\\n]*clamp: true", columns);
+ Assert.DoesNotMatch("key: \"(last_error|note_summary|output_finding)\"[^\\n]*wrap: true", columns);
+
+ Assert.Matches("label: \"Message\", clamp: true, render: jobMessageCell", tabs);
+ var page = Wwwroot("js", "pages", "job-history.js");
+ Assert.Matches("label: \"Message\", clamp: true, render: messageCell", page);
+ }
+
+ [Fact]
+ public void TheStylesheet_ClipsAClampCellToOneLine_AndLetsAnOpenOneWrap()
+ {
+ var css = Wwwroot("css", "app.css");
+ Assert.Matches(@"\.clamp-text \{[^}]*overflow: hidden;[^}]*text-overflow: ellipsis;[^}]*white-space: nowrap;", css);
+ Assert.Matches(@"\.clamp-text\.open \{[^}]*white-space: pre-wrap;", css);
+ Assert.DoesNotMatch(@"\.jh-message \{[^}]*line-clamp", css);
+ }
+}
diff --git a/Darling/Darling.Tests/WebPartialWindowBehaviourTests.cs b/Darling/Darling.Tests/WebPartialWindowBehaviourTests.cs
new file mode 100644
index 0000000000..387c6d9291
--- /dev/null
+++ b/Darling/Darling.Tests/WebPartialWindowBehaviourTests.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.ComponentModel;
+using System.Diagnostics;
+using System.Text.Json;
+using Xunit;
+using static Darling.Tests.RepoFile;
+
+namespace Darling.Tests;
+
+///
+/// Every panel built from a row-capped read says so (#5644 W12): the System Events "system_health CPU" chart plotted only the
+/// newest 50 events under a "last 24 hours" title with no notice, and the Active Queries grid showed about an hour under the same
+/// title. These run the shipped util.js under Node (web-partial-window-harness.mjs). Skipped when Node is not installed.
+///
+public sealed class WebPartialWindowBehaviourTests
+{
+ private static JsonElement Run()
+ {
+ var psi = new ProcessStartInfo("node") { RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false };
+ psi.ArgumentList.Add(PathTo("Darling", "Darling.Tests", "web-partial-window-harness.mjs"));
+ psi.ArgumentList.Add(PathTo("Darling", "PerformanceMonitor.Darling.Service", "wwwroot", "js"));
+
+ Process proc;
+ try
+ {
+ proc = Process.Start(psi)!;
+ }
+ catch (Win32Exception)
+ {
+ Assert.Skip("Node is not installed, so the shipped page script cannot be run.");
+ return default;
+ }
+
+ using (proc)
+ {
+ var error = proc.StandardError.ReadToEndAsync();
+ var output = proc.StandardOutput.ReadToEnd().Trim();
+ if (!proc.WaitForExit(20000))
+ {
+ proc.Kill(entireProcessTree: true);
+ Assert.Fail("the partial window harness did not finish in 20 s");
+ }
+
+ Assert.True(proc.ExitCode == 0, "the partial window harness failed: " + error.Result);
+ using var doc = JsonDocument.Parse(output.Split('\n')[0]);
+ return doc.RootElement.Clone();
+ }
+ }
+
+ private static string? S(JsonElement r, string name) => r.GetProperty(name).ValueKind == JsonValueKind.Null ? null : r.GetProperty(name).GetString();
+
+ [Fact]
+ public void ARowCappedChart_DrawsTheSameNoticeAsItsGrid_WorkedForAChart()
+ {
+ var r = Run();
+ Assert.StartsWith("partial window: this grid shows only the newest rows", S(r, "gridServerNote"));
+ var chart = S(r, "chartServerNote")!;
+ Assert.StartsWith("partial window: this chart shows only the newest rows", chart);
+ Assert.Contains("The chart covers", chart);
+ Assert.DoesNotContain("grid", chart);
+ }
+
+ [Fact]
+ public void AChart_DoesNotDrawADataStartNote_BecauseItsAxisShowsTheEmptySpan()
+ {
+ var r = Run();
+ Assert.Null(S(r, "chartDataStart"));
+ Assert.Null(S(r, "chartWindowNoteOff"));
+ }
+
+ [Fact]
+ public void ACutNewestFirstPage_GetsANoticeWithItsCountsAndOldestRow()
+ {
+ var r = Run();
+ var oldest = S(r, "oldestLocal")!;
+ Assert.Equal("partial window: this grid shows only the newest 50 of 219 snapshots, back to " + oldest + ", because it stops at its row limit.", S(r, "activeGrid"));
+ Assert.StartsWith("partial window: this chart shows only the newest 50 of 219 snapshots", S(r, "activeChart"));
+ Assert.Equal("partial window: this grid shows only the newest rows, back to " + oldest + ", because it stops at its row limit.", S(r, "activeNoCounts"));
+ }
+
+ [Fact]
+ public void ACutPage_ThatNamesNoTime_OrIsNotCut_OrOwnsItsNote_GetsNothing()
+ {
+ var r = Run();
+ Assert.Null(S(r, "activeStat"));
+ Assert.Null(S(r, "notCut"));
+ Assert.Null(S(r, "rankedByOther"));
+ Assert.Null(S(r, "noOldest"));
+ Assert.Null(S(r, "ownNote"));
+ }
+
+ [Fact]
+ public void TheServersOwnNote_IsNotDrawnTwice()
+ {
+ var r = Run();
+ Assert.Contains("The window started at S.", S(r, "serverNoteWins"));
+ }
+
+ [Fact]
+ public void AnIsoInstantInASentence_PrintsInTheGridsLocalTimeFormat()
+ {
+ // #5644 W20: server messages and status lines carried raw UTC stamps beside local-time grids.
+ var r = Run();
+ var a = S(r, "isoLocal")!;
+ var b = S(r, "isoZLocal")!;
+ Assert.Equal("stored at " + a + " and not collected since " + b + ".", S(r, "sentence"));
+ Assert.Equal("last event " + a, S(r, "emptyStripText"));
+ Assert.Equal("since " + b, S(r, "noticeStripText"));
+ Assert.Equal("nothing to change 10/9/2026, 10:36:32 AM", S(r, "noStamp"));
+ Assert.Equal("null", S(r, "nonString"));
+ Assert.DoesNotContain("T", a);
+ }
+
+ [Fact]
+ public void ACappedPanelsHeading_DropsItsWindowLabel_AndAnyOtherKeepsIt()
+ {
+ var r = Run();
+ var oldest = S(r, "oldestLocal")!;
+ Assert.Equal("newest rows only, back to " + oldest, S(r, "subtitleActive"));
+ Assert.Equal("newest rows only, back to " + oldest, S(r, "subtitleServer"));
+ Assert.Null(S(r, "subtitleDataStart"));
+ Assert.Null(S(r, "subtitleCovered"));
+ Assert.Equal("Active Queries newest rows only, back to " + oldest, S(r, "headingCapped"));
+ Assert.Equal(" not the heading", S(r, "bodyUntouched"));
+ Assert.Equal("Active Queries last 24 hours", S(r, "headingCovered"));
+ Assert.Equal("No sub", S(r, "headingWithoutSub"));
+ }
+}
diff --git a/Darling/Darling.Tests/web-clamp-cell-harness.mjs b/Darling/Darling.Tests/web-clamp-cell-harness.mjs
new file mode 100644
index 0000000000..b0edee3eba
--- /dev/null
+++ b/Darling/Darling.Tests/web-clamp-cell-harness.mjs
@@ -0,0 +1,89 @@
+/* #5644 W11: runs the web dashboard's real shared table renderer (wwwroot/js/panels.js, VIZ.table) and the clampText helper
+ (util.js) on a small fake DOM, and prints what a long-text column drew as one line of JSON. WebClampCellBehaviourTests starts it as
+ node web-clamp-cell-harness.mjs
+ Only the DOM is a stand-in; panels.js and util.js are the shipped modules. */
+import { pathToFileURL } from "node:url";
+
+class FakeNode {
+ constructor(tag) {
+ this.tag = tag;
+ this.children = [];
+ this.attrs = {};
+ this.listeners = {};
+ this.parent = null;
+ this._text = "";
+ this.className = "";
+ this.dataset = {};
+ this.style = {};
+ const set = new Set();
+ this.classes = set;
+ this.classList = { add: (c) => set.add(c), remove: (c) => set.delete(c), toggle: (c, on) => (on ? set.add(c) : set.delete(c)), contains: (c) => set.has(c) };
+ }
+ get firstChild() { return this.children[0] || null; }
+ get textContent() { return this._text + this.children.map((c) => c.textContent).join(""); }
+ set textContent(v) { this._text = String(v); this.children = []; }
+ setAttribute(k, v) { this.attrs[k] = String(v); if (k === "class") this.className = String(v); }
+ getAttribute(k) { return this.attrs[k]; }
+ appendChild(c) { if (c.parent) c.parent.children = c.parent.children.filter((x) => x !== c); c.parent = this; this.children.push(c); return c; }
+ removeChild(c) { this.children = this.children.filter((x) => x !== c); c.parent = null; }
+ addEventListener(t, fn) { (this.listeners[t] ||= []).push(fn); }
+ contains(n) { for (let x = n; x; x = x.parent) if (x === this) return true; return false; }
+ fire(t, e = {}) {
+ const ev = { type: t, stopped: false, defaultPrevented: false, stopPropagation() { this.stopped = true; }, preventDefault() { this.defaultPrevented = true; }, ...e };
+ for (let n = this; n && !ev.stopped; n = n.parent) for (const fn of n.listeners[t] || []) fn(ev);
+ return ev;
+ }
+ findAll(pred, out = []) { if (pred(this)) out.push(this); this.children.forEach((c) => c.findAll(pred, out)); return out; }
+}
+class FakeText extends FakeNode { constructor(t) { super("#text"); this._text = t; } }
+globalThis.Node = FakeNode;
+globalThis.document = { createElement: (t) => new FakeNode(t), createTextNode: (t) => new FakeText(t) };
+globalThis.location = { hash: "#/server/a/health" };
+
+const root = process.argv[2];
+const { VIZ } = await import(pathToFileURL(root + "/panels.js").href);
+const { clampText } = await import(pathToFileURL(root + "/util.js").href);
+
+const longNote = "The collector read 0 items. " + "Nothing matched the filter on this server. ".repeat(20);
+const cols = [
+ { key: "collector", label: "Collector" },
+ { key: "note", label: "Note", clamp: true, plain: true },
+ { key: "plain", label: "Plain", wrap: true },
+];
+const rows = [
+ { collector: "a", note: longNote, plain: longNote },
+ { collector: "b", note: "short", plain: "short" },
+];
+const out = {};
+
+const t = VIZ.table({ rows }, { rowsKey: "rows", columns: cols, title: "Collectors", tools: false });
+const tr = t.findAll((n) => n.tag === "tr").filter((n) => n.parent && n.parent.tag === "tbody");
+let rowClicks = 0;
+tr[0].addEventListener("click", () => rowClicks++);
+const tds = (r) => r.children;
+const noteTd = tds(tr[0])[1];
+const clampOf = (td) => td.findAll((n) => String(n.className).includes("clamp-text"))[0];
+const longClamp = clampOf(noteTd);
+out.noteTdClass = noteTd.className;
+out.plainTdClass = tds(tr[0])[2].className;
+out.longHasFullText = longClamp.textContent === longNote;
+out.longTitleFull = longClamp.attrs.title === longNote;
+out.longRole = longClamp.attrs.role;
+out.longExpandedBefore = longClamp.attrs["aria-expanded"];
+const ev = longClamp.fire("click");
+out.afterClick = { expanded: longClamp.attrs["aria-expanded"], open: longClamp.classes.has("open"), stopped: ev.stopped, rowClicks };
+longClamp.fire("click");
+out.afterSecondClick = { expanded: longClamp.attrs["aria-expanded"], open: longClamp.classes.has("open") };
+const key = longClamp.fire("keydown", { key: "Enter" });
+out.afterEnter = { expanded: longClamp.attrs["aria-expanded"], prevented: key.defaultPrevented, stopped: key.stopped };
+const other = longClamp.fire("keydown", { key: "a" });
+out.otherKey = { expanded: longClamp.attrs["aria-expanded"], stopped: other.stopped };
+
+const shortClamp = clampOf(tds(tr[1])[1]);
+out.short = { role: shortClamp.attrs.role || null, tabindex: shortClamp.attrs.tabindex || null, text: shortClamp.textContent };
+const fixed = clampText(longNote, { expandable: false });
+out.notExpandable = { role: fixed.attrs.role || null, text: fixed.textContent === longNote, title: fixed.attrs.title === longNote };
+out.empty = { text: clampText(null).textContent, title: clampText(null).attrs.title || null };
+out.multiline = clampText("one\ntwo").attrs.role;
+
+console.log(JSON.stringify(out));
diff --git a/Darling/Darling.Tests/web-partial-window-harness.mjs b/Darling/Darling.Tests/web-partial-window-harness.mjs
new file mode 100644
index 0000000000..fa91114298
--- /dev/null
+++ b/Darling/Darling.Tests/web-partial-window-harness.mjs
@@ -0,0 +1,97 @@
+/* #5644 W12: runs the web dashboard's real windowFloorStrip, cappedReadNote, partialWindowSubtitle and markPartialSubtitle (wwwroot/js/util.js)
+ on a small fake DOM and prints what each drew as one line of JSON. WebPartialWindowBehaviourTests starts it as
+ node web-partial-window-harness.mjs
+ Only the DOM is a stand-in; util.js is the shipped module. */
+import { pathToFileURL } from "node:url";
+
+class FakeNode {
+ constructor(tag) {
+ this.tag = tag;
+ this.children = [];
+ this.attrs = {};
+ this.listeners = {};
+ this.parent = null;
+ this._text = "";
+ this.className = "";
+ this.dataset = {};
+ this.style = {};
+ }
+ get firstChild() { return this.children[0] || null; }
+ get textContent() { return this._text + this.children.map((c) => c.textContent).join(""); }
+ set textContent(v) { this._text = String(v); this.children = []; }
+ setAttribute(k, v) { this.attrs[k] = String(v); }
+ getAttribute(k) { return this.attrs[k]; }
+ appendChild(c) { if (c.parent) c.parent.children = c.parent.children.filter((x) => x !== c); c.parent = this; this.children.push(c); return c; }
+ removeChild(c) { this.children = this.children.filter((x) => x !== c); c.parent = null; }
+ addEventListener(t, fn) { (this.listeners[t] ||= []).push(fn); }
+}
+class FakeText extends FakeNode { constructor(t) { super("#text"); this._text = t; } }
+globalThis.Node = FakeNode;
+globalThis.document = { createElement: (t) => new FakeNode(t), createTextNode: (t) => new FakeText(t) };
+globalThis.location = { hash: "#/server/a/events" };
+
+const root = process.argv[2];
+const { windowFloorStrip, cappedReadNote, partialWindowSubtitle, markPartialSubtitle, localizeIsoText, emptyStrip, noticeStrip, el, localTime } = await import(pathToFileURL(root + "/util.js").href);
+
+const text = (n) => (n ? n.textContent : null);
+const OLDEST = "2026-10-12T13:29:59.5027090Z";
+const oldestLocal = localTime(OLDEST);
+const out = { oldestLocal };
+
+/* the server's own capped note (WebDataStartNote), as readTool leaves it: a sentence built for a grid */
+const serverCapped = {
+ window_truncated: true,
+ oldest_shown_utc: OLDEST,
+ truncation_note: "partial window: this grid shows only the newest rows, back to X, because it stops at its row limit. The window started at S. The grid covers X to E.",
+};
+out.gridServerNote = text(windowFloorStrip(serverCapped, { viz: "table" }));
+out.chartServerNote = text(windowFloorStrip(serverCapped, { viz: "line" }));
+/* a chart does not draw a plain data-start note: its axis shows the empty span */
+out.chartDataStart = text(windowFloorStrip({ window_truncated: true, data_start_utc: OLDEST, truncation_note: "partial window: this panel's data starts at X." }, { viz: "line" }));
+out.chartWindowNoteOff = text(windowFloorStrip(serverCapped, { viz: "line", windowNote: false }));
+
+/* the active-query page: cut at its limit, newest first, no window_truncated */
+const active = { truncated: true, total_snapshots: 219, snapshots_returned: 50, order: "collection_time_desc", oldest_returned_collection_time: OLDEST };
+out.activeGrid = text(windowFloorStrip(active, { viz: "table" }));
+out.activeChart = text(windowFloorStrip(active, { viz: "line" }));
+out.activeStat = text(windowFloorStrip(active, { viz: "stat" }));
+out.activeNoCounts = cappedReadNote({ truncated: true, order: "collection_time_desc", oldest_returned_collection_time: OLDEST }, { viz: "table" }).text;
+out.notCut = text(windowFloorStrip({ ...active, truncated: false }, { viz: "table" }));
+out.rankedByOther = text(windowFloorStrip({ ...active, order: "total_elapsed" }, { viz: "table" }));
+out.noOldest = text(windowFloorStrip({ truncated: true, order: "collection_time_desc" }, { viz: "table" }));
+out.serverNoteWins = text(windowFloorStrip({ ...active, ...serverCapped }, { viz: "table" }));
+out.ownNote = text(windowFloorStrip(active, { viz: "table", noteKey: "truncation_note" }));
+
+/* the heading: a capped panel drops its window label, any other keeps it */
+out.subtitleActive = partialWindowSubtitle(active, { viz: "table" });
+out.subtitleServer = partialWindowSubtitle(serverCapped, { viz: "line" });
+out.subtitleDataStart = partialWindowSubtitle({ window_truncated: true, data_start_utc: OLDEST, truncation_note: "x" }, { viz: "line" });
+out.subtitleCovered = partialWindowSubtitle({ truncated: false, rows: [] }, { viz: "table" });
+const panel = () => el("div", { class: "panel card" }, [
+ el("h3", {}, ["Active Queries", el("span", { class: "panel-sub", text: " last 24 hours" })]),
+ el("div", { class: "panel-body" }, [el("span", { class: "panel-sub", text: " not the heading" })]),
+]);
+const capped = panel();
+markPartialSubtitle(capped, active, { viz: "table" });
+out.headingCapped = capped.children[0].textContent;
+out.bodyUntouched = capped.children[1].textContent;
+const covered = panel();
+markPartialSubtitle(covered, { truncated: false }, { viz: "table" });
+out.headingCovered = covered.children[0].textContent;
+markPartialSubtitle(null, active, { viz: "table" });
+const noSub = el("div", { class: "panel card" }, [el("h3", {}, ["No sub"])]);
+markPartialSubtitle(noSub, active, { viz: "table" });
+out.headingWithoutSub = noSub.children[0].textContent;
+
+/* W20: ISO instants inside a sentence print in the grids local-time format */
+const iso = "2026-10-09T14:37:35.0158630";
+const isoZ = "2026-10-08T18:11:14Z";
+out.isoLocal = localTime(iso);
+out.isoZLocal = localTime(isoZ);
+out.sentence = localizeIsoText("stored at " + iso + " and not collected since " + isoZ + ".");
+out.noStamp = localizeIsoText("nothing to change 10/9/2026, 10:36:32 AM");
+out.nonString = String(localizeIsoText(null));
+out.emptyStripText = emptyStrip("last event " + iso).textContent;
+out.noticeStripText = noticeStrip("since " + isoZ).textContent;
+
+console.log(JSON.stringify(out));
diff --git a/Darling/PerformanceMonitor.Darling.Service/wwwroot/css/app.css b/Darling/PerformanceMonitor.Darling.Service/wwwroot/css/app.css
index 35886ef7f8..fce876122f 100644
--- a/Darling/PerformanceMonitor.Darling.Service/wwwroot/css/app.css
+++ b/Darling/PerformanceMonitor.Darling.Service/wwwroot/css/app.css
@@ -476,6 +476,11 @@ table.data th {
table.data tbody tr:nth-child(even) { background: var(--row-alt); }
table.data td.num { text-align: right; font-variant-numeric: tabular-nums; }
table.data td.wrap { white-space: normal; max-width: 620px; }
+/* A long text cell on one line, the rest on demand (#5644 W11). The full text stays in the node; the clip is only paint. */
+table.data td.clamp { max-width: 28rem; }
+.clamp-text { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.clamp-text[role="button"] { cursor: pointer; }
+.clamp-text.open { white-space: pre-wrap; overflow-wrap: anywhere; }
.plan-summary { display: flex; flex-wrap: wrap; gap: 0 1rem; margin: 4px 0; }
table.data td.pre { white-space: pre-wrap; }
table.data td.mono { font-family: var(--mono); font-size: 0.78rem; }
@@ -1313,7 +1318,7 @@ pre.code {
.locking-detail-counters dd { margin: 0; font-variant-numeric: tabular-nums; }
/* Job History (W2): the Message cell keeps a readable width and shows the first 3 lines; the row's detail pane holds the whole message. */
-.jh-message { min-width: 24rem; max-width: 40rem; display: -webkit-box; -webkit-line-clamp: 3; line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; white-space: pre-wrap; overflow-wrap: anywhere; }
+.jh-message { min-width: 24rem; max-width: 40rem; }
.jh-detail:empty { display: none; }
.jh-detail { background: var(--bg-dark); border: 1px solid var(--border); border-radius: 6px; padding: 10px 14px; margin: 8px 0; }
.jh-detail-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
diff --git a/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/job-history.js b/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/job-history.js
index f5120cf229..3dc5db84ca 100644
--- a/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/job-history.js
+++ b/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/job-history.js
@@ -25,7 +25,7 @@ import { VIZ } from "../panels.js";
import { pageRangePicker, windowOfSpec } from "../page-range.js";
import { relativeSpec } from "../time-range.js";
import { orderServers } from "../server-order.js";
-import { el, mount, clear, loadingStrip, emptyStrip, noticeStrip, errorStrip, readErrorStrip, readTool, readToolWithinKeptHistory, keptWindowStrip, localTime, makeActivatable } from "../util.js";
+import { el, mount, clear, loadingStrip, emptyStrip, noticeStrip, errorStrip, readErrorStrip, readTool, readToolWithinKeptHistory, keptWindowStrip, localTime, makeActivatable, clampText } from "../util.js";
/** The default range, in hours. The range itself is the shared time range picker's (#5562). */
export const DEFAULT_HOURS = 24;
@@ -53,15 +53,16 @@ export const JOB_HISTORY_COLUMNS = [
{ key: "duration_formatted", label: "Duration", sortValue: (r) => r.duration_seconds },
{ key: "retries", label: "Retries", format: "int" },
{ key: "last_success", label: "Last Success", format: "time" },
- { key: "message", label: "Message", wrap: true, render: messageCell },
+ { key: "message", label: "Message", clamp: true, render: messageCell },
];
/* The Message column (the morning walk, W2). A failed step's message can run to thousands of characters and sat in a
- column the table squeezed to about 90 px, so each row grew 150-250 px tall. The cell now has a readable width and
- shows the first few lines only; the whole message opens in the detail pane when the row is clicked. */
+ column the table squeezed to about 90 px, so each row grew 150-250 px tall. The cell has a readable width and is
+ clipped to ONE line (#5644 W11, was three), with the full text in the tooltip;
+ the whole message opens in the detail pane when the row is clicked. */
function messageCell(r) {
const text = r && r.message != null ? String(r.message) : "";
- return el("div", { class: "jh-message", title: text || null, text });
+ return el("div", { class: "jh-message" }, [clampText(text, { expandable: false })]);
}
/* A run has no id in the read, so the open row is found again after a rebuild by what identifies it. */
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 97cb90a3a9..068d696555 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, makeActivatable, readTool, readToolWithinKeptHistory, keptWindowStrip, windowFloorStrip, mount, truncate, loadingStrip, errorStrip, readErrorStrip, emptyStrip, disclosure, noticeStrip, getPath, fmtMs, fmtRate, localTime, parseUtc, windowFromHours, daysText, dbScopeChip, dbFilteredEmptyText, sourceStrip, getActiveDatabaseFilter, queryWaitFilter, setQueryWaitFilter, waitIsLinked } from "../util.js";
+import { el, makeActivatable, readTool, readToolWithinKeptHistory, keptWindowStrip, windowFloorStrip, markPartialSubtitle, mount, truncate, loadingStrip, errorStrip, readErrorStrip, emptyStrip, disclosure, noticeStrip, getPath, fmtMs, fmtRate, localTime, parseUtc, windowFromHours, daysText, dbScopeChip, dbFilteredEmptyText, sourceStrip, clampText, getActiveDatabaseFilter, queryWaitFilter, setQueryWaitFilter, waitIsLinked } from "../util.js";
import { renderPanel, setPanelSignal, getPanelSignal, VIZ, worstFirst, newestFirst } from "../panels.js";
import { zoomableLineChart, chartZoomScope, SERIES_COLORS, CATEGORICAL_COLORS } from "../charts.js";
import { multiPicker, mergeSeriesRows } from "../multi-picker.js";
@@ -233,6 +233,7 @@ function fanout(read, params, specs) {
opts.atTimeItem; Active Queries when it names none. */
const rendered = VIZ[spec.viz](res.data, { ...spec, read, windowHours: res.keptHours || (params && params.hours), atTime: spec.atTime === false || !params || !params.server ? null : { server: params.server, item: spec.atTimeItem || "queries" } });
+ markPartialSubtitle(shells[i].panel, res.data, spec);
mount(body, [keptWindowStrip(res), windowFloorStrip(res.data, spec), sourceStrip(res.data, spec), typeof note === "string" && note.trim() ? noticeStrip(note) : null, rendered]);
} catch (e) {
mount(body, errorStrip("Could not render this panel: " + (e && e.message ? e.message : String(e))));
@@ -4549,13 +4550,13 @@ const JOB_HISTORY_COLUMNS = [
{ key: "duration_formatted", label: "Duration", sortValue: (r) => r.duration_seconds },
{ key: "retries", label: "Retries", format: "int" },
{ key: "last_success", label: "Last Success", format: "time" },
- { key: "message", label: "Message", wrap: true, render: jobMessageCell },
+ { key: "message", label: "Message", clamp: true, render: jobMessageCell },
];
-/* The same squeeze as the Job History page's Message column (W2): a readable width, the first 3 lines, and the whole text on hover. */
+/* The same squeeze as the Job History page's Message column (W2): a readable width, ONE line (#5644 W11), and the rest on a click (this tab has no detail pane). */
function jobMessageCell(r) {
const text = r && r.message != null ? String(r.message) : "";
- return el("div", { class: "jh-message", title: text || null, text });
+ return el("div", { class: "jh-message" }, [clampText(text)]);
}
const PERFMON_COLUMNS = [
@@ -4887,8 +4888,8 @@ const COLLECTOR_COLUMNS = [
time. Each column is left out unless a row fills it, so a server with no run times shows none of them. */
{ key: "run_at", label: "Run at", hideWhenEmpty: true },
{ key: "next_run_utc", label: "Next run", format: "time", hideWhenEmpty: true },
- { key: "run_time_note", label: "Run time", wrap: true, hideWhenEmpty: true },
- { key: "last_error", label: "Last Error", wrap: true, plain: "named" },
+ { key: "run_time_note", label: "Run time", clamp: true, hideWhenEmpty: true },
+ { key: "last_error", label: "Last Error", clamp: true, plain: "named" },
/* #1837: what a NON-failing run reported (an enumeration that came back with 0 items). Blank for a
plainly healthy collector; the same column the two WPF grids carry, so the web view is not the one
Collection Health surface that still hides it. note_summary, not the raw last_note: it carries the
@@ -4896,24 +4897,24 @@ const COLLECTOR_COLUMNS = [
one, composed server-side from the shared formatter so this table cannot render it a third way.
A collector's label=value counts read as its latest NOTED run's ("latest run:" only when every run
carried them), never a window total. */
- { key: "note_summary", label: "Note", wrap: true, plain: true },
+ { key: "note_summary", label: "Note", clamp: true, plain: true },
/* #5646: a collector that recovered (its newest 3 runs succeeded after failures) is HEALTHY, and this says so with the
time of the last failure. Partial rows carry no note_summary, so the sentence rides in its own field. */
- { key: "recovered_note", label: "Recovered", wrap: true, plain: true, hideWhenEmpty: true },
+ { key: "recovered_note", label: "Recovered", clamp: true, plain: true, hideWhenEmpty: true },
/* #3017: which of the two zero-output readings a collector that spent and stored nothing is — read and
found nothing, or could not read. Blank whenever Rows is positive, for the same reason the Note
column is blank on a plainly healthy collector. Composed server-side from the shared formatter, so
this table cannot render the sentence a second way. */
- { key: "output_finding", label: "Output", wrap: true, plain: true },
+ { key: "output_finding", label: "Output", clamp: true, plain: true },
/* #4620: why a collector that stopped doing what it used to do reads WARNING. The Status column already
showed the floor, but no column showed the sentence behind it, so a regressed row read WARNING with
every other cell blank. Composed server-side from the shared formatter, like the two columns above. */
- { key: "regression_finding", label: "Regression", wrap: true, plain: true },
+ { key: "regression_finding", label: "Regression", clamp: true, plain: true },
/* #5249: a PostgreSQL collector this server's engine does not collect (a collector this engine kind can never run: an Aurora-only reader on stock PostgreSQL, the sampler on Aurora) has no log row,
so the server lists it with status not_collected and the sentence saying why. The status paints with the
neutral Unknown band, not an error one: a gate is not a fault. Left out unless a row has a message, so a
server with no such collector (and every SQL Server tab) shows no column of dashes. */
- { key: "message", label: "Why not collected", wrap: true, hideWhenEmpty: true, plain: true },
+ { key: "message", label: "Why not collected", clamp: true, hideWhenEmpty: true, plain: true },
];
const HEAVIEST_COLUMNS = [
diff --git a/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/views.js b/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/views.js
index 0e42a3f836..4b81c7ff9d 100644
--- a/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/views.js
+++ b/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/views.js
@@ -25,7 +25,7 @@
* descriptions) reaches the DOM through el()/textContent (R4 — never innerHTML).
*/
-import { el, mount, apiGetFleet, loadingStrip, errorStrip, emptyStrip, noticeStrip, relTime, localTime, fmtNum } from "../util.js";
+import { el, mount, apiGetFleet, loadingStrip, errorStrip, emptyStrip, noticeStrip, relTime, localTime, localizeIsoText, fmtNum } from "../util.js";
import { rollingHoursPicker } from "../page-range.js";
import { renderPanel, setPanelSignal, VIZ } from "../panels.js";
import { orderServers } from "../server-order.js";
@@ -803,7 +803,7 @@ function renderAlertCell(cell, index, readSet, sourceSet, scope, opts, limiter)
if (cell.type === "status") {
return el("div", { class: "notebook-panel panel card" }, [
el("h3", {}, [cell.title || "Status"]),
- el("div", { class: "panel-body" }, [el("span", { class: "fv", text: cell.status || "Unknown" })]),
+ el("div", { class: "panel-body" }, [el("span", { class: "fv", text: localizeIsoText(cell.status || "Unknown") })]),
]);
}
if (cell.type === "read") {
diff --git a/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/panels.js b/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/panels.js
index f8802410ad..a5ae8a0355 100644
--- a/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/panels.js
+++ b/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/panels.js
@@ -44,6 +44,9 @@ import {
dbScopeChip,
dbFilteredEmptyText,
sourceStrip,
+ clampText,
+ markPartialSubtitle,
+ localizeIsoText,
} from "./util.js";
import { zoomableLineChart, chartZoomScope, SERIES_COLORS } from "./charts.js";
import { plainText, plainTextNamed } from "./plain-text.js";
@@ -192,6 +195,8 @@ async function loadPanelBody(desc, body, signal) {
const moreNotes = (desc.moreNoteKeys || []).map((k) => getPath(res.data, k));
/* #4966: a grid says where its table's data starts when that is after the window's start (windowFloorStrip). */
const floor = windowFloorStrip(res.data, desc);
+ /* #5644 W12: a row-capped panel stops claiming the window it was asked for in its heading. */
+ markPartialSubtitle(body.parentNode, res.data, desc);
/* A narrowed read draws its chart over the hours it answered for, not the Range it was asked for (#2802).
A copy, so the caller's descriptor keeps the window it asked for. */
if (res.keptHours) desc = { ...desc, windowHours: res.keptHours };
@@ -233,7 +238,7 @@ export const VIZ = {
*/
const NO_FIELDS_MSG = "No fields configured — edit this view and run Auto-detect fields.";
-/* table: desc = { rowsKey, columns:[{key,label,format,align,wrap,mono,pre,sevKey,statusSev,sortable,sortValue,csv,copy,copyValue}], sortable, sortId, onRow(row, tr), rowClass(row)|string, tools:false }
+/* table: desc = { rowsKey, columns:[{key,label,format,align,wrap,clamp,mono,pre,sevKey,statusSev,sortable,sortValue,csv,copy,copyValue}], sortable, sortId, onRow(row, tr), rowClass(row)|string, tools:false }
Column-header sort (#4843). A header click cycles ascending -> descending -> the server's own order, with a ▲/▼
indicator and aria-sort; Enter and Space on the focused header do the same. The comparison reads the RAW row value
@@ -1211,7 +1216,8 @@ function cell(row, c) {
query-text expander. It owns its own content; wrap/mono classes still apply if the column asks for them. */
if (typeof c.render === "function") {
const rcls = [];
- if (c.wrap) rcls.push("wrap");
+ if (c.clamp) rcls.push("clamp");
+ else if (c.wrap) rcls.push("wrap");
if (c.mono) rcls.push("mono");
if (c.pre) rcls.push("pre");
if (typeof c.cellClass === "function") { const k = c.cellClass(row); if (k) rcls.push(k); }
@@ -1220,7 +1226,8 @@ function cell(row, c) {
const raw = getPath(row, c.key);
const cls = [];
if (isNumericCol(c)) cls.push("num");
- if (c.wrap) cls.push("wrap");
+ if (c.clamp) cls.push("clamp");
+ else if (c.wrap) cls.push("wrap");
if (c.mono) cls.push("mono");
if (c.pre) cls.push("pre");
if (c.sevKey) cls.push(sevClass(getPath(row, c.sevKey)));
@@ -1230,7 +1237,7 @@ function cell(row, c) {
"n/a (log service)" for the log file of a Hyperscale database). The server wrote the sentence; the page only
shows it in place of the bare em dash. */
const why = raw == null && c.nullKey ? getPath(row, c.nullKey) : null;
- const text =
+ const shown =
why != null && why !== ""
? String(why)
: typeof c.display === "function"
@@ -1244,6 +1251,9 @@ function cell(row, c) {
: c.plain
? plainText(String(raw))
: String(raw);
+ /* #5644 W20: a column with no format shows any ISO instant in its text (a bare timestamp or one inside a sentence) in local time, like the grids beside it. */
+ const text = c.format ? shown : localizeIsoText(shown);
+ if (c.clamp) return el("td", { class: cls.join(" ") || null }, [clampText(text)]);
return el("td", { class: cls.join(" ") || null, text });
}
diff --git a/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/util.js b/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/util.js
index dd6c69f97a..da50e01f0a 100644
--- a/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/util.js
+++ b/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/util.js
@@ -105,6 +105,46 @@ export function disclosure(summaryText, detail, opts = {}) {
return el("details", { class: "disclosure" }, [summary, el("div", { class: "disc-body" }, detail)]);
}
+/* Text longer than this many characters (or holding a line break) gets the expand toggle; shorter text fits a cell as is. */
+const CLAMP_MIN_CHARS = 60;
+
+/**
+ * A long text cell shown on ONE line (#5644 W11): the full text stays in the node (so a selection, the tooltip and a
+ * screen reader read all of it) and the stylesheet clips it with an ellipsis (`.clamp-text`); a click, or Enter/Space,
+ * opens the rest in place (`.open`) and closes it again. Long collector notes, errors and job messages made rows
+ * hundreds of pixels tall. Copy and CSV export read the row data, not this node, so they still give the full text.
+ * `expandable: false` is for a cell whose row already opens the whole text elsewhere (the Job History detail pane).
+ * The toggle stops its event so a clickable row (Collectors) is not also activated.
+ */
+export function clampText(text, opts = {}) {
+ const full = String(text == null ? "" : text);
+ const node = el("span", { class: "clamp-text", title: full || null, text: full });
+ const long = full.length > CLAMP_MIN_CHARS || /[\r\n]/.test(full);
+ if (opts.expandable === false || !long) return node;
+ node.setAttribute("role", "button");
+ node.setAttribute("tabindex", "0");
+ node.setAttribute("aria-expanded", "false");
+ const toggle = (e) => {
+ /* A click that ends a text selection inside the cell is the reader copying, not opening it. */
+ if (e && e.type === "click" && typeof window !== "undefined" && window.getSelection) {
+ const sel = window.getSelection();
+ if (sel && !sel.isCollapsed && String(sel).length > 0 && sel.anchorNode && node.contains(sel.anchorNode)) return;
+ }
+ if (e && e.stopPropagation) e.stopPropagation();
+ const open = node.getAttribute("aria-expanded") !== "true";
+ node.setAttribute("aria-expanded", open ? "true" : "false");
+ node.classList.toggle("open", open);
+ };
+ node.addEventListener("click", toggle);
+ node.addEventListener("keydown", (e) => {
+ if (e.key === "Enter" || e.key === " ") {
+ e.preventDefault();
+ toggle(e);
+ }
+ });
+ return node;
+}
+
/*
* #3031: a stable id for one of a rollup tile's text rows, so the tile's NUMBER can point at it.
*
@@ -135,11 +175,24 @@ export function errorStrip(message) {
return el("div", { class: "strip error", role: "alert" }, [message]);
}
export function emptyStrip(message) {
- return el("div", { class: "strip empty" }, [message]);
+ return el("div", { class: "strip empty" }, [localizeIsoText(message)]);
}
/** A non-fatal caveat about otherwise-good data (e.g. the #1665 partial-window notice). */
export function noticeStrip(message) {
- return el("div", { class: "strip notice", role: "status" }, [message]);
+ return el("div", { class: "strip notice", role: "status" }, [localizeIsoText(message)]);
+}
+
+/**
+ * Rewrites every ISO-8601 instant inside a sentence (a server message, a status line) into the grids' local-time format
+ * (#5644 W20): "stored at 2026-10-09T14:37:35.0158630" and "since 2026-10-12T19:18:38Z" printed raw UTC beside grids in local
+ * time. A stamp with no zone is UTC, as parseUtc reads it everywhere. Text with no such stamp, and a non-string, come back as given.
+ */
+export function localizeIsoText(text) {
+ if (typeof text !== "string") return text;
+ return text.replace(/\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(?:\.\d+)?(?:Z|[+-]\d\d:?\d\d)?/g, (m) => {
+ const d = parseUtc(m);
+ return d ? d.toLocaleString() : m;
+ });
}
/**
* The two collectors the blocking reads draw from (#5244): the blocked-process-report XE session when it holds a row in the window
@@ -248,12 +301,83 @@ export function keptWindowStrip(res) {
* `floorKey`, and the fields are read from there instead of from the top level. The note is text, never markup (R4).
*/
export function windowFloorStrip(data, desc) {
- if (!desc || desc.windowNote === false || (desc.viz !== "table" && desc.viz !== "stat")) return null;
+ if (!desc || desc.windowNote === false) return null;
+ const chart = desc.viz === "line";
+ if (desc.viz !== "table" && desc.viz !== "stat" && !chart) return null;
if (desc.noteKey === "truncation_note" || (desc.moreNoteKeys || []).includes("truncation_note")) return null;
const source = desc.floorKey ? getPath(data, desc.floorKey) : data;
- if (!source || source.window_truncated !== true) return null;
- const note = source.truncation_note;
- return typeof note === "string" && note.trim() ? noticeStrip(note) : null;
+ if (source && source.window_truncated === true) {
+ const note = source.truncation_note;
+ if (typeof note !== "string" || !note.trim()) return null;
+ if (!chart) return noticeStrip(note);
+ /* A chart draws only the row-capped note (#5644 W12): its time axis already shows the empty span of a data start that
+ falls after the window's start, but a chart built from the newest N rows plots nothing before its oldest row while
+ the axis and the title claim the whole window. That note names the oldest row shown (oldest_shown_utc). */
+ if (typeof source.oldest_shown_utc !== "string") return null;
+ return noticeStrip(note.replace("this grid shows", "this chart shows").replace("The grid covers", "The chart covers"));
+ }
+ const capped = cappedReadNote(data, desc);
+ return capped ? noticeStrip(capped.text) : null;
+}
+
+/*
+ * The partial-window facts of a read that stopped at its row limit (#5644 W12), or null. Two shapes carry them: the server's
+ * window-floor fields (`window_truncated` with `oldest_shown_utc`: the reads WebDataStartNote lists, rewritten by readTool), and a
+ * newest-first page that says it was cut (`truncated: true` with `oldest_returned_collection_time` and an `*_desc` order: the
+ * active-query page, whose cap of 50 rows covers about an hour of a "last 24 hours" panel). A cut that names no time (a list
+ * ranked by something other than time) is not a partial window, so it gets nothing. `{ text, oldest }`: the sentence for a
+ * strip above the panel, and the page's oldest instant.
+ */
+export function cappedReadNote(data, desc) {
+ if (!data || typeof data !== "object" || Array.isArray(data)) return null;
+ if (desc && (desc.windowNote === false || (desc.viz !== "table" && desc.viz !== "line"))) return null;
+ if (desc && (desc.noteKey === "truncation_note" || (desc.moreNoteKeys || []).includes("truncation_note"))) return null;
+ if (data.window_truncated === true) return null;
+ if (data.truncated !== true) return null;
+ const oldest = data.oldest_returned_collection_time;
+ if (typeof oldest !== "string" || !parseUtc(oldest)) return null;
+ if (!/_desc$|newest/.test(String(data.order == null ? "" : data.order))) return null;
+ const noun = desc && desc.viz === "line" ? "chart" : "grid";
+ const shown = Number(data.snapshots_returned);
+ const total = Number(data.total_snapshots);
+ const counts = shown > 0 && total > shown ? shown.toLocaleString() + " of " + total.toLocaleString() + " snapshots" : "rows";
+ return {
+ text: "partial window: this " + noun + " shows only the newest " + counts + ", back to " + localTime(oldest) + ", because it stops at its row limit.",
+ oldest,
+ };
+}
+
+/**
+ * The subtitle a row-capped panel gets in place of its window label (#5644 W12): "last 24 hours" over a page that reaches back
+ * one hour claimed a window the data does not cover. Null when the panel is not a row-capped one (the label stays).
+ */
+export function partialWindowSubtitle(data, desc) {
+ if (desc && desc.windowNote === false) return null;
+ if (data && typeof data === "object" && data.window_truncated === true) {
+ if (typeof data.oldest_shown_utc !== "string" || !parseUtc(data.oldest_shown_utc)) return null;
+ if (desc && desc.viz !== "table" && desc.viz !== "line") return null;
+ return "newest rows only, back to " + localTime(data.oldest_shown_utc);
+ }
+ const capped = cappedReadNote(data, desc);
+ return capped ? "newest rows only, back to " + localTime(capped.oldest) : null;
+}
+
+/** Replaces the window label in a panel's heading (its `.panel-sub` span) when the read was row-capped. A no-op otherwise. */
+export function markPartialSubtitle(panel, data, desc) {
+ const text = partialWindowSubtitle(data, desc);
+ if (!text || !panel) return;
+ const find = (node) => {
+ for (const c of Array.from(node.children || [])) {
+ const k = c.className || (typeof c.getAttribute === "function" ? c.getAttribute("class") : null);
+ if (k === "panel-sub") return c;
+ const deeper = find(c);
+ if (deeper) return deeper;
+ }
+ return null;
+ };
+ const head = Array.from(panel.children || [])[0];
+ const sub = head ? find(head) : null;
+ if (sub) sub.textContent = " " + text;
}
/**
From f84f5f19fdbd9d4a1f0d7739b089cf3675fa82c5 Mon Sep 17 00:00:00 2001
From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com>
Date: Mon, 12 Oct 2026 15:37:15 -0400
Subject: [PATCH 08/42] Pin the blocking wait count: one event per wait, SQL
and C# agree (#5644 W1, W2)
---
.../Darling.Tests/BlockingWaitCountTests.cs | 220 ++++++++++++++++++
1 file changed, 220 insertions(+)
create mode 100644 Darling/Darling.Tests/BlockingWaitCountTests.cs
diff --git a/Darling/Darling.Tests/BlockingWaitCountTests.cs b/Darling/Darling.Tests/BlockingWaitCountTests.cs
new file mode 100644
index 0000000000..1024eba566
--- /dev/null
+++ b/Darling/Darling.Tests/BlockingWaitCountTests.cs
@@ -0,0 +1,220 @@
+/*
+ * 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.Tasks;
+using Npgsql;
+using PerformanceMonitor.Alerting;
+using PerformanceMonitor.Common;
+using Xunit;
+
+namespace Darling.Tests;
+
+///
+/// #5644 W1/W2: one blocking event is one blocked session, blocker and wait start, not one blocked-process report.
+/// The reports below are built the way SQL Server writes them: one every 5 seconds while a session stays blocked,
+/// each carrying the wait time so far, with a few milliseconds of jitter between the event timestamp and the wait
+/// time (the wait start is derived, so it drifts by that much between reports of one wait).
+///
+public sealed class BlockingWaitCountTests
+{
+ private static readonly DateTime T0 = new(2026, 10, 9, 9, 0, 0, DateTimeKind.Unspecified);
+
+ /// The reports of one wait that began at : one every 5 s, wait times 5.0 s,
+ /// 10.0 s, ... with +/- 35 ms of jitter on the derived start.
+ internal static List OneWait(
+ int blocked, int blocker, DateTime start, int reports, string obj = "db.dbo.Users",
+ string source = BlockedProcessAlertRow.XeReportSource)
+ {
+ var rows = new List();
+ for (var k = 1; k <= reports; k++)
+ {
+ var jitter = ((k * 37) % 71) - 35;
+ var wait = (5000L * k) + 900;
+ rows.Add(new BlockedProcessAlertRow
+ {
+ EventTime = start.AddMilliseconds(wait + jitter),
+ DatabaseName = "db",
+ BlockedSpid = blocked,
+ BlockingSpid = blocker,
+ WaitTimeMs = wait,
+ LockMode = "X",
+ BlockedSqlText = "UPDATE Users SET x = 1",
+ BlockingSqlText = "BEGIN TRAN UPDATE Users",
+ ContentiousObject = obj,
+ Source = source,
+ });
+ }
+
+ return rows;
+ }
+
+ private static List Collapse(IEnumerable rows) =>
+ BlockingWaits.Collapse(rows, r => r.Source, r => r.BlockedSpid, r => r.BlockingSpid, r => r.EventTime, r => r.WaitTimeMs);
+
+ [Fact]
+ public void OneMinuteWait_ElevenReports_IsOneEvent_WithTheLongestWait()
+ {
+ var rows = OneWait(102, 100, T0, 11);
+
+ var waits = Collapse(rows);
+
+ var wait = Assert.Single(waits);
+ Assert.Equal(rows.Max(r => r.WaitTimeMs), wait.WaitTimeMs);
+ }
+
+ [Fact]
+ public void IssueShape_FortyThreeReportsOfFourWaits_AreFourEvents()
+ {
+ /* The audit's table: blocked 102, blocker 100, 11 + 9 + 9 + 6 reports. */
+ var rows = new List();
+ rows.AddRange(OneWait(102, 100, T0, 11));
+ rows.AddRange(OneWait(102, 100, T0.AddMinutes(10), 9));
+ rows.AddRange(OneWait(102, 100, T0.AddMinutes(20), 9));
+ rows.AddRange(OneWait(102, 100, T0.AddMinutes(30), 6));
+ Assert.Equal(35, rows.Count);
+
+ var waits = Collapse(rows);
+
+ Assert.Equal(4, waits.Count);
+ Assert.Equal(new[] { 55900L, 45900L, 45900L, 30900L }.OrderBy(x => x), waits.Select(w => w.WaitTimeMs).OrderBy(x => x));
+ }
+
+ [Fact]
+ public void ReportOrder_DoesNotChangeTheCount()
+ {
+ var rows = new List();
+ rows.AddRange(OneWait(102, 100, T0, 11));
+ rows.AddRange(OneWait(102, 100, T0.AddMinutes(5), 4));
+
+ Assert.Equal(2, Collapse(rows).Count);
+ Assert.Equal(2, Collapse(Enumerable.Reverse(rows)).Count);
+ Assert.Equal(2, Collapse(rows.OrderBy(r => r.WaitTimeMs % 7)).Count);
+ }
+
+ [Fact]
+ public void BackToBackWaits_OfTheSamePair_AreTwoEvents()
+ {
+ /* The second wait starts the instant the first one ends; its first report is 5 s later. */
+ var first = OneWait(102, 100, T0, 3);
+ var secondStart = first[^1].EventTime!.Value;
+ var second = OneWait(102, 100, secondStart, 3);
+
+ Assert.Equal(2, Collapse(first.Concat(second)).Count);
+ }
+
+ [Fact]
+ public void DifferentSessions_DifferentBlockers_AndDifferentSources_NeverMerge()
+ {
+ var rows = new List();
+ rows.AddRange(OneWait(102, 100, T0, 4));
+ rows.AddRange(OneWait(103, 100, T0, 4));
+ rows.AddRange(OneWait(102, 101, T0, 4));
+ rows.AddRange(OneWait(102, 100, T0, 4, source: BlockedProcessAlertRow.DmvSnapshotSource));
+
+ Assert.Equal(4, Collapse(rows).Count);
+ }
+
+ [Fact]
+ public void ARowWithNoEventTime_StaysAnEventOfItsOwn()
+ {
+ var rows = OneWait(102, 100, T0, 3);
+ rows.Add(new BlockedProcessAlertRow { BlockedSpid = 102, BlockingSpid = 100, WaitTimeMs = 9000, EventTime = null });
+
+ Assert.Equal(2, Collapse(rows).Count);
+ }
+
+ [Fact]
+ public void AlertValue_IsTheSumOfTheChainCounts_NotOneHigher()
+ {
+ /* W2's shape: 42 waits of one chain and 1 of another, 5 reports each. The old value counted rows and the
+ card showed "Blocking chain (x42)" beside it; now both come from BlockingWaitRows. */
+ var rows = new List();
+ for (var i = 0; i < 42; i++)
+ {
+ rows.AddRange(OneWait(200 + i, 100, T0.AddMinutes(i * 3), 5));
+ }
+
+ rows.AddRange(OneWait(900, 901, T0.AddHours(3), 5, obj: "db.dbo.Orders"));
+
+ var counted = AlertContextBuilders.BlockingWaitRows(rows, Array.Empty());
+ var context = AlertContextBuilders.BuildBlockingContext("srv", rows, Array.Empty());
+
+ Assert.Equal(43, counted.Count);
+ Assert.NotNull(context);
+ var shown = context!.Details
+ .Where(d => d.Heading.StartsWith("Blocking chain", StringComparison.Ordinal))
+ .Select(d => d.Heading == "Blocking chain" ? 1 : int.Parse(d.Heading[(d.Heading.IndexOf("(x", StringComparison.Ordinal) + 2)..^1], CultureInfo.InvariantCulture))
+ .ToList();
+ Assert.Contains(42, shown);
+ Assert.Equal(43, shown.Sum());
+ }
+
+ [Fact]
+ public async Task Engine_FiresOnTheWaitCount_AndTheValueMatchesTheCard()
+ {
+ var h = new AlertEngineTests.Harness();
+ h.Settings.BlockingEnabled = true;
+ h.Settings.BlockingCountThreshold = 1;
+ var engine = h.Build();
+
+ /* One long wait, eleven reports; before #5644 this fired with a value of 11. */
+ h.Adapter.Blocking.AddRange(OneWait(102, 100, T0, 11));
+ await engine.EvaluateServerAsync(AlertEngineTests.Harness.Snapshot());
+
+ var fired = Assert.Single(h.Deliverer.Outcomes);
+ Assert.Equal("1", fired.CurrentValue);
+ Assert.Equal(1, fired.NumericCurrentValue);
+ var chain = Assert.Single(fired.Context!.Details, d => d.Heading.StartsWith("Blocking chain", StringComparison.Ordinal));
+ Assert.Equal("Blocking chain", chain.Heading);
+ var waitRange = chain.Fields.Single(f => f.Label == "Wait Range").Value;
+ Assert.Contains("55", waitRange, StringComparison.Ordinal);
+ }
+
+ /* ---- the SQL form of the same rule (PostgreSQL): the fleet tile, the analysis fact and the summaries run it ---- */
+
+ private static string ValuesSource(IReadOnlyList rows, int server) =>
+ "SELECT " + server.ToString(CultureInfo.InvariantCulture) + " AS server_id, v.blocked_spid, v.blocking_spid, v.event_time, v.wait_time_ms FROM (VALUES "
+ + string.Join(",", rows.Select(r => string.Format(
+ CultureInfo.InvariantCulture, "({0},{1},TIMESTAMP '{2:yyyy-MM-dd HH:mm:ss.ffffff}',{3}::bigint)",
+ r.BlockedSpid, r.BlockingSpid, r.EventTime, r.WaitTimeMs)))
+ + ") AS v(blocked_spid, blocking_spid, event_time, wait_time_ms)";
+
+ [Fact]
+ public async Task PostgresSql_AgreesWithTheCSharpRule_OnTheSameReports()
+ {
+ var connectionString = Environment.GetEnvironmentVariable("DARLING_TEST_PG");
+ Assert.SkipWhen(string.IsNullOrWhiteSpace(connectionString), "Set DARLING_TEST_PG to run the live wait-count SQL test.");
+
+ var rows = new List();
+ rows.AddRange(OneWait(102, 100, T0, 11));
+ rows.AddRange(OneWait(102, 100, T0.AddMinutes(10), 9));
+ rows.AddRange(OneWait(102, 100, T0.AddMinutes(20), 9));
+ rows.AddRange(OneWait(102, 100, T0.AddMinutes(30), 6));
+ rows.AddRange(OneWait(103, 100, T0, 4));
+ rows.AddRange(OneWait(102, 101, T0.AddMinutes(1), 7));
+
+ var sql = "WITH " + BlockingWaits.WaitsCteSql(
+ BlockingWaits.SqlDialect.Postgres, "waits", ValuesSource(rows, 1), new[] { "server_id" })
+ + " SELECT COUNT(*), MAX(wait_time_ms) FROM waits";
+
+ await using var connection = new NpgsqlConnection(connectionString);
+ await connection.OpenAsync();
+ await using var command = new NpgsqlCommand(sql, connection);
+ await using var reader = await command.ExecuteReaderAsync();
+ Assert.True(await reader.ReadAsync());
+
+ var expected = Collapse(rows);
+ Assert.Equal(expected.Count, Convert.ToInt32(reader.GetValue(0), CultureInfo.InvariantCulture));
+ Assert.Equal(6, expected.Count);
+ Assert.Equal(expected.Max(w => w.WaitTimeMs), Convert.ToInt64(reader.GetValue(1), CultureInfo.InvariantCulture));
+ }
+}
From 7a7ac3deb54cd627a3f1ea07ea8d3c0c70841fcf Mon Sep 17 00:00:00 2001
From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com>
Date: Mon, 12 Oct 2026 15:37:52 -0400
Subject: [PATCH 09/42] Web UI audit lane F: strict integer query params,
changed-only restart banner, job history and plan corrections repeats (#5644)
---
.../DarlingMcpPlanCorrectionToolsTests.cs | 45 ++++++
.../Darling.Tests/DarlingWebEndpointsTests.cs | 79 ++++++++++
Darling/Darling.Tests/JobHistoryPageTests.cs | 7 +-
.../PlanCorrectionCollapseLiveTests.cs | 144 ++++++++++++++++++
.../ServerTabGridColumnPinTests.cs | 2 +-
.../DarlingWebEndpoints.cs | 49 +++++-
.../Mcp/DarlingMcpPlanCorrectionTools.cs | 36 ++++-
.../Mcp/DarlingPlanCorrectionReader.cs | 100 +++++++++++-
.../wwwroot/js/pages/job-history.js | 5 +-
.../wwwroot/js/pages/server-tabs.js | 7 +-
Lite.Tests/BaselineDiscontinuityTests.cs | 24 ++-
.../BaselineDiscontinuity.cs | 3 +-
PerformanceMonitor.Collectors/ServerEpoch.cs | 34 ++++-
13 files changed, 513 insertions(+), 22 deletions(-)
create mode 100644 Darling/Darling.Tests/PlanCorrectionCollapseLiveTests.cs
diff --git a/Darling/Darling.Tests/DarlingMcpPlanCorrectionToolsTests.cs b/Darling/Darling.Tests/DarlingMcpPlanCorrectionToolsTests.cs
index e446039abe..2e90667af9 100644
--- a/Darling/Darling.Tests/DarlingMcpPlanCorrectionToolsTests.cs
+++ b/Darling/Darling.Tests/DarlingMcpPlanCorrectionToolsTests.cs
@@ -7,6 +7,7 @@
*/
using System;
+using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
using System.Reflection;
@@ -93,6 +94,49 @@ the window's count. */
Assert.DoesNotContain("LIMIT 200", sql, StringComparison.Ordinal);
}
+ /// #5644 W21: the web's collapsed read keeps ONE row per recommendation (its newest capture), counts the captures
+ /// and reports the oldest, under the same window, scope, cap and database filter as the per-capture read.
+ [Fact]
+ public void PlanCorrectionsCollapsedSql_OneRowPerRecommendation_NewestCapture_WithFirstSeenAndCount()
+ {
+ var sql = DarlingPlanCorrectionReader.PlanCorrectionsCollapsedSql;
+
+ SqlTextPin.AssertExpresses("PARTITION BY database_name, recommendation_name ORDER BY collection_time DESC", sql,
+ "the newest capture of each recommendation is no longer the one row kept");
+ SqlTextPin.AssertExpresses("MIN(collection_time) OVER (PARTITION BY database_name, recommendation_name) AS first_seen", sql,
+ "the recommendation's first capture is no longer reported");
+ SqlTextPin.AssertExpresses("COUNT(*) OVER (PARTITION BY database_name, recommendation_name) AS capture_count", sql,
+ "the capture count is no longer reported");
+ SqlTextPin.AssertExpresses("WHERE newest_rank = 1", sql, "every capture, not one per recommendation, comes back");
+ SqlTextPin.AssertExpresses("recommendation_name IS NOT NULL", sql, "enablement-only rows come back as phantom recommendations");
+ SqlTextPin.AssertExpresses("WHERE server_id = $1", sql, "the read is not scoped to the requested server");
+ SqlTextPin.AssertExpresses("collection_time >= $2", sql, "the window's start no longer bounds the read");
+ SqlTextPin.AssertExpresses("collection_time <= $3", sql, "the window's end no longer bounds the read");
+ SqlTextPin.AssertExpresses("($5::text[] IS NULL OR database_name = ANY($5))", sql, "the database filter no longer narrows the read");
+ SqlTextPin.AssertExpresses("ORDER BY collection_time DESC, score DESC", sql, "the newest recommendations no longer come first");
+ SqlTextPin.AssertExpresses("LIMIT $4", sql, "the row cap is no longer the caller's limit");
+ }
+
+ /// #5644 W21: the web-only keys are added to the serialized rows, and the MCP tool's own call never reaches them.
+ [Fact]
+ public void WithSeenKeys_AddsFirstSeenLastSeenAndCount_ToEachRow()
+ {
+ var newest = new DateTime(2026, 10, 12, 8, 5, 0);
+ var oldest = new DateTime(2026, 10, 11, 8, 5, 0);
+ var page = new List
+ {
+ new(newest, "db", null, "Active", null, null, 80, null, 7, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, oldest, 17),
+ };
+
+ var array = DarlingMcpPlanCorrectionTools.WithSeenKeys(new object[] { new { database_name = "db", query_id = 7 } }, page);
+
+ var row = array[0]!.AsObject();
+ Assert.Equal("db", (string?)row["database_name"]);
+ Assert.Equal(newest.ToString("o"), (string?)row["last_seen"]);
+ Assert.Equal(oldest.ToString("o"), (string?)row["first_seen"]);
+ Assert.Equal(17L, (long?)row["capture_count"]);
+ }
+
[Fact]
public void AutomaticTuningSql_NewestCaptureOnly_OneRowPerDatabase()
{
@@ -114,6 +158,7 @@ public void AutomaticTuningSql_NewestCaptureOnly_OneRowPerDatabase()
[Theory]
[InlineData(nameof(DarlingPlanCorrectionReader.PlanCorrectionsSql))]
+ [InlineData(nameof(DarlingPlanCorrectionReader.PlanCorrectionsCollapsedSql))]
[InlineData(nameof(DarlingPlanCorrectionReader.AutomaticTuningSql))]
public void Reads_ArePostgresDialect_NoTsqlIsms(string sqlName)
{
diff --git a/Darling/Darling.Tests/DarlingWebEndpointsTests.cs b/Darling/Darling.Tests/DarlingWebEndpointsTests.cs
index 342ee956af..c02c1c2f28 100644
--- a/Darling/Darling.Tests/DarlingWebEndpointsTests.cs
+++ b/Darling/Darling.Tests/DarlingWebEndpointsTests.cs
@@ -464,6 +464,85 @@ public void ClassifyToolResponse_TheResolversRegistryReadFault_IsAServerError()
public void ParseInt_DefaultsOnMissOrGarbage(string? raw, int expected) =>
Assert.Equal(expected, DarlingWebEndpoints.ParseInt(raw, 10));
+ ///
+ /// #5644 W22: a present, non-integer integer knob is refused (the 400 envelope), not read as the default, so
+ /// ?hours=abc and ?hours=1.5 no longer answer 200 for a different window than the one asked for.
+ /// Absent and empty knobs still take the default; a whole number (even out of range) is the tool's to judge.
+ ///
+ [Theory]
+ [InlineData("hours", "abc")]
+ [InlineData("hours", "1.5")]
+ [InlineData("hours", "1e2")]
+ [InlineData("hours_back", "24h")]
+ [InlineData("limit", "ten")]
+ [InlineData("top", "5.0")]
+ [InlineData("days_back", "x")]
+ [InlineData("bucket_minutes", "five")]
+ [InlineData("worst_count", "-")]
+ public void NonIntegerParamRefusal_RefusesAPresentNonInteger(string key, string raw)
+ {
+ var refusal = DarlingWebEndpoints.NonIntegerParamRefusal(k => k == key ? raw : null);
+
+ Assert.NotNull(refusal);
+ Assert.True(McpHelpers.IsRefusalEnvelope(refusal));
+ var envelope = JsonNode.Parse(refusal)!;
+ Assert.Equal("invalid", (string?)envelope["status"]);
+ Assert.Equal(key, (string?)envelope["hints"]!["parameter"]);
+ Assert.Equal($"Invalid {key} value '{raw}'. Must be a whole number.", (string?)envelope["message"]);
+ }
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ [InlineData("24")]
+ [InlineData("-5")] // readable: the tool owns the range refusal ("Must be a positive integer")
+ [InlineData("500")]
+ [InlineData("+7")]
+ public void NonIntegerParamRefusal_LeavesAbsentEmptyAndWholeNumbersToTheTool(string? raw)
+ {
+ foreach (var key in DarlingWebEndpoints.IntegerQueryKeys)
+ {
+ Assert.Null(DarlingWebEndpoints.NonIntegerParamRefusal(k => k == key ? raw : null));
+ }
+ }
+
+ [Fact]
+ public void NonIntegerParamRefusal_ThroughTheQueryString_ReadsTheFirstNonEmptyValue()
+ {
+ var context = new Microsoft.AspNetCore.Http.DefaultHttpContext();
+ context.Request.QueryString = new Microsoft.AspNetCore.Http.QueryString("?server_name=SQL1&hours=abc");
+ Assert.NotNull(DarlingWebEndpoints.NonIntegerParamRefusal(k => DarlingWebEndpoints.First(context, k)));
+
+ context.Request.QueryString = new Microsoft.AspNetCore.Http.QueryString("?hours=&hours=12");
+ Assert.Null(DarlingWebEndpoints.NonIntegerParamRefusal(k => DarlingWebEndpoints.First(context, k)));
+ }
+
+ ///
+ /// #5644 W22 source pin: the /api/read dispatch loop and /api/fleet both call the check BEFORE any tool or reader
+ /// runs, and every key the dispatch reads through QueryInt/Rows/Hours is in the list.
+ ///
+ [Fact]
+ public void NonIntegerParamRefusal_RunsBeforeEveryReadAndCoversEveryIntegerKnob()
+ {
+ var source = RepoFile.ReadRepoFile("Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs");
+
+ Assert.Equal(2, System.Text.RegularExpressions.Regex.Matches(source, @"NonIntegerParamRefusal\(context\) is \{ \}").Count);
+ Assert.True(source.IndexOf("NonIntegerParamRefusal(context) is { } nonIntegerRefusal", StringComparison.Ordinal)
+ < source.IndexOf("result = await handler(context, postgres, analysis);", StringComparison.Ordinal));
+
+ var keys = new HashSet(StringComparer.Ordinal);
+ foreach (System.Text.RegularExpressions.Match m in System.Text.RegularExpressions.Regex.Matches(
+ source, @"(?:QueryInt\((?:c|context), ""|Rows\(c, "")([a-z_]+)"""))
+ {
+ keys.Add(m.Groups[1].Value);
+ }
+
+ keys.Add("hours");
+ keys.Add("hours_back");
+ Assert.NotEmpty(keys);
+ Assert.All(keys, k => Assert.Contains(k, DarlingWebEndpoints.IntegerQueryKeys));
+ }
+
[Theory]
[InlineData("true", false, true)]
[InlineData("false", true, false)]
diff --git a/Darling/Darling.Tests/JobHistoryPageTests.cs b/Darling/Darling.Tests/JobHistoryPageTests.cs
index 9ad0072e86..e9ac89101e 100644
--- a/Darling/Darling.Tests/JobHistoryPageTests.cs
+++ b/Darling/Darling.Tests/JobHistoryPageTests.cs
@@ -73,7 +73,7 @@ public void TheColumnKeysAreTheOnesTheToolEmits()
{
var source = ReadRepoFile("Darling", "PerformanceMonitor.Darling.Service", "Mcp", "DarlingMcpJobTools.cs");
var keys = Regex.Matches(Page(), "\\{ key: \"([a-z_]+)\"").Select(m => m.Groups[1].Value).ToList();
- Assert.Equal(10, keys.Count);
+ Assert.Equal(9, keys.Count);
foreach (var key in keys)
Assert.Matches("(?m)^\\s+" + Regex.Escape(key) + " = ", source.ReplaceLineEndings("\n"));
}
@@ -149,8 +149,9 @@ public void TheFirstBuildReadsTheFleetWithTheDefaultsAndTheDesktopColumns()
Assert.Equal("100", q.GetProperty("limit").GetString());
Assert.False(q.TryGetProperty("server", out _));
Assert.True(call.GetProperty("signal").GetBoolean());
- Assert.Equal("Run Time,Server,Job,Category,Step,Status,Duration,Retries,Last Success,Message", string.Join(",", Strings(run.GetProperty("columns"))));
- Assert.Equal("Run Time,Server,Job,Category,Step,Status,Duration,Retries,Last Success,Message", string.Join(",", Strings(first.GetProperty("headers"))));
+ /* #5644 W21: no Last Success column; it was the job's current last success repeated on every run row. */
+ Assert.Equal("Run Time,Server,Job,Category,Step,Status,Duration,Retries,Message", string.Join(",", Strings(run.GetProperty("columns"))));
+ Assert.Equal("Run Time,Server,Job,Category,Step,Status,Duration,Retries,Message", string.Join(",", Strings(first.GetProperty("headers"))));
Assert.Equal(2, first.GetProperty("bodyRows").GetInt32());
Assert.Equal(",srv-a,srv-b", string.Join(",", Strings(first.GetProperty("servers"))));
Assert.Equal(",Backup,Maintenance", string.Join(",", Strings(first.GetProperty("categories"))));
diff --git a/Darling/Darling.Tests/PlanCorrectionCollapseLiveTests.cs b/Darling/Darling.Tests/PlanCorrectionCollapseLiveTests.cs
new file mode 100644
index 0000000000..5f88b3f3b0
--- /dev/null
+++ b/Darling/Darling.Tests/PlanCorrectionCollapseLiveTests.cs
@@ -0,0 +1,144 @@
+/*
+ * 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.Threading;
+using System.Threading.Tasks;
+using Npgsql;
+using PerformanceMonitor.Common;
+using PerformanceMonitor.Darling.Service.Mcp;
+using PerformanceMonitor.Darling.Storage;
+using Xunit;
+
+namespace Darling.Tests;
+
+///
+/// #5644 W21: the web's Plan Corrections read collapses the collector's per-cycle re-captures to one row per
+/// recommendation. Three recommendations captured five times each: the per-capture read returns 15 rows, the
+/// collapsed read returns 3, each at its newest capture with its first capture and its capture count. The cap then
+/// bounds DISTINCT recommendations.
+///
+/// Plants fifteen rows under a per-run server id and deletes them in a finally through
+/// , creating and dropping no relations.
+///
+[Collection("live-postgres")]
+public sealed class PlanCorrectionCollapseLiveTests
+{
+ private const string ServerName = "darling-5644-plan-correction-collapse";
+ private static readonly int ServerId = ServerIdHelper.GetDeterministicHashCode(ServerName);
+
+ private const int Recommendations = 3;
+ private const int Captures = 5;
+
+ private static string? ConnectionString => Environment.GetEnvironmentVariable("DARLING_TEST_PG");
+
+ [Fact]
+ public async Task RepeatedCaptures_CollapseToOneRowPerRecommendation_WithFirstSeenAndCount()
+ {
+ var cs = ConnectionString;
+ Assert.SkipWhen(string.IsNullOrEmpty(cs), "Set DARLING_TEST_PG to a Postgres connection string to run the live collapse test.");
+
+ var ct = TestContext.Current.CancellationToken;
+ using var connection = new NpgsqlConnection(cs);
+ await connection.OpenAsync(ct);
+ await PgMigrations.MigrateAsync(connection, ct);
+ await DeleteRowsAsync(connection, ct);
+
+ await using var postgres = NpgsqlDataSource.Create(cs!);
+ var bodySucceeded = false;
+
+ try
+ {
+ /* Whole seconds, so the planted and returned instants compare for exact equality. */
+ var newest = DateTime.SpecifyKind(
+ new DateTime(DateTime.UtcNow.AddMinutes(-5).Ticks / TimeSpan.TicksPerSecond * TimeSpan.TicksPerSecond), DateTimeKind.Unspecified);
+ var oldest = newest.AddMinutes(-5 * (Captures - 1));
+
+ for (var recommendation = 1; recommendation <= Recommendations; recommendation++)
+ {
+ for (var capture = 0; capture < Captures; capture++)
+ {
+ await PlantAsync(connection, oldest.AddMinutes(5 * capture), recommendation, capture, ct);
+ }
+ }
+
+ var start = newest.AddHours(-1);
+ var end = newest.AddHours(1);
+
+ var perCapture = await DarlingPlanCorrectionReader.GetPlanCorrectionsAsync(
+ postgres, ServerId, start, end, cap: 50, cancellationToken: ct);
+ Assert.Equal(Recommendations * Captures, perCapture.Count);
+ Assert.All(perCapture, r => Assert.Null(r.CaptureCount));
+
+ var collapsed = await DarlingPlanCorrectionReader.GetCollapsedPlanCorrectionsAsync(
+ postgres, ServerId, start, end, cap: 50, cancellationToken: ct);
+
+ Assert.Equal(Recommendations, collapsed.Count);
+ Assert.All(collapsed, r =>
+ {
+ Assert.Equal(newest, r.CollectionTime);
+ Assert.Equal(oldest, r.FirstSeen);
+ Assert.Equal(Captures, r.CaptureCount);
+ /* The newest capture's own fields, not the oldest's: the state advanced with each capture. */
+ Assert.Equal("State" + (Captures - 1), r.RecommendationState);
+ });
+ Assert.Equal(new long?[] { 1, 2, 3 }, collapsed.Select(r => r.QueryId).OrderBy(q => q).ToArray());
+
+ /* The cap bounds distinct recommendations: a cap of 2 returns 2 of the 3, not 2 captures of one. */
+ var capped = await DarlingPlanCorrectionReader.GetCollapsedPlanCorrectionsAsync(
+ postgres, ServerId, start, end, cap: 2, cancellationToken: ct);
+ Assert.Equal(2, capped.Count);
+ Assert.Equal(2, capped.Select(r => r.QueryId).Distinct().Count());
+
+ /* A window that holds only the last two captures counts those two, and first_seen is the oldest in the window. */
+ var narrow = await DarlingPlanCorrectionReader.GetCollapsedPlanCorrectionsAsync(
+ postgres, ServerId, newest.AddMinutes(-5), end, cap: 50, cancellationToken: ct);
+ Assert.Equal(Recommendations, narrow.Count);
+ Assert.All(narrow, r =>
+ {
+ Assert.Equal(2, r.CaptureCount);
+ Assert.Equal(newest.AddMinutes(-5), r.FirstSeen);
+ });
+
+ bodySucceeded = true;
+ }
+ finally
+ {
+ await LiveStoreCleanup.RunAsync(cs!, bodySucceeded, async (cleanup, cleanupCt) =>
+ await DeleteRowsAsync(cleanup, cleanupCt));
+ }
+ }
+
+ private static async Task PlantAsync(NpgsqlConnection connection, DateTime collectionTime, int recommendation, int capture, CancellationToken ct)
+ {
+ using var command = new NpgsqlCommand(
+ """
+ INSERT INTO plan_correction
+ (collection_id, collection_time, server_id, server_name, database_name, recommendation_name,
+ recommendation_state, query_id, score)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
+ """, connection);
+ command.Parameters.AddWithValue((long)(recommendation * 100 + capture));
+ command.Parameters.AddWithValue(collectionTime);
+ command.Parameters.AddWithValue(ServerId);
+ command.Parameters.AddWithValue(ServerName);
+ command.Parameters.AddWithValue("collapse_probe_db");
+ command.Parameters.AddWithValue("PlanRegression_" + recommendation);
+ command.Parameters.AddWithValue("State" + capture);
+ command.Parameters.AddWithValue((long)recommendation);
+ command.Parameters.AddWithValue(70 + recommendation);
+ await command.ExecuteNonQueryAsync(ct);
+ }
+
+ private static async Task DeleteRowsAsync(NpgsqlConnection connection, CancellationToken ct)
+ {
+ using var cleanup = new NpgsqlCommand($"DELETE FROM plan_correction WHERE server_id = {ServerId};", connection);
+ await cleanup.ExecuteNonQueryAsync(ct);
+ }
+}
diff --git a/Darling/Darling.Tests/ServerTabGridColumnPinTests.cs b/Darling/Darling.Tests/ServerTabGridColumnPinTests.cs
index 64ff87ede3..38f9bd0ea8 100644
--- a/Darling/Darling.Tests/ServerTabGridColumnPinTests.cs
+++ b/Darling/Darling.Tests/ServerTabGridColumnPinTests.cs
@@ -37,7 +37,7 @@ private static void EveryKeyIsReturnedBy(string constant, string toolFile, param
var tool = Tool(toolFile);
var cols = Columns(constant);
foreach (var (key, _) in cols)
- Assert.True(Regex.IsMatch(tool, @"\b" + key + @"\s*="), $"{constant}: '{key}' is not a field of {toolFile}");
+ Assert.True(Regex.IsMatch(tool, @"\b" + key + @"(\s*=|""\]\s*=)"), $"{constant}: '{key}' is not a field of {toolFile}");
foreach (var key in expected)
Assert.Contains(cols, c => c.Key == key);
Assert.Equal(cols.Count, cols.Select(c => c.Key).Distinct().Count());
diff --git a/Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs b/Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs
index 448e6b7640..27a34bd5e8 100644
--- a/Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs
+++ b/Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs
@@ -342,6 +342,12 @@ makes it the only one that can answer when the store IS the problem — so it re
/* The pre-banded fleet roll-up (also surfaced as the get_fleet_overview MCP tool). */
app.MapGet("/api/fleet", async (HttpContext context) =>
{
+ /* #5644 W22: a present, non-integer window or count is refused like an out-of-range one. */
+ if (NonIntegerParamRefusal(context) is { } fleetRefusal)
+ {
+ return ToHttpResult(fleetRefusal, DarlingWebFailureLog.RouteOf(context.Request), logger, 0, context.RequestAborted);
+ }
+
var hours = Math.Clamp(QueryInt(context, "hours_back", "hours", DefaultFleetHours), 1, 168);
var worstCount = Math.Max(0, QueryInt(context, "worst_count", null, DarlingFleetReader.DefaultWorstCount));
var now = DateTime.UtcNow;
@@ -390,6 +396,14 @@ makes it the only one that can answer when the store IS the problem — so it re
readScope.Scope.CaptureStatements = true;
try
{
+ /* #5644 W22: ?hours=abc / ?hours=1.5 (or any other integer knob) was read as the default and answered
+ 200 for a different window than the one asked for, while ?hours=500 got a 400. Refused up front now,
+ before any tool runs, in the same envelope the range refusals use. */
+ if (NonIntegerParamRefusal(context) is { } nonIntegerRefusal)
+ {
+ return ToHttpResult(nonIntegerRefusal, DarlingWebFailureLog.RouteOf(context.Request), logger, stopwatch.ElapsedMilliseconds, context.RequestAborted);
+ }
+
/* A newest-first capped read is judged against the window it read: with no anchor sent, the end is
taken once here and handed to both the read and the note check. A request with its own anchor
is left as sent. */
@@ -5584,7 +5598,7 @@ budget cut does not silently shrink what the viewer renders. */
web viewer has always shown the whole query text. The row pins its OWN default to true so
#4198's MCP-side budget cut does not silently truncate what the viewer renders. */
["get_plan_corrections"] = (c, pg, an) => DatabaseNames(c) is { } databases
- ? DarlingMcpPlanCorrectionTools.GetPlanCorrections(pg, Server(c), Hours(c, 24), Rows(c, "limit", 50), AsOf(c), QueryBool(c, "full_text", true), databases, null, c.RequestAborted)
+ ? DarlingMcpPlanCorrectionTools.GetPlanCorrections(pg, Server(c), Hours(c, 24), Rows(c, "limit", 50), AsOf(c), QueryBool(c, "full_text", true), databases, null, c.RequestAborted, collapse: true)
: DatabaseNamesRefusal(c),
/* ── config (current + history) ── */
@@ -6422,6 +6436,39 @@ private static Task UnparseableParam(string key, string? expected = null
Task.FromResult(McpHelpers.Refusal(key, $"Invalid value for parameter '{key}'. {expected ?? "Expected a number."}"));
+ ///
+ /// Every query-string knob the dispatch reads as a whole number through / /
+ /// (#5644 W22). reads an unreadable one as the default, so a window or row
+ /// count the caller got wrong was answered, with a 200, for a different one than the one asked for.
+ ///
+ internal static readonly string[] IntegerQueryKeys =
+ [
+ "hours", "hours_back", "days_back", "trend_hours_back", "baseline_hours_back", "limit", "top",
+ "bucket_minutes", "min_dop", "worst_count"
+ ];
+
+ ///
+ /// The 400 envelope for the first knob that is present but not a whole number
+ /// (?hours=abc, ?hours=1.5), else null. An absent or empty knob still takes the tool's default, and a number out of
+ /// range is left to the tool, which owns that refusal. Pure over the lookup, so a test pins it without a server.
+ ///
+ internal static string? NonIntegerParamRefusal(Func lookup)
+ {
+ foreach (var key in IntegerQueryKeys)
+ {
+ var raw = lookup(key);
+ if (!string.IsNullOrEmpty(raw) && !int.TryParse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture, out _))
+ {
+ return McpHelpers.Refusal(key, $"Invalid {key} value '{McpHelpers.Truncate(raw, 40)}'. Must be a whole number.");
+ }
+ }
+
+ return null;
+ }
+
+ private static string? NonIntegerParamRefusal(HttpContext context) =>
+ NonIntegerParamRefusal(key => First(context, key));
+
private static int QueryInt(HttpContext context, string key, string? aliasKey, int def) =>
ParseInt(First(context, key) ?? (aliasKey is null ? null : First(context, aliasKey)), def);
diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpPlanCorrectionTools.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpPlanCorrectionTools.cs
index 532bd7372e..c95fd73e94 100644
--- a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpPlanCorrectionTools.cs
+++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpPlanCorrectionTools.cs
@@ -7,9 +7,11 @@
*/
using System;
+using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
using System.Text.Json;
+using System.Text.Json.Nodes;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
@@ -43,6 +45,28 @@ public sealed class DarlingMcpPlanCorrectionTools
///
private const int QueryTextPreviewLength = 150;
+ ///
+ /// #5644 W21, the web read only: each collapsed recommendation row gains first_seen (its oldest capture in the
+ /// window), last_seen (its newest, the same instant as collection_time) and capture_count. Added to
+ /// the serialized row rather than to the anonymous shape above, so the MCP tool's payload keys do not move.
+ ///
+ internal static JsonArray WithSeenKeys(IEnumerable
- public const string RangeSql = """
+ private const string RangeSqlTemplate = """
WITH wait_per_spelling AS (
SELECT date_trunc('day', collection_time) AS d, wait_type, SUM(delta_wait_time_ms) AS ms
FROM v_wait_stats
@@ -87,17 +88,17 @@ FROM v_deadlocks
AND collection_time >= $4
GROUP BY 1
),
+ /*blocking-waits*/
bpr AS (
+ /* #5644: counts WAITS, not report rows (BlockingWaits.WaitsCteSql, the one definition the alert, the
+ fleet tile and the MCP counts share), bucketed on the day of the wait's longest report. */
SELECT date_trunc('day', event_time) AS d, COUNT(*) AS c, MAX(wait_time_ms) AS max_wait_ms
- FROM v_blocked_process_reports
- WHERE server_id = $1 AND event_time >= $2 AND event_time < $3
- AND collection_time >= $4
+ FROM bpr_waits
GROUP BY 1
),
dmv AS (
SELECT date_trunc('day', collection_time) AS d, COUNT(*) AS c, MAX(wait_time_ms) AS max_wait_ms
- FROM v_dmv_blocking_snapshots
- WHERE server_id = $1 AND collection_time >= $2 AND collection_time < $3
+ FROM dmv_waits
GROUP BY 1
),
cpu AS (
@@ -221,6 +222,24 @@ FROM day_spine s
ORDER BY s.d
""";
+ ///
+ /// with the two blocking-wait chains spliced in (#5644): the day's
+ /// blocking_events counts blocking WAITS, as the alert value, the fleet "Blocking" tile and the MCP counts
+ /// do (), not blocked-process report rows (SQL Server writes about eleven
+ /// per one-minute wait). The blocked-process side carries database_name for
+ /// 's filter; the DMV side keeps its collected-day bucket.
+ ///
+ public static readonly string RangeSql = RangeSqlTemplate.Replace(
+ "/*blocking-waits*/",
+ BlockingWaits.WaitsCteSql(
+ BlockingWaits.SqlDialect.Postgres, "bpr_waits",
+ @"SELECT blocked_spid, blocking_spid, event_time, wait_time_ms, database_name FROM v_blocked_process_reports
+ WHERE server_id = $1 AND event_time >= $2 AND event_time < $3 AND collection_time >= $4") + "," + BlockingWaits.WaitsCteSql(
+ BlockingWaits.SqlDialect.Postgres, "dmv_waits",
+ @"SELECT blocked_spid, blocking_spid, event_time, collection_time, wait_time_ms FROM v_dmv_blocking_snapshots
+ WHERE server_id = $1 AND collection_time >= $2 AND collection_time < $3") + ",",
+ StringComparison.Ordinal);
+
///
/// The queries CTE exactly as it appears in — the ONE part of the
/// daily summary that reads a table subject to the 4-day raw drop. Every other source here (wait_stats,
diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.DailySummary.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.DailySummary.cs
index e48845f6dc..dffa0cbb2d 100644
--- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.DailySummary.cs
+++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.DailySummary.cs
@@ -36,7 +36,7 @@ public sealed partial class ViewerDataService
/// service's DarlingHealthReader (the get_daily_health MCP tool) — the two used to keep
/// hand-copied literals with nothing enforcing the copy (#1661).
///
- public const string DailySummaryRangeSql = DailySummarySql.RangeSql;
+ public static readonly string DailySummaryRangeSql = DailySummarySql.RangeSql;
/// The tier-routed form. See .
public static string DailySummaryRangeSqlFor(RetentionTier tier) => DailySummarySql.RangeSqlFor(tier);
diff --git a/Lite.Tests/AzureMasterDailySummaryScopeTests.cs b/Lite.Tests/AzureMasterDailySummaryScopeTests.cs
index 15387fbd8f..e8c4d6385e 100644
--- a/Lite.Tests/AzureMasterDailySummaryScopeTests.cs
+++ b/Lite.Tests/AzureMasterDailySummaryScopeTests.cs
@@ -50,12 +50,14 @@ private async Task ExecAsync(string sql, params object?[] args)
await cmd.ExecuteNonQueryAsync();
}
- private async Task SeedBprAsync(int count, string database)
+ /* #5644: the day counts WAITS; reports with the same time and wait time (and, here, no session ids) are one
+ wait, so callers seeding two databases in one day give each its own start minute. */
+ private async Task SeedBprAsync(int count, string database, int startMinute = 0)
{
for (var i = 0; i < count; i++)
await ExecAsync(
"INSERT INTO blocked_process_reports (blocked_report_id, collection_time, event_time, server_id, server_name, database_name, wait_time_ms) VALUES ($1,$2,$2,$3,'TestServer',$4,1000)",
- _nextId--, Day.AddHours(1).AddMinutes(i), ServerId, database);
+ _nextId--, Day.AddHours(1).AddMinutes(startMinute + i), ServerId, database);
}
private async Task SeedDmvAsync(int count, string database)
@@ -138,7 +140,7 @@ public async Task MasterWithSiblings_DmvArmCountsOnlyMasterRows()
public async Task SqlServerTarget_AndSiblinglessMaster_CountEverything(bool emptyList)
{
await SeedBprAsync(4, "GP");
- await SeedBprAsync(3, "master");
+ await SeedBprAsync(3, "master", startMinute: 30);
await SeedDeadlocksAsync(5, "GP");
await SeedDeadlocksAsync(3, "master");
AnalysisService.SeparatelyMonitoredDatabasesProvider = _ => emptyList ? Array.Empty() : null;
diff --git a/Lite.Tests/BlockingWaitDuckDbTests.cs b/Lite.Tests/BlockingWaitDuckDbTests.cs
index 48007ccf98..9664afd12a 100644
--- a/Lite.Tests/BlockingWaitDuckDbTests.cs
+++ b/Lite.Tests/BlockingWaitDuckDbTests.cs
@@ -183,6 +183,42 @@ public async Task TheDmvSnapshotFallback_CountsWaitsToo()
Assert.Equal(2, await OverviewCountAsync());
}
+ /* ── #5644 final review: the server tab badge and the daily summary count waits too ── */
+
+ private async Task BadgeBlockingCountAsync() =>
+ (await new LocalDataService(_duckDb).GetAlertCountsAsync(ServerId, hoursBack: 2, fromDate: null, toDate: null)).blockingCount;
+
+ /* The day buckets are UTC days of the wait's longest report; a run across midnight is two buckets, so sum them. */
+ private async Task DailySummaryBlockingEventsAsync()
+ {
+ var today = DateTime.UtcNow.Date;
+ var rows = await new LocalDataService(_duckDb).GetDailySummaryRangeAsync(ServerId, today.AddDays(-1), today.AddDays(2));
+ return rows.Sum(r => r.BlockingEvents);
+ }
+
+ [Fact]
+ public async Task TheServerTabBadgeAndTheDailySummary_CountWaitsNotReports()
+ {
+ /* Nine reports of one wait, then six of another: 15 report rows, 2 waits. */
+ await SeedWaitAsync(52, 51, Ago(30), reports: 9);
+ await SeedWaitAsync(53, 51, Ago(20), reports: 6);
+
+ Assert.Equal(2, CSharpWaitCount());
+ Assert.Equal(2, await BadgeBlockingCountAsync());
+ Assert.Equal(2, await DailySummaryBlockingEventsAsync());
+ Assert.Equal(2, await OverviewCountAsync());
+ }
+
+ [Fact]
+ public async Task TheServerTabBadgeAndTheDailySummary_CountWaitsOnTheDmvFallbackToo()
+ {
+ await SeedWaitAsync(52, 51, Ago(30), reports: 11, dmv: true);
+ await SeedWaitAsync(53, 51, Ago(20), reports: 6, dmv: true);
+
+ Assert.Equal(2, await BadgeBlockingCountAsync());
+ Assert.Equal(2, await DailySummaryBlockingEventsAsync());
+ }
+
/* ── #5644 wave-1 review: sub-millisecond starts and NULL times ── */
private sealed record Edge(int Blocked, int Blocker, DateTime? Time, long? Wait);
diff --git a/Lite.Tests/PerformanceCalendarDataTests.cs b/Lite.Tests/PerformanceCalendarDataTests.cs
index 706a5b9986..2b0403cdf1 100644
--- a/Lite.Tests/PerformanceCalendarDataTests.cs
+++ b/Lite.Tests/PerformanceCalendarDataTests.cs
@@ -104,10 +104,12 @@ private Task SeedDmvBlockingAsync(DateTime day) =>
"INSERT INTO dmv_blocking_snapshots (collection_id, collection_time, server_id, server_name, monitor_loop) VALUES ($1,$2,$3,$4,1)",
_nextId--, day.AddHours(1), ServerId, ServerName);
- private Task SeedBprAsync(DateTime day, long waitTimeMs) =>
+ /* #5644: the day's blocking count is WAITS, and reports with the same time and wait time are one wait, so a
+ caller that wants several distinct events gives each its own minute. */
+ private Task SeedBprAsync(DateTime day, long waitTimeMs, int minute = 0) =>
ExecAsync(
"INSERT INTO blocked_process_reports (blocked_report_id, collection_time, server_id, server_name, wait_time_ms, event_time) VALUES ($1,$2,$3,$4,$5,$2)",
- _nextId--, day.AddHours(1), ServerId, ServerName, waitTimeMs);
+ _nextId--, day.AddHours(1).AddMinutes(minute), ServerId, ServerName, waitTimeMs);
private Task SeedMemoryAsync(DateTime day, int process, int system) =>
ExecAsync(
@@ -176,7 +178,7 @@ public async Task GetDailySummaryRange_BucketsEachDay_AndBandsViaSharedCalculato
// exactly (#3539 A3) — each under the 10 s wait bar so the count arm is what decides.
await SeedCollectionRunAsync(Day(11));
for (var i = 0; i < 120; i++)
- await SeedBprAsync(Day(11), 6_000);
+ await SeedBprAsync(Day(11), 6_000, minute: i);
// 07-12 Healthy: collected, quiet.
await SeedCollectionRunAsync(Day(12));
diff --git a/Lite/Services/LocalDataService.Blocking.cs b/Lite/Services/LocalDataService.Blocking.cs
index 4ac6e22d2d..777a6608e4 100644
--- a/Lite/Services/LocalDataService.Blocking.cs
+++ b/Lite/Services/LocalDataService.Blocking.cs
@@ -581,7 +581,7 @@ FROM population p
///
/// Gets lightweight blocking + deadlock counts and latest event time for alert badge updates.
- /// Much cheaper than fetching full rows with XML — just COUNT(*) and MAX(time).
+ /// Much cheaper than fetching full rows with XML — a count of waits and MAX(time).
///
///
/// / are the tab's custom range as naive-UTC instants
@@ -601,11 +601,20 @@ FROM population p
/* blocking_count prefers the blocked-process-report; falls back to the always-on DMV snapshot when
BPR captured nothing (AWS RDS). latest_event_time includes DMV blocking recency too. */
+ /* #5644: the count is WAITS, not report rows (BlockingWaits.WaitsCteSql, the definition the Overview tile, the
+ alert and the analysis fact share): SQL Server writes a report for a still-blocked session every threshold
+ seconds, so one one-minute wait put about eleven on this badge beside a tile that said 1. */
command.CommandText = @"
+WITH " + BlockingWaits.WaitsCteSql(
+ BlockingWaits.SqlDialect.DuckDb, "xe_waits",
+ @"SELECT blocked_spid, blocking_spid, event_time, wait_time_ms
+ FROM " + StoredEventCopies.BlockedProcessReports("server_id = $1 AND event_time >= $2 AND event_time <= $3") + @" AS ev") + "," + BlockingWaits.WaitsCteSql(
+ BlockingWaits.SqlDialect.DuckDb, "dmv_waits",
+ @"SELECT blocked_spid, blocking_spid, event_time, wait_time_ms
+ FROM v_dmv_blocking_snapshots
+ WHERE server_id = $1 AND collection_time >= $2 AND collection_time <= $3") + @"
SELECT
- COALESCE(NULLIF((SELECT COUNT(*) FROM " + StoredEventCopies.BlockedProcessReports("server_id = $1 AND event_time >= $2 AND event_time <= $3") + @" AS ev), 0),
- (SELECT COUNT(*) FROM v_dmv_blocking_snapshots
- WHERE server_id = $1 AND collection_time >= $2 AND collection_time <= $3)) AS blocking_count,
+ COALESCE(NULLIF((SELECT COUNT(*) FROM xe_waits), 0), (SELECT COUNT(*) FROM dmv_waits)) AS blocking_count,
(SELECT " + StoredEventCopies.DeadlockDistinctCount + @" FROM v_deadlocks AS dl WHERE server_id = $1 AND deadlock_time >= $2 AND deadlock_time <= $3) AS deadlock_count,
(SELECT MAX(t) FROM (
SELECT MAX(event_time) AS t FROM " + StoredEventCopies.BlockedProcessReports("server_id = $1 AND event_time >= $2 AND event_time <= $3") + @" AS ev
diff --git a/Lite/Services/LocalDataService.DailySummary.cs b/Lite/Services/LocalDataService.DailySummary.cs
index 2f966d106d..caede84489 100644
--- a/Lite/Services/LocalDataService.DailySummary.cs
+++ b/Lite/Services/LocalDataService.DailySummary.cs
@@ -52,15 +52,24 @@ FROM v_deadlocks AS dl
WHERE server_id = $1 AND deadlock_time >= $2 AND deadlock_time < $3
GROUP BY 1
),
+" + BlockingWaits.WaitsCteSql(
+ BlockingWaits.SqlDialect.DuckDb, "bpr_waits",
+ @"SELECT blocked_spid, blocking_spid, event_time, wait_time_ms
+ FROM " + StoredEventCopies.BlockedProcessReports("server_id = $1 AND event_time >= $2 AND event_time < $3/*scope*/") + @" AS ev") + "," + BlockingWaits.WaitsCteSql(
+ BlockingWaits.SqlDialect.DuckDb, "dmv_waits",
+ @"SELECT blocked_spid, blocking_spid, event_time, collection_time, wait_time_ms
+ FROM v_dmv_blocking_snapshots
+ WHERE server_id = $1 AND collection_time >= $2 AND collection_time < $3/*scope*/") + @",
bpr AS (
+ /* #5644: counts WAITS, not report rows (BlockingWaits.WaitsCteSql, the definition the alert, the Overview tile
+ and the analysis fact share), bucketed on the day of the wait's longest report. */
SELECT date_trunc('day', event_time) AS d, COUNT(*) AS c, MAX(wait_time_ms) AS max_wait_ms
- FROM " + StoredEventCopies.BlockedProcessReports("server_id = $1 AND event_time >= $2 AND event_time < $3/*scope*/") + @" AS ev
+ FROM bpr_waits
GROUP BY 1
),
dmv AS (
SELECT date_trunc('day', collection_time) AS d, COUNT(*) AS c, MAX(wait_time_ms) AS max_wait_ms
- FROM v_dmv_blocking_snapshots
- WHERE server_id = $1 AND collection_time >= $2 AND collection_time < $3/*scope*/
+ FROM dmv_waits
GROUP BY 1
),
cpu AS (
From 5ba497c15f9e9eec92bc5d351b1a2227e4ba5fd3 Mon Sep 17 00:00:00 2001
From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com>
Date: Mon, 12 Oct 2026 23:28:30 -0400
Subject: [PATCH 34/42] Starter dashboard blocking trend panel is titled for
reports or snapshot rows (#5644)
---
Darling/Darling.Tests/ChartIntegerTicksPinTests.cs | 2 +-
Darling/Darling.Tests/ViewTemplatesTests.cs | 6 +++++-
Darling/Darling.Tests/WebBlockingSourceBehaviourTests.cs | 7 +++++--
.../wwwroot/js/view-templates.js | 8 +++++---
4 files changed, 16 insertions(+), 7 deletions(-)
diff --git a/Darling/Darling.Tests/ChartIntegerTicksPinTests.cs b/Darling/Darling.Tests/ChartIntegerTicksPinTests.cs
index e67c2f5b3f..025bc85852 100644
--- a/Darling/Darling.Tests/ChartIntegerTicksPinTests.cs
+++ b/Darling/Darling.Tests/ChartIntegerTicksPinTests.cs
@@ -85,7 +85,7 @@ public void EveryCountChartOnTheServerPage_DeclaresWholeNumbers_AndVizLinePasses
public void TheStarterDashboardAndComposedCountCharts_AskForWholeNumberTicks()
{
var templates = Js("view-templates.js");
- Assert.Matches(@"series: \[\{ key: ""count"", label: ""Reports"" \}\],\n\s+format: ""int"",", templates);
+ Assert.Matches(@"series: \[\{ key: ""count"", label: ""Reports or snapshot rows"" \}\],\n\s+format: ""int"",", templates);
Assert.Contains("...READ_FIELDS.get_deadlock_trend.line,", templates, StringComparison.Ordinal);
Assert.Matches(@"get_deadlock_trend: \{[\s\S]*?line: \{[^}]*?format: ""int"",[^}]*?label: ""Deadlocks""", Js("read-fields.js"));
diff --git a/Darling/Darling.Tests/ViewTemplatesTests.cs b/Darling/Darling.Tests/ViewTemplatesTests.cs
index 9410d1f4c4..c66d84e2d2 100644
--- a/Darling/Darling.Tests/ViewTemplatesTests.cs
+++ b/Darling/Darling.Tests/ViewTemplatesTests.cs
@@ -306,7 +306,11 @@ public void EveryTemplateDataPanel_ExplainsItsOwnEmptyState()
/* The two reads whose EMPTY ARRAY means "it did not happen" say exactly that, rather than inheriting a
sentence about collection. Absence and non-occurrence are the distinction this whole codebase keeps
closing, and a starter dashboard is the worst place to blur it. */
- Assert.Contains("an empty trend here means none happened, not that nothing was collected", js, StringComparison.Ordinal);
+ /* The blocking trend counts blocked process REPORTS (or DMV snapshot rows), so its sentence says "captured" (#5644 W16);
+ the deadlock trend panel spreads READ_FIELDS and keeps "happened". */
+ Assert.Contains("an empty trend here means none were captured, not that nothing was collected", js, StringComparison.Ordinal);
+ Assert.Contains("an empty trend here means none happened, not that nothing was collected",
+ ReadRepoFileLf(Path.Combine("Darling", "PerformanceMonitor.Darling.Service", "wwwroot", "js", "read-fields.js")), StringComparison.Ordinal);
/* And the two reads that CANNOT be honest on day one are deliberately absent, not merely unused: the
analysis pass writes nothing for 24 hours, and a target with Query Store off has nothing ever. */
diff --git a/Darling/Darling.Tests/WebBlockingSourceBehaviourTests.cs b/Darling/Darling.Tests/WebBlockingSourceBehaviourTests.cs
index 80001c5b53..de1b2b75eb 100644
--- a/Darling/Darling.Tests/WebBlockingSourceBehaviourTests.cs
+++ b/Darling/Darling.Tests/WebBlockingSourceBehaviourTests.cs
@@ -141,10 +141,13 @@ public void TheBlockingTrendChart_SaysItCountsBlockedProcessReports()
Assert.Equal(2, System.Text.RegularExpressions.Regex.Matches(tabs, "line\\(\"Blocked process report trend\", \"get_blocking_trend\"").Count);
Assert.Contains("const BLOCKING_REPORT_SERIES = [{ key: \"count\", label: \"Reports\" }];", tabs);
Assert.DoesNotContain("No blocking events in this window — an empty trend", tabs);
- Assert.Contains("label: \"Reports\"", templates);
+ /* The starter dashboard panel has no collector tag to follow, so it words itself for both sources. */
+ Assert.Contains("title: \"Blocking trend (reports or snapshot rows)\"", templates);
+ Assert.Contains("label: \"Reports or snapshot rows\"", templates);
+ Assert.DoesNotContain("title: \"Blocked process report trend\"", templates);
/* The axis caption says it too: both tab charts and the starter dashboard panel carry unit "reports". */
Assert.Equal(2, System.Text.RegularExpressions.Regex.Matches(tabs, "unit: \"reports\",").Count);
- Assert.Contains("unit: \"reports\",", templates);
+ Assert.Contains("unit: \"reports or snapshot rows\",", templates);
}
/// #5644 W16 review: only a true negative (status "empty") takes the page's one sentence. A not_collected or unavailable
diff --git a/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/view-templates.js b/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/view-templates.js
index ab190fdd98..7af79e6036 100644
--- a/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/view-templates.js
+++ b/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/view-templates.js
@@ -183,16 +183,18 @@ export const DASHBOARD_TEMPLATES = [
definition: {
panels: [
{
- title: "Blocked process report trend",
+ /* #5644 W16: a Custom View panel has no collector tag, and get_blocking_trend falls back from blocked process
+ reports to DMV snapshot rows, so the title, legend and axis caption are true for both. */
+ title: "Blocking trend (reports or snapshot rows)",
read: "get_blocking_trend",
params: { server, hours: 24 },
viz: "line",
rowsKey: "trend",
xKey: "time",
emptyText: "No blocked process reports in this window — an empty trend here means none were captured, not that nothing was collected.",
- series: [{ key: "count", label: "Reports" }],
+ series: [{ key: "count", label: "Reports or snapshot rows" }],
format: "int",
- unit: "reports",
+ unit: "reports or snapshot rows",
},
{
title: "Deadlocks",
From b69234eae6b0ba57a623a3655cd9081d242ac2f2 Mon Sep 17 00:00:00 2001
From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com>
Date: Tue, 13 Oct 2026 00:15:59 -0400
Subject: [PATCH 35/42] Overview tab Deadlocks chart uses the same empty
sentence as the Blocking tab (#5644)
---
Darling/Darling.Tests/WebAuditLaneDLabelTests.cs | 2 ++
.../wwwroot/js/pages/server-tabs.js | 3 ++-
2 files changed, 4 insertions(+), 1 deletion(-)
diff --git a/Darling/Darling.Tests/WebAuditLaneDLabelTests.cs b/Darling/Darling.Tests/WebAuditLaneDLabelTests.cs
index 1daec931d3..2d7a54e07a 100644
--- a/Darling/Darling.Tests/WebAuditLaneDLabelTests.cs
+++ b/Darling/Darling.Tests/WebAuditLaneDLabelTests.cs
@@ -48,6 +48,8 @@ public void W16_TheDeadlockTrendChartAndTheDeadlocksTableAreNamedApartAndEmptyTh
Assert.Contains("line(\"Deadlock trend\", \"get_deadlock_trend\", { server, hours: ctx.hours }, \"trend\", \"time\", COUNT_SERIES, {\n subtitle: ctx.label,\n format: \"int\",\n atTimeItem: \"deadlocks\",\n emptyText: DEADLOCKS_EMPTY_TEXT,", tabs, StringComparison.Ordinal);
Assert.Contains("const DEADLOCKS_EMPTY_TEXT = \"No deadlocks in this window.\";", tabs, StringComparison.Ordinal);
Assert.Contains(" ctx.label,\n DEADLOCKS_EMPTY_TEXT,\n 2,\n \"separately_monitored_note\"", tabs, StringComparison.Ordinal);
+ Assert.Contains("line(\"Deadlocks\", \"get_deadlock_trend\", { server, hours: ctx.hours }, \"trend\", \"time\", COUNT_SERIES, {\n subtitle: ctx.label,\n format: \"int\",\n atTimeItem: \"deadlocks\",\n emptyText: DEADLOCKS_EMPTY_TEXT,\n emptyWins: true,", tabs, StringComparison.Ordinal);
+ Assert.DoesNotContain("an empty trend here means none happened", tabs, StringComparison.Ordinal);
}
[Fact]
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 9d99028b62..74d5f3f337 100644
--- a/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/server-tabs.js
+++ b/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/server-tabs.js
@@ -1112,7 +1112,8 @@ export const SERVER_TABS = [
subtitle: ctx.label,
format: "int",
atTimeItem: "deadlocks",
- emptyText: "No deadlocks in this window — an empty trend here means none happened, not that nothing was collected.",
+ emptyText: DEADLOCKS_EMPTY_TEXT,
+ emptyWins: true,
}),
fileIoPanel(server, ctx),
table(
From 3c4fc7058cb5b84189a4da8222e72417c73406cf Mon Sep 17 00:00:00 2001
From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com>
Date: Tue, 13 Oct 2026 01:00:59 -0400
Subject: [PATCH 36/42] Tests follow the web UI audit: Alert History harness
finds Metric by header, localized detail pin, plain-text.js import, emptyWins
signature (#5644)
---
.../Darling.Tests/AlertNotebookRenderClientTests.cs | 4 +++-
.../Darling.Tests/FinOpsTabIndexAnalysisPageTests.cs | 2 +-
.../Darling.Tests/FinOpsTabUtilizationRawPageTests.cs | 2 +-
Darling/Darling.Tests/ServerPageTabsTests.cs | 4 +++-
Darling/Darling.Tests/alert-history-range-harness.mjs | 10 +++++++++-
5 files changed, 17 insertions(+), 5 deletions(-)
diff --git a/Darling/Darling.Tests/AlertNotebookRenderClientTests.cs b/Darling/Darling.Tests/AlertNotebookRenderClientTests.cs
index d7de36724b..5289e7697b 100644
--- a/Darling/Darling.Tests/AlertNotebookRenderClientTests.cs
+++ b/Darling/Darling.Tests/AlertNotebookRenderClientTests.cs
@@ -126,7 +126,9 @@ public void Views_AlertHeaderCell_RendersValueThresholdAndDetailText_ThroughElsT
Assert.Contains("a.detail_text", views, StringComparison.Ordinal);
// The detail_text line must go through el()'s { text: ... } prop (textContent), never innerHTML.
- Assert.Contains("el(\"pre\", { class: \"code\", text: a.detail_text })", views, StringComparison.Ordinal);
+ // W20 (#5644) shows the stored text with its UTC lines converted to the viewer's zone (localizeAlertDetail returns
+ // a string), so the text prop now holds that call; it is still el()'s text path, not markup.
+ Assert.Contains("el(\"pre\", { class: \"code\", text: localizeAlertDetail(a.detail_text, a.newest_sample) })", views, StringComparison.Ordinal);
// Scope the innerHTML ban to renderAlertHeaderCell's own body — the file's header comment (R4)
// says "never innerHTML" in prose, which the whole-file assertion this replaces was tripping on.
diff --git a/Darling/Darling.Tests/FinOpsTabIndexAnalysisPageTests.cs b/Darling/Darling.Tests/FinOpsTabIndexAnalysisPageTests.cs
index 8b5d004855..d4794fc4d1 100644
--- a/Darling/Darling.Tests/FinOpsTabIndexAnalysisPageTests.cs
+++ b/Darling/Darling.Tests/FinOpsTabIndexAnalysisPageTests.cs
@@ -358,7 +358,7 @@ public void TheTabImportsOnlyTheSharedHelpers()
{
var imports = Regex.Matches(Tab(), "from \"([^\"]+)\";").Select(m => m.Groups[1].Value).ToList();
Assert.NotEmpty(imports);
- Assert.All(imports, i => Assert.Contains(i, new[] { "./database-box.js", "../../panels.js", "../../util.js", "./gate.js" }));
+ Assert.All(imports, i => Assert.Contains(i, new[] { "./database-box.js", "../../panels.js", "../../util.js", "../../plain-text.js", "./gate.js" }));
Assert.Contains("./gate.js", imports);
}
diff --git a/Darling/Darling.Tests/FinOpsTabUtilizationRawPageTests.cs b/Darling/Darling.Tests/FinOpsTabUtilizationRawPageTests.cs
index 83c15dd8eb..4c3b56f865 100644
--- a/Darling/Darling.Tests/FinOpsTabUtilizationRawPageTests.cs
+++ b/Darling/Darling.Tests/FinOpsTabUtilizationRawPageTests.cs
@@ -126,7 +126,7 @@ public void TheTab_ImportsOnlyFromTheSharedModules()
{
var imports = Regex.Matches(Tab(), "from \"([^\"]+)\"").Select(m => m.Groups[1].Value).ToList();
Assert.NotEmpty(imports);
- Assert.All(imports, i => Assert.Contains(i, new[] { "../../panels.js", "../../charts.js", "../../util.js", "../../read-fields.js", "./gate.js" }));
+ Assert.All(imports, i => Assert.Contains(i, new[] { "../../panels.js", "../../charts.js", "../../util.js", "../../read-fields.js", "../../plain-text.js", "./gate.js" }));
Assert.Contains("./gate.js", imports);
}
diff --git a/Darling/Darling.Tests/ServerPageTabsTests.cs b/Darling/Darling.Tests/ServerPageTabsTests.cs
index 01f75b2ea5..ef9e5f5050 100644
--- a/Darling/Darling.Tests/ServerPageTabsTests.cs
+++ b/Darling/Darling.Tests/ServerPageTabsTests.cs
@@ -937,9 +937,11 @@ public void EveryDataPanel_ExplainsItsOwnEmptyState()
rendered the same way), and #4843 appended `columnGroups = null` (the opt-in column groups of a wide grid), and #5226 appended `control = null` (a node drawn under the title:
the ranking selector of the Top Queries / Top Procedures cards), and #5245 appended `dbScope = null` (the panel's own database-scope chip, for a panel whose scope differs from its read's class; unrelated to this guard)., and the release walk appended `extensionMissingLine = null` (the plain line a precondition panel shows when its optional extension is not installed; unrelated to this guard). The literal is spelled out here rather than truncated at emptyText
because a prefix match would stop noticing a parameter inserted BEFORE it. */
+ /* #5644 W16 appended `emptyWins = false` after those (the panel's one agreed sentence replaces the read's own empty message
+ on an empty envelope; unrelated to this guard, and the throw below still fires without an emptyText). */
Assert.Contains(
"function table(title, read, params, rowsKey, columns, subtitle, emptyText, span = 2, "
- + "noteKey = null, moreNoteKeys = null, columnGroups = null, control = null, dbScope = null, extensionMissingLine = null)",
+ + "noteKey = null, moreNoteKeys = null, columnGroups = null, control = null, dbScope = null, extensionMissingLine = null, emptyWins = false)",
js,
StringComparison.Ordinal);
Assert.Contains(
diff --git a/Darling/Darling.Tests/alert-history-range-harness.mjs b/Darling/Darling.Tests/alert-history-range-harness.mjs
index 628e8c76d1..853f2cde18 100644
--- a/Darling/Darling.Tests/alert-history-range-harness.mjs
+++ b/Darling/Darling.Tests/alert-history-range-harness.mjs
@@ -91,7 +91,15 @@ try {
const checkbox = (main) => main.all("input").find((i) => i.attrs.type === "checkbox");
const pick = async (sel, value) => { sel.value = String(value); sel.fire("change"); await new Promise((r) => setTimeout(r, 20)); };
const bodyRows = (main) => { const t = main.all("table")[0]; return t ? t.children[1].children : []; };
- const metrics = (main) => bodyRows(main).map((tr) => tr.children[3].textContent);
+ // The Metric column is found by its header, not a fixed index: W14 (#5644) put Triage and Mute before Time, so the
+ // column position moves with the grid's leading columns (and with whether the seat can edit).
+ const metricIndex = (main) => {
+ const t = main.all("table")[0];
+ const i = t ? t.children[0].children[0].children.findIndex((th) => th.textContent.trim().startsWith("Metric")) : -1;
+ if (i < 0) throw new Error("the Alert History grid has no Metric header");
+ return i;
+ };
+ const metrics = (main) => { const i = metricIndex(main); return bodyRows(main).map((tr) => tr.children[i].textContent); };
const settle = () => new Promise((r) => setTimeout(r, 20));
const out = {};
From 168ffe347d4b6a5c45a4484aa6c60d92093baebf Mon Sep 17 00:00:00 2001
From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com>
Date: Tue, 13 Oct 2026 01:05:12 -0400
Subject: [PATCH 37/42] Update four test pins the #5644 web UI audit outdated:
wait-counting seeds, Default Trace columns, AvgCpuMs golden, Regression
column spec
---
.../DailySummaryAzureMasterScopeLiveTests.cs | 15 +++++++++------
...WindowedReadsAreBoundedLivePostgresTests.cs | 5 ++++-
.../FinOpsDatabaseResources/golden.json | 18 ++++++++++++------
.../ProductiveZeroBandingTests.cs | 4 +++-
...werDailySummaryAzureMasterScopeLiveTests.cs | 12 +++++++-----
5 files changed, 35 insertions(+), 19 deletions(-)
diff --git a/Darling/Darling.Tests/DailySummaryAzureMasterScopeLiveTests.cs b/Darling/Darling.Tests/DailySummaryAzureMasterScopeLiveTests.cs
index e06cc241d7..27fafab461 100644
--- a/Darling/Darling.Tests/DailySummaryAzureMasterScopeLiveTests.cs
+++ b/Darling/Darling.Tests/DailySummaryAzureMasterScopeLiveTests.cs
@@ -81,9 +81,9 @@ await Exec(connection, "INSERT INTO collection_log (log_id, server_id, server_na
await Server(LoneId, Base + "-lone", 5);
await Server(PlainId, Base + "-plain", 3);
- async Task Bpr(int id, string name, DateTime day, int waitMs, string db) =>
+ async Task Bpr(int id, string name, DateTime day, int waitMs, string db, int minute = 0) =>
await Exec(connection, "INSERT INTO blocked_process_reports (blocked_report_id, collection_time, server_id, server_name, event_time, wait_time_ms, blocking_spid, blocked_spid, blocking_status, database_name) VALUES ($1,$2,$3,$4,$2,$5,60,70,'suspended',$6)",
- ct, CollectionIdGenerator.Next(), day.AddHours(12), id, name, waitMs, db);
+ ct, CollectionIdGenerator.Next(), day.AddHours(12).AddMinutes(minute), id, name, waitMs, db);
async Task Dmv(int id, string name, DateTime day, int waitMs) =>
await Exec(connection, "INSERT INTO dmv_blocking_snapshots (collection_id, collection_time, server_id, server_name, event_time, database_name, blocked_spid, blocking_spid, wait_time_ms, blocking_status) VALUES ($1,$2,$3,$4,$2,'master',70,60,$5,'suspended')",
ct, CollectionIdGenerator.Next(), day.AddHours(13), id, name, waitMs);
@@ -93,10 +93,13 @@ await Exec(connection, "INSERT INTO deadlocks (deadlock_id, collection_time, ser
var master = Base + "-master";
/* Day 1: three GP reports waiting 600 s, one of the master's own waiting 2 s; day 2: only GP's, so the
- scoped day falls back to the DMV snapshots. Unequal waits, so a peak taken from the wrong set shows. */
- for (var i = 0; i < 3; i++) await Bpr(MasterId, master, Day1, 600_000, "GP");
- await Bpr(MasterId, master, Day1, 2_000, "master");
- for (var i = 0; i < 2; i++) await Bpr(MasterId, master, Day2, 400_000, "GP");
+ scoped day falls back to the DMV snapshots. Unequal waits, so a peak taken from the wrong set shows.
+ #5644: the daily summary counts blocking WAITS (BlockingWaits.WaitsCteSql: one per blocked session,
+ blocker and derived start), not report rows, so each intended event gets its own minute and is its
+ own wait; reports of one pair at one instant would be a single wait. */
+ for (var i = 0; i < 3; i++) await Bpr(MasterId, master, Day1, 600_000, "GP", minute: i);
+ await Bpr(MasterId, master, Day1, 2_000, "master", minute: 3);
+ for (var i = 0; i < 2; i++) await Bpr(MasterId, master, Day2, 400_000, "GP", minute: i);
await Dmv(MasterId, master, Day2, 7_000);
await Dmv(MasterId, master, Day2, 3_000);
/* Day 1 deadlocks: an own-database row; a master-stamped graph wholly in GP; a master-stamped mixed
diff --git a/Darling/Darling.Tests/EventWindowedReadsAreBoundedLivePostgresTests.cs b/Darling/Darling.Tests/EventWindowedReadsAreBoundedLivePostgresTests.cs
index 02e0d49d8e..926b59c06a 100644
--- a/Darling/Darling.Tests/EventWindowedReadsAreBoundedLivePostgresTests.cs
+++ b/Darling/Darling.Tests/EventWindowedReadsAreBoundedLivePostgresTests.cs
@@ -107,7 +107,10 @@ ORDER BY sp.collection_time DESC
dte.integer_data,
dte.severity,
dte.error_number,
- dte.text_data
+ dte.text_data,
+ dte.filename,
+ dte.event_class,
+ dte.event_sequence
FROM default_trace_events AS dte, svr
WHERE dte.server_id = $1
AND dte.event_time - make_interval(mins => svr.offset_minutes) >= $2
diff --git a/Darling/Darling.Tests/Fixtures/FinOpsDatabaseResources/golden.json b/Darling/Darling.Tests/Fixtures/FinOpsDatabaseResources/golden.json
index 175cfe3fc4..ddef0e5b74 100644
--- a/Darling/Darling.Tests/Fixtures/FinOpsDatabaseResources/golden.json
+++ b/Darling/Darling.Tests/Fixtures/FinOpsDatabaseResources/golden.json
@@ -106,7 +106,8 @@
"PctCpu": 36.51,
"PctIo": 5.56,
"TotalCpuTimeMs": 0,
- "AvgIoMb": 0
+ "AvgIoMb": 0,
+ "AvgCpuMs": 0
},
{
"DatabaseName": "Bravo",
@@ -116,7 +117,8 @@
"PctCpu": 15.87,
"PctIo": 0.88,
"TotalCpuTimeMs": 0,
- "AvgIoMb": 0
+ "AvgIoMb": 0,
+ "AvgCpuMs": 0
},
{
"DatabaseName": "Charlie",
@@ -126,7 +128,8 @@
"PctCpu": 15.87,
"PctIo": 0.00,
"TotalCpuTimeMs": 0,
- "AvgIoMb": 0
+ "AvgIoMb": 0,
+ "AvgCpuMs": 0
}
],
"byAvg": [
@@ -138,7 +141,8 @@
"PctCpu": 0,
"PctIo": 0,
"TotalCpuTimeMs": 5000,
- "AvgIoMb": 0.2000
+ "AvgIoMb": 0.2000,
+ "AvgCpuMs": 500.00
},
{
"DatabaseName": "Charlie",
@@ -148,7 +152,8 @@
"PctCpu": 0,
"PctIo": 0,
"TotalCpuTimeMs": 5000,
- "AvgIoMb": 0.0000
+ "AvgIoMb": 0.0000,
+ "AvgCpuMs": 500.00
},
{
"DatabaseName": "",
@@ -158,7 +163,8 @@
"PctCpu": 0,
"PctIo": 0,
"TotalCpuTimeMs": 4000,
- "AvgIoMb": 0.0000
+ "AvgIoMb": 0.0000,
+ "AvgCpuMs": 500.00
}
]
},
diff --git a/Darling/Darling.Tests/ProductiveZeroBandingTests.cs b/Darling/Darling.Tests/ProductiveZeroBandingTests.cs
index 39a64ce747..091b11214b 100644
--- a/Darling/Darling.Tests/ProductiveZeroBandingTests.cs
+++ b/Darling/Darling.Tests/ProductiveZeroBandingTests.cs
@@ -398,11 +398,13 @@ stopped PRODUCING reaches the same number an install countersign already reads.
}
}
+ /* #5644 W11: the column's long text now clips to one line (clamp) and prints its ISO times local
+ (localizeText) instead of wrapping, so the pin follows that spec; the column itself must still exist. */
/* And the web table has a column for it: the viewer reads the full shape (full_detail: true), which
always carried the sentence, but no column rendered it, so a regressed row read WARNING with every
cell beside it blank. */
Assert.Contains(
- "{ key: \"regression_finding\", label: \"Regression\", wrap: true, plain: true }",
+ "{ key: \"regression_finding\", label: \"Regression\", clamp: true, localizeText: true, plain: true }",
ReadRepoFile("Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/server-tabs.js"),
StringComparison.Ordinal);
diff --git a/Darling/Darling.Tests/ViewerDailySummaryAzureMasterScopeLiveTests.cs b/Darling/Darling.Tests/ViewerDailySummaryAzureMasterScopeLiveTests.cs
index 0bc8719c21..e189913de8 100644
--- a/Darling/Darling.Tests/ViewerDailySummaryAzureMasterScopeLiveTests.cs
+++ b/Darling/Darling.Tests/ViewerDailySummaryAzureMasterScopeLiveTests.cs
@@ -63,9 +63,11 @@ private static async Task WithStoreAsync(Func
+ private static Task BprAsync(NpgsqlConnection connection, int id, DateTime day, int waitMs, string db, CancellationToken ct, int minute = 0) =>
DarlingMcpTestData.ExecAsync(connection, ct,
"INSERT INTO blocked_process_reports (blocked_report_id, collection_time, server_id, server_name, event_time, wait_time_ms, blocking_spid, blocked_spid, blocking_status, database_name) VALUES ($1, $2, $3, 's', $2, $4, 60, 70, 'suspended', $5)",
- CollectionIdGenerator.Next(), day.AddHours(12), id, waitMs, db);
+ CollectionIdGenerator.Next(), day.AddHours(12).AddMinutes(minute), id, waitMs, db);
private static Task DmvAsync(NpgsqlConnection connection, int id, DateTime day, int waitMs, CancellationToken ct) =>
DarlingMcpTestData.ExecAsync(connection, ct,
From 54909f3eebc0c000d9e7923bc996c2f6651df036 Mon Sep 17 00:00:00 2001
From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com>
Date: Tue, 13 Oct 2026 01:07:39 -0400
Subject: [PATCH 38/42] CI fixes: four pins follow the #5644 wording and panel
changes; darling filter reaches the linked watermark test
---
.github/workflows/build.yml | 2 ++
Darling/Darling.Tests/PgIndexBloatCoverageTests.cs | 2 +-
Darling/Darling.Tests/WebDataStartNoteTests.cs | 2 +-
Darling/Darling.Tests/WebServerTrendsBehaviourTests.cs | 2 +-
Darling/Darling.Tests/WebWaitsActivityTrendsBehaviourTests.cs | 2 +-
5 files changed, 6 insertions(+), 4 deletions(-)
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index ba6ffdf6a8..7f9d36aa1e 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -735,6 +735,8 @@ jobs:
- 'Lite.Tests/LiteTimeRangeGapTests.cs'
# #5641: Darling.Tests compiles this Lite.Tests file in (Linked\AlertLegendParityTests.cs), so an edit to it has to reach the Darling suite.
- 'Lite.Tests/AlertLegendParityTests.cs'
+ # #5644: Darling.Tests compiles this Lite.Tests file in (Linked\DefaultTraceEventSequenceWatermarkTests.cs), so an edit to it has to reach the Darling suite.
+ - 'Lite.Tests/DefaultTraceEventSequenceWatermarkTests.cs'
# #5596: Darling.Tests' WpfExitSwitchPinTests reads this project file for the WPF exit switch, so an edit to it has to reach the Darling suite.
- 'Lite.Tests/Lite.Tests.csproj'
# #5616: the two shard scripts are run by the shard jobs and parsed by Darling.Tests pins.
diff --git a/Darling/Darling.Tests/PgIndexBloatCoverageTests.cs b/Darling/Darling.Tests/PgIndexBloatCoverageTests.cs
index 3b3e315b0e..0756f02956 100644
--- a/Darling/Darling.Tests/PgIndexBloatCoverageTests.cs
+++ b/Darling/Darling.Tests/PgIndexBloatCoverageTests.cs
@@ -1024,7 +1024,7 @@ above them would never reach a rendered body - and that is precisely the half th
/* The helper forwards it to the descriptor, or the panel below configures something nothing reads. */
Assert.Contains("noteKey = null", tabs, StringComparison.Ordinal);
- Assert.Contains("span, noteKey, dbScope, extensionMissingLine }", tabs, StringComparison.Ordinal);
+ Assert.Contains("span, noteKey, dbScope, extensionMissingLine, emptyWins }", tabs, StringComparison.Ordinal);
/* And THIS panel asks for it. Scoped to a window after its own title so a noteKey on some other
panel cannot satisfy the claim. */
diff --git a/Darling/Darling.Tests/WebDataStartNoteTests.cs b/Darling/Darling.Tests/WebDataStartNoteTests.cs
index 42db38c900..e470ccc33c 100644
--- a/Darling/Darling.Tests/WebDataStartNoteTests.cs
+++ b/Darling/Darling.Tests/WebDataStartNoteTests.cs
@@ -841,7 +841,7 @@ public void TheNote_IsDrawnByEveryGridPath_AndByNoChartPath()
/* #4966: the empty envelope carries the note too, drawn above its message, and a read's answer is composed in the
browser's zone for the envelope as it is for rows. */
- Assert.Contains("mount(body, [kept, windowFloorStrip(res.data, desc), emptyStrip(res.message)]);", panels, StringComparison.Ordinal);
+ Assert.Contains("mount(body, [kept, windowFloorStrip(res.data, desc), emptyStrip(emptyHeadline(desc, res))]);", panels, StringComparison.Ordinal);
Assert.Contains("res.kind !== \"data\" && res.kind !== \"empty\"", util, StringComparison.Ordinal);
Assert.Contains("windowFloorStrip(res.data, spec)", tabs, StringComparison.Ordinal);
diff --git a/Darling/Darling.Tests/WebServerTrendsBehaviourTests.cs b/Darling/Darling.Tests/WebServerTrendsBehaviourTests.cs
index 289af76fc3..8cd2320f8a 100644
--- a/Darling/Darling.Tests/WebServerTrendsBehaviourTests.cs
+++ b/Darling/Darling.Tests/WebServerTrendsBehaviourTests.cs
@@ -150,7 +150,7 @@ public void AnEmptyAnswer_ShowsTheReadsMessage_NotAChart()
{
var r = Run(scenario);
Assert.Equal(JsonValueKind.Null, r.GetProperty("chart").ValueKind);
- Assert.Contains("recorded in the last 24 hour(s)", Strings(r.GetProperty("empties")).Single());
+ Assert.Contains("recorded in the last 24 hours", Strings(r.GetProperty("empties")).Single());
}
}
diff --git a/Darling/Darling.Tests/WebWaitsActivityTrendsBehaviourTests.cs b/Darling/Darling.Tests/WebWaitsActivityTrendsBehaviourTests.cs
index f2943ce0f5..6c30ab8c55 100644
--- a/Darling/Darling.Tests/WebWaitsActivityTrendsBehaviourTests.cs
+++ b/Darling/Darling.Tests/WebWaitsActivityTrendsBehaviourTests.cs
@@ -134,7 +134,7 @@ public void AnEmptyAnswer_ShowsTheReadsMessage_NotAChart()
{
var r = Run(scenario);
Assert.Equal(JsonValueKind.Null, r.GetProperty("chart").ValueKind);
- Assert.Contains("recorded in the last 24 hour(s)", Strings(r.GetProperty("empties")).Single());
+ Assert.Contains("recorded in the last 24 hours", Strings(r.GetProperty("empties")).Single());
}
}
From 0785efaaeb08484c3042ca7c3768150b66b655c7 Mon Sep 17 00:00:00 2001
From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com>
Date: Tue, 13 Oct 2026 01:31:21 -0400
Subject: [PATCH 39/42] Band blocking on report counts again; /api/fleet
refuses out-of-range hours and worst_count (#5644)
W1 made the displayed blocking counts WAITS, but the health band's 5/20 per-hour tiers were measured in blocked-process reports, so a long wait stopped banding by count. The fleet card and the viewer summary keep waits for display and hand the band the report count. /api/fleet refuses hours outside 1..168 and a negative worst_count with the invalid envelope instead of clamping.
---
.../BlockingBandReportUnitLiveTests.cs | 165 ++++++++++++++++++
.../Darling.Tests/DarlingWebEndpointsTests.cs | 65 +++++++
.../PgFactCollector.Waits.cs | 5 +-
.../DarlingWebEndpoints.cs | 34 +++-
.../Mcp/DarlingFleetReader.cs | 113 +++++++++---
.../ViewerDataService.AzureMasterScope.cs | 7 +-
.../ViewerDataService.Overview.cs | 28 ++-
7 files changed, 375 insertions(+), 42 deletions(-)
create mode 100644 Darling/Darling.Tests/BlockingBandReportUnitLiveTests.cs
diff --git a/Darling/Darling.Tests/BlockingBandReportUnitLiveTests.cs b/Darling/Darling.Tests/BlockingBandReportUnitLiveTests.cs
new file mode 100644
index 0000000000..e4fb072b97
--- /dev/null
+++ b/Darling/Darling.Tests/BlockingBandReportUnitLiveTests.cs
@@ -0,0 +1,165 @@
+/*
+ * 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.Threading;
+using System.Threading.Tasks;
+using Npgsql;
+using PerformanceMonitor.Collectors;
+using PerformanceMonitor.Common;
+using PerformanceMonitor.Darling.Service.Mcp;
+using PerformanceMonitor.Darling.Storage;
+using PerformanceMonitor.Darling.Viewer;
+using Xunit;
+
+namespace Darling.Tests;
+
+///
+/// #5644 review of W1: the displayed blocking count is WAITS, but the health band's per-hour count tiers
+/// (ServerHealthThresholds.BlockingWarnPerHour 5 / BlockingCriticalPerHour 20) were measured in
+/// blocked-process REPORTS per server-hour, so the band reads the report count. One wait that SQL Server reported
+/// 22 times in the last ten minutes (short waits, so the 10 s / 60 s wait arms stay out of it) is ONE wait on the
+/// card and 22 reports for the band: Critical by count, as it was before W1. Read through the fleet reader and the
+/// viewer's per-server summary. Gated on DARLING_TEST_PG.
+///
+/* #1776 own-store: every fact plants its rows under one dedicated server id and deletes them in cleanup. */
+[Collection("live-postgres")]
+public sealed class BlockingBandReportUnitLiveTests
+{
+ private const string ServerName = "darling-blocking-band-report-unit";
+ private static readonly int ServerId = ServerIdHelper.GetDeterministicHashCode(ServerName);
+
+ private const int Reports = 22;
+
+ private static string? ConnectionString => Environment.GetEnvironmentVariable("DARLING_TEST_PG");
+
+ private static async Task ExecAsync(NpgsqlConnection c, string sql, CancellationToken ct, params object[] p)
+ {
+ using var cmd = new NpgsqlCommand(sql, c);
+ foreach (var v in p)
+ cmd.Parameters.AddWithValue(v);
+ await cmd.ExecuteNonQueryAsync(ct);
+ }
+
+ /* One wait reported 22 times, 100 ms apart: the k-th report carries wait_time_ms = k * 100 and is written at
+ start + wait, so every report derives the same wait start (the rule BlockingWaits collapses on). The longest
+ wait is 2.2 s: under the 10 s wait arm, so only the count arm can band it. */
+ private static async Task PlantAsync(NpgsqlConnection c, bool dmv, CancellationToken ct)
+ {
+ var start = DateTime.SpecifyKind(DateTime.UtcNow.AddMinutes(-10), DateTimeKind.Unspecified);
+ for (var k = 1; k <= Reports; k++)
+ {
+ var at = start.AddMilliseconds(100 * k);
+ if (dmv)
+ await ExecAsync(c, "INSERT INTO dmv_blocking_snapshots (collection_id, collection_time, server_id, server_name, event_time, database_name, blocked_spid, blocking_spid, wait_time_ms, blocking_status) VALUES ($1,$2,$3,$4,$2,'db',52,51,$5,'suspended')",
+ ct, CollectionIdGenerator.Next(), at, ServerId, ServerName, (long)(100 * k));
+ else
+ await ExecAsync(c, "INSERT INTO blocked_process_reports (blocked_report_id, collection_time, server_id, server_name, event_time, wait_time_ms, blocking_spid, blocked_spid, blocking_status, database_name) VALUES ($1,$2,$3,$4,$2,$5,51,52,'suspended','db')",
+ ct, CollectionIdGenerator.Next(), at, ServerId, ServerName, (long)(100 * k));
+ }
+ }
+
+ private static async Task OpenSeededAsync(bool dmv, CancellationToken ct)
+ {
+ var connection = new NpgsqlConnection(ConnectionString);
+ await connection.OpenAsync(ct);
+ await PgMigrations.MigrateAsync(connection, ct);
+ await DeleteRowsAsync(connection, ct);
+ await ExecAsync(connection, @"
+INSERT INTO servers (server_id, server_name, display_name, is_enabled, sql_major_version, created_date, modified_date)
+VALUES ($1, $2, $2, TRUE, 16, now()::timestamp, now()::timestamp)
+ON CONFLICT (server_id) DO UPDATE SET is_enabled = TRUE", ct, ServerId, ServerName);
+ await PlantAsync(connection, dmv, ct);
+ return connection;
+ }
+
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public async Task TheFleetCard_ShowsOneWait_AndBandsOnTheReports(bool dmv)
+ {
+ Assert.SkipWhen(string.IsNullOrEmpty(ConnectionString), "Set DARLING_TEST_PG to a Postgres connection string to run the live test.");
+ var ct = TestContext.Current.CancellationToken;
+ var bodySucceeded = false;
+ try
+ {
+ await using var connection = await OpenSeededAsync(dmv, ct);
+ await using var postgres = NpgsqlDataSource.Create(ConnectionString!);
+ var now = DateTime.UtcNow;
+
+ var result = await DarlingFleetReader.GetFleetOverviewAsync(
+ postgres, now.AddHours(-1), now, now, cancellationToken: ct);
+
+ var card = result.Cards.Single(c => c.ServerId == ServerId);
+ Assert.Equal(1, card.BlockingCount); // displayed: ONE wait
+ Assert.Equal(Reports, card.BlockingReportCount); // the band's unit
+ Assert.Equal((double)Reports, card.BlockingRatePerHour); // reports per hour
+ Assert.Equal(HealthSeverity.Critical, card.BlockingSeverity);
+ Assert.True(card.MaxBlockingWaitMs < 10_000, "the wait arm must not be what bands this card");
+ /* The re-band the fleet ranking runs reads the same report count. */
+ Assert.Equal(Reports, card.ToHealthMetrics().BlockingCount);
+ bodySucceeded = true;
+ }
+ finally
+ {
+ await LiveStoreCleanup.RunAsync(ConnectionString!, bodySucceeded, async (cleanup, cleanupCt) =>
+ await DeleteRowsAsync(cleanup, cleanupCt));
+ }
+ }
+
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public async Task TheViewerSummary_ShowsOneWait_AndBandsOnTheReports(bool dmv)
+ {
+ Assert.SkipWhen(string.IsNullOrEmpty(ConnectionString), "Set DARLING_TEST_PG to a Postgres connection string to run the live test.");
+ var ct = TestContext.Current.CancellationToken;
+ var bodySucceeded = false;
+ try
+ {
+ await using var connection = await OpenSeededAsync(dmv, ct);
+ await using var viewer = new ViewerDataService(ConnectionString!);
+
+ var summary = await viewer.GetServerSummaryAsync(ServerId, ServerName, null, ct);
+
+ Assert.Equal(1, summary.BlockingCount);
+ Assert.Equal(Reports, summary.BlockingReportCount);
+ Assert.Equal(Reports, summary.BlockingCountForBand);
+ Assert.Equal((double)Reports, summary.BlockingRatePerHour);
+ Assert.Equal(HealthSeverity.Critical, summary.BlockingSeverity);
+ bodySucceeded = true;
+ }
+ finally
+ {
+ await LiveStoreCleanup.RunAsync(ConnectionString!, bodySucceeded, async (cleanup, cleanupCt) =>
+ await DeleteRowsAsync(cleanup, cleanupCt));
+ }
+ }
+
+ /// A card or summary built without a report count (the pre-W1 shape) bands on its own count.
+ [Fact]
+ public void ACardBuiltWithoutAReportCount_BandsOnItsCount()
+ {
+ var summary = new ServerSummaryItem { BlockingCount = 7, BlockingWindow = TimeSpan.FromHours(1) };
+ Assert.Equal(7, summary.BlockingCountForBand);
+
+ var withReports = new ServerSummaryItem { BlockingCount = 1, BlockingReportCount = 30, BlockingWindow = TimeSpan.FromHours(1) };
+ Assert.Equal(30, withReports.BlockingCountForBand);
+ Assert.Equal(HealthSeverity.Critical, withReports.BlockingSeverity);
+ }
+
+ private static async Task DeleteRowsAsync(NpgsqlConnection connection, CancellationToken ct)
+ {
+ using var cleanup = new NpgsqlCommand(
+ $"DELETE FROM blocked_process_reports WHERE server_id = {ServerId}; " +
+ $"DELETE FROM dmv_blocking_snapshots WHERE server_id = {ServerId}; " +
+ $"DELETE FROM servers WHERE server_id = {ServerId};", connection);
+ await cleanup.ExecuteNonQueryAsync(ct);
+ }
+}
diff --git a/Darling/Darling.Tests/DarlingWebEndpointsTests.cs b/Darling/Darling.Tests/DarlingWebEndpointsTests.cs
index 8e1381ccf2..daa7e2e206 100644
--- a/Darling/Darling.Tests/DarlingWebEndpointsTests.cs
+++ b/Darling/Darling.Tests/DarlingWebEndpointsTests.cs
@@ -877,6 +877,71 @@ public void MapAll_BuildsTheAnalysisServiceWithTheHostsLogger()
Assert.Contains("logger: logger", line, StringComparison.Ordinal);
}
+ ///
+ /// #5644 (review of W22): /api/fleet refuses an out-of-range window or a negative worst_count with the
+ /// {"status":"invalid"} envelope, naming the spelling the caller sent, and still answers the in-range requests
+ /// (the bare route, hours=24, the edges 1 and 168, worst_count=0) with no refusal. It used to clamp
+ /// (hours=500 answered 200 over 168 hours, worst_count=-3 over zero).
+ ///
+ [Theory]
+ [InlineData("hours", 500, "hours")]
+ [InlineData("hours", 0, "hours")]
+ [InlineData("hours", -5, "hours")]
+ [InlineData("hours", 169, "hours")]
+ [InlineData("hours_back", 500, "hours_back")]
+ [InlineData("hours_back", 0, "hours_back")]
+ public void FleetParamRefusal_RefusesAnOutOfRangeWindow_NamingTheParameterSent(string key, int value, string expectedParameter)
+ {
+ var refusal = DarlingWebEndpoints.FleetParamRefusal(value, 3, k => k == key ? value.ToString() : null);
+
+ Assert.NotNull(refusal);
+ Assert.True(McpHelpers.IsRefusalEnvelope(refusal));
+ var envelope = JsonNode.Parse(refusal)!;
+ Assert.Equal("invalid", (string?)envelope["status"]);
+ Assert.Equal(expectedParameter, (string?)envelope["hints"]!["parameter"]);
+ }
+
+ [Theory]
+ [InlineData(-1)]
+ [InlineData(-3)]
+ public void FleetParamRefusal_RefusesANegativeWorstCount(int worstCount)
+ {
+ var refusal = DarlingWebEndpoints.FleetParamRefusal(24, worstCount, k => k == "worst_count" ? worstCount.ToString() : null);
+
+ Assert.NotNull(refusal);
+ var envelope = JsonNode.Parse(refusal)!;
+ Assert.Equal("invalid", (string?)envelope["status"]);
+ Assert.Equal("worst_count", (string?)envelope["hints"]!["parameter"]);
+ }
+
+ [Theory]
+ [InlineData(1, 0)]
+ [InlineData(1, 5)]
+ [InlineData(24, 5)]
+ [InlineData(168, 5)]
+ public void FleetParamRefusal_AnswersInRangeRequests(int hours, int worstCount)
+ {
+ Assert.Null(DarlingWebEndpoints.FleetParamRefusal(hours, worstCount, k => k == "hours" ? hours.ToString() : null));
+ }
+
+ /// The /api/fleet route runs that refusal before it reads, and no longer clamps the window or the count.
+ [Fact]
+ public void FleetRoute_RefusesBeforeReading_AndDoesNotClamp()
+ {
+ var source = ReadSource(WebEndpointsSourcePath);
+ var start = source.IndexOf("app.MapGet(\"/api/fleet\"", StringComparison.Ordinal);
+ Assert.True(start >= 0, "the /api/fleet route has moved; update this pin.");
+ var end = source.IndexOf("app.MapGet(", start + 10, StringComparison.Ordinal);
+ var route = source[start..end];
+
+ Assert.Contains("FleetParamRefusal(", route, StringComparison.Ordinal);
+ Assert.DoesNotContain("Math.Clamp", route, StringComparison.Ordinal);
+ Assert.DoesNotContain("Math.Max", route, StringComparison.Ordinal);
+ Assert.True(
+ route.IndexOf("FleetParamRefusal(", StringComparison.Ordinal) < route.IndexOf("GetFleetOverviewAsync", StringComparison.Ordinal),
+ "the refusal must come before the read");
+ }
+
private static string ReadSource(string relative)
{
var path = Path.Combine(RepoRoot(), relative);
diff --git a/Darling/PerformanceMonitor.Darling.Analysis/PgFactCollector.Waits.cs b/Darling/PerformanceMonitor.Darling.Analysis/PgFactCollector.Waits.cs
index d65281769d..1d8b39c357 100644
--- a/Darling/PerformanceMonitor.Darling.Analysis/PgFactCollector.Waits.cs
+++ b/Darling/PerformanceMonitor.Darling.Analysis/PgFactCollector.Waits.cs
@@ -299,7 +299,10 @@ GROUP BY bucket_start
MAX(wait_time_ms) AS max_wait_time_ms,
COUNT(DISTINCT blocking_spid) AS distinct_head_blockers,
COUNT(CASE WHEN blocking_status = 'sleeping' THEN 1 END) AS sleeping_blocker_count,
- (SELECT COALESCE(MAX(bucket_event_count), 0) FROM buckets) AS peak_4h_event_count
+ (SELECT COALESCE(MAX(bucket_event_count), 0) FROM buckets) AS peak_4h_event_count,
+ /* #5644: the report rows before they collapse to waits, the unit the health band's per-hour count tiers were
+ measured in. Appended last so every ordinal above is where it was; the fleet tile and the viewer band read it. */
+ (SELECT COUNT(*) FROM waits_marked) AS report_count
FROM reports";
public static string BlockingSql => BuildBlockingSql(@"
diff --git a/Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs b/Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs
index f71257aa29..3ed9f58464 100644
--- a/Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs
+++ b/Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs
@@ -349,8 +349,17 @@ makes it the only one that can answer when the store IS the problem — so it re
return ToHttpResult(fleetRefusal, DarlingWebFailureLog.RouteOf(context.Request), logger, 0, context.RequestAborted);
}
- var hours = Math.Clamp(QueryInt(context, "hours_back", "hours", DefaultFleetHours), 1, 168);
- var worstCount = Math.Max(0, QueryInt(context, "worst_count", null, DarlingFleetReader.DefaultWorstCount));
+ /* #5644 (review of W22): an out-of-range window or count is refused with the {"status":"invalid"} 400
+ the read loop answers, never clamped to a different window and answered 200 (REVIEW-TRAPS: counts
+ are refused, not clamped). The refusal names the spelling the caller sent (hours or hours_back).
+ The in-product callers send neither (apiGetFleet asks for the bare /api/fleet, the one-hour default). */
+ var hours = QueryInt(context, "hours_back", "hours", DefaultFleetHours);
+ var worstCount = QueryInt(context, "worst_count", null, DarlingFleetReader.DefaultWorstCount);
+ if (FleetParamRefusal(hours, worstCount, key => First(context, key)) is { } fleetRangeRefusal)
+ {
+ return ToHttpResult(fleetRangeRefusal, DarlingWebFailureLog.RouteOf(context.Request), logger, 0, context.RequestAborted);
+ }
+
var now = DateTime.UtcNow;
var result = await DarlingFleetReader.GetFleetOverviewAsync(
postgres, now.AddHours(-hours), now, now, worstCount,
@@ -6508,6 +6517,25 @@ internal static string NameWindowParameterTheCallerSent(string result, Func
+ /// #5644: the refusal envelope for an out-of-range /api/fleet window ( outside 1..168) or a
+ /// negative , else null. Same envelope and sentences as the MCP tool's
+ /// , with the window parameter named as the caller sent it
+ /// (). Pure over the lookup, so a test pins it without a server.
+ ///
+ internal static string? FleetParamRefusal(int hours, int worstCount, Func lookup)
+ {
+ var window = McpHelpers.ValidateHoursBack(hours);
+ if (window is not null)
+ {
+ return NameWindowParameterTheCallerSent(window, lookup);
+ }
+
+ return worstCount < 0
+ ? McpHelpers.Refusal("worst_count", $"Invalid worst_count value '{worstCount}'. Must be zero or a positive integer.")
+ : null;
+ }
+
private static string? NonIntegerParamRefusal(HttpContext context) =>
NonIntegerParamRefusal(key => First(context, key));
@@ -6517,7 +6545,7 @@ private static int QueryInt(HttpContext context, string key, string? aliasKey, i
///
/// A row-count knob (?limit= / ?top=), clamped to [1, ] at the
/// dispatch layer so an authenticated/loopback caller can't request an unbounded result set from a reader
- /// that binds the value straight into LIMIT $N (the /api/fleet window is clamped the same way).
+ /// that binds the value straight into LIMIT $N (the /api/fleet window is refused out of range instead, #5644).
///
private static int Rows(HttpContext context, string key, int def) =>
ClampRows(QueryInt(context, key, null, def));
diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingFleetReader.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingFleetReader.cs
index b5156f1cf5..6813a5d6c1 100644
--- a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingFleetReader.cs
+++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingFleetReader.cs
@@ -334,49 +334,65 @@ WHERE s.is_enabled
AND {SqlServerCollectedTargetSql}";
/// Blocking in the window per server from BOTH sources — XE blocked-process reports and the always-on
- /// DMV snapshot, each counted per server_id with its worst wait, lined up by a FULL OUTER JOIN so a
+ /// DMV snapshot, each counted per server_id as WAITS with its worst wait (and, for the health band, as
+ /// the raw report rows: xe_reports / dmv_reports, #5644), lined up by a FULL OUTER JOIN so a
/// server present in only one source still appears. The caller applies Lite's XE-preferred / DMV-fallback per
/// server. $1 window start, $2 window end, $3 the for $1 (all naive UTC).
///
/// $3 is the partition-column bound the event window cannot supply (#3895): without it both scans
/// open every retained chunk to count the last hour. It cannot drop a row the window wants —
/// says why.
- /* #5644 W1: each side counts WAITS (a blocked session, its blocker and the wait's derived start), not report
- rows, with the longest wait time as its max: SQL Server writes a report for a still-blocked session every
- threshold seconds, so one one-minute wait was about eleven tile counts. BlockingWaits.WaitsCteSql is the
- one definition the alert engine and the analysis fact share. The DMV side applies the same rule to its
- snapshot rows (a wait seen in several snapshots is one wait). */
+ /* #5644 W1: xe_count / dmv_count (and the max waits) count WAITS (a blocked session, its blocker and the wait's
+ derived start), not report rows: SQL Server writes a report for a still-blocked session every threshold
+ seconds, so one one-minute wait was about eleven tile counts. BlockingWaits.WaitsCteSql is the one definition
+ the alert engine and the analysis fact share. The DMV side applies the same rule to its snapshot rows (a wait
+ seen in several snapshots is one wait).
+
+ #5644 (review): xe_reports / dmv_reports keep the raw ROW count of the same two windowed reads. The health
+ band's count tiers (5 / 20 per hour) were measured in blocked-process REPORTS per server-hour, so the band
+ reads those; the tile, the alert value and the MCP counts stay waits. Each source is read once (the
+ *_reports CTE, shared by the report count and the waits chain), so the window and the floor are still
+ applied once per source. */
public static readonly string FleetBlockingSql = @"
-WITH " + BlockingWaits.WaitsCteSql(
- BlockingWaits.SqlDialect.Postgres, "xe_waits",
- @"SELECT server_id, blocked_spid, blocking_spid, event_time, wait_time_ms
+WITH xe_reports AS (
+ SELECT server_id, blocked_spid, blocking_spid, event_time, wait_time_ms
FROM v_blocked_process_reports
WHERE event_time >= $1
AND event_time <= $2
- AND collection_time >= $3", new[] { "server_id" }) + "," + BlockingWaits.WaitsCteSql(
- BlockingWaits.SqlDialect.Postgres, "dmv_waits",
- @"SELECT server_id, blocked_spid, blocking_spid, event_time, wait_time_ms
+ AND collection_time >= $3
+),
+dmv_reports AS (
+ SELECT server_id, blocked_spid, blocking_spid, event_time, wait_time_ms
FROM v_dmv_blocking_snapshots
WHERE event_time >= $1
AND event_time <= $2
- AND collection_time >= $3", new[] { "server_id" }) + @"
+ AND collection_time >= $3
+)," + BlockingWaits.WaitsCteSql(
+ BlockingWaits.SqlDialect.Postgres, "xe_waits",
+ "SELECT * FROM xe_reports", new[] { "server_id" }) + "," + BlockingWaits.WaitsCteSql(
+ BlockingWaits.SqlDialect.Postgres, "dmv_waits",
+ "SELECT * FROM dmv_reports", new[] { "server_id" }) + @"
SELECT
COALESCE(xe.server_id, dmv.server_id) AS server_id,
COALESCE(xe.cnt, 0) AS xe_count,
COALESCE(xe.max_wait, 0) AS xe_max_wait,
COALESCE(dmv.cnt, 0) AS dmv_count,
- COALESCE(dmv.max_wait, 0) AS dmv_max_wait
+ COALESCE(dmv.max_wait, 0) AS dmv_max_wait,
+ COALESCE(xe.reports, 0) AS xe_reports,
+ COALESCE(dmv.reports, 0) AS dmv_reports
FROM
(
- SELECT server_id, COUNT(*) AS cnt, MAX(wait_time_ms) AS max_wait
- FROM xe_waits
- GROUP BY server_id
+ SELECT r.server_id, r.reports, w.cnt, w.max_wait
+ FROM (SELECT server_id, COUNT(*) AS reports FROM xe_reports GROUP BY server_id) AS r
+ LEFT JOIN (SELECT server_id, COUNT(*) AS cnt, MAX(wait_time_ms) AS max_wait FROM xe_waits GROUP BY server_id) AS w
+ ON r.server_id = w.server_id
) AS xe
FULL OUTER JOIN
(
- SELECT server_id, COUNT(*) AS cnt, MAX(wait_time_ms) AS max_wait
- FROM dmv_waits
- GROUP BY server_id
+ SELECT r.server_id, r.reports, w.cnt, w.max_wait
+ FROM (SELECT server_id, COUNT(*) AS reports FROM dmv_reports GROUP BY server_id) AS r
+ LEFT JOIN (SELECT server_id, COUNT(*) AS cnt, MAX(wait_time_ms) AS max_wait FROM dmv_waits GROUP BY server_id) AS w
+ ON r.server_id = w.server_id
) AS dmv ON xe.server_id = dmv.server_id";
/// Deadlocks in the window per server — count and newest deadlock instant (for each card's
@@ -986,6 +1002,14 @@ internal static FleetServerCard BuildCard(
snapshot — both count and worst-wait come from whichever source wins. */
var blockingCount = blocking.XeCount > 0 ? blocking.XeCount : blocking.DmvCount;
var maxBlockingWaitMs = blocking.XeCount > 0 ? blocking.XeMaxWait : blocking.DmvMaxWait;
+ /* #5644 (review of W1): the count the BAND reads is the winning source's REPORT count, not blockingCount
+ (waits, which the card shows). ServerHealthThresholds.BlockingWarnPerHour / BlockingCriticalPerHour
+ were measured in blocked-process reports per server-hour; a long wait is many reports and one wait,
+ and handing the waits to those tiers stopped it banding by count. A row built without a report count
+ (the pre-W1 shape a test constructs) reads its own count, which is what it always banded on. */
+ var blockingReportCount = blocking.XeCount > 0
+ ? blocking.XeReports ?? blocking.XeCount
+ : blocking.DmvReports ?? blocking.DmvCount;
var cpuPercent = cpu.SqlCpu;
var otherCpu = cpu.OtherCpu;
@@ -1042,7 +1066,7 @@ deadlock_source and the coverage block disclosing the gap. The PostgreSQL arm is
computed from an empty set. That is also what keeps #3539 A6's card - an online PostgreSQL
target nothing has collected from - measuring nothing. */
var memoryPressureForBand = ServerMetricSources.DmvSourced(hasMemoryPressure, isPostgres);
- var blockingForBand = ServerMetricSources.DmvSourced(blockingCount, isPostgres);
+ var blockingForBand = ServerMetricSources.DmvSourced(blockingReportCount, isPostgres);
int? deadlocksForBand = deadlockMeasured ? deadlockCount : null;
var metrics = new ServerHealthMetrics
@@ -1139,7 +1163,9 @@ deliberately not surfaced. */
MaxBlockingWaitMs = maxBlockingWaitMs,
/* blockingForBand, not the raw count, for the reason DeadlockRatePerHour below gives: a
PostgreSQL target's raw count is a structural zero, and a rate derived from it would publish
- 0.0/hr against a severity that reads Unknown (#3539 A3). */
+ 0.0/hr against a severity that reads Unknown (#3539 A3). It is REPORTS per hour, the unit the
+ band's tiers were measured in (#5644), beside a count that is waits. */
+ BlockingReportCount = blockingReportCount,
BlockingRatePerHour = blockingForBand.HasValue
? ServerHealthClassifier.BlockingRatePerHour(blockingForBand.Value, deadlockWindow)
: null,
@@ -1632,7 +1658,12 @@ private static async Task ScopeAzureMasterRowsAsync(
postgres, server.ServerId, startUtc, endUtc, separate, cancellationToken);
blocking.TryGetValue(server.ServerId, out var current);
- blocking[server.ServerId] = current with { XeCount = scoped.XeCount, XeMaxWait = scoped.XeMaxWait };
+ blocking[server.ServerId] = current with
+ {
+ XeCount = scoped.XeCount,
+ XeMaxWait = scoped.XeMaxWait,
+ XeReports = scoped.XeReports,
+ };
deadlocks.TryGetValue(server.ServerId, out var currentDeadlock);
deadlocks[server.ServerId] = currentDeadlock with
{
@@ -1653,8 +1684,10 @@ private static async Task ScopeAzureMasterRowsAsync(
/// .., skipping the events of the databases in
/// . is the newest of the deadlocks that count, or
/// null when none does.
+ /// counts waits; is the report-row count of the same
+ /// scoped read, the band's input (#5644).
internal readonly record struct AzureMasterScopedCounts(
- int XeCount, long XeMaxWait, int DeadlockCount, DateTime? DeadlockLastSeen);
+ int XeCount, long XeMaxWait, int DeadlockCount, DateTime? DeadlockLastSeen, int XeReports = 0);
/// The scoped reads behind , shared with the per-server summary
/// (get_server_summary) so the two surfaces count a master's events with the same statements.
@@ -1669,6 +1702,7 @@ internal static async Task ReadAzureMasterScopedCountsA
await using var connection = await postgres.OpenConnectionAsync(cancellationToken);
var xeCount = 0;
+ var xeReports = 0;
long xeMaxWait = 0;
await using (var command = new NpgsqlCommand(PgFactCollector.BlockingSqlSkippingSeparate, connection)
{ CommandTimeout = McpCommandDeadlines.ReadSeconds })
@@ -1683,12 +1717,13 @@ internal static async Task ReadAzureMasterScopedCountsA
{
xeCount = reader.IsDBNull(0) ? 0 : (int)Math.Min(Convert.ToInt64(reader.GetValue(0)), int.MaxValue);
xeMaxWait = reader.IsDBNull(2) ? 0 : Convert.ToInt64(reader.GetValue(2));
+ xeReports = reader.IsDBNull(6) ? 0 : (int)Math.Min(Convert.ToInt64(reader.GetValue(6)), int.MaxValue);
}
}
var (deadlockCount, deadlockLastSeen) = await PgFactCollector.CountAndNewestDeadlocksSkippingSeparateAsync(
connection, serverId, startUtc, endUtc, separate, cancellationToken, McpCommandDeadlines.ReadSeconds);
- return new AzureMasterScopedCounts(xeCount, xeMaxWait, (int)Math.Min(deadlockCount, int.MaxValue), deadlockLastSeen);
+ return new AzureMasterScopedCounts(xeCount, xeMaxWait, (int)Math.Min(deadlockCount, int.MaxValue), deadlockLastSeen, xeReports);
}
private static async Task> ReadBlockingAsync(
@@ -1707,7 +1742,9 @@ private static async Task> ReadBlockingAsync(
reader.IsDBNull(1) ? 0 : Convert.ToInt32(reader.GetValue(1)),
reader.IsDBNull(2) ? 0 : Convert.ToInt64(reader.GetValue(2)),
reader.IsDBNull(3) ? 0 : Convert.ToInt32(reader.GetValue(3)),
- reader.IsDBNull(4) ? 0 : Convert.ToInt64(reader.GetValue(4)));
+ reader.IsDBNull(4) ? 0 : Convert.ToInt64(reader.GetValue(4)),
+ reader.IsDBNull(5) ? 0 : Convert.ToInt32(reader.GetValue(5)),
+ reader.IsDBNull(6) ? 0 : Convert.ToInt32(reader.GetValue(6)));
}
return map;
@@ -2099,7 +2136,11 @@ internal readonly record struct PgCpuRow(
internal readonly record struct MemoryRow(double? MemoryMb, double? BufferPoolMb);
internal readonly record struct MemoryPressureRow(long WaiterCount, long TimeoutCount, long ForcedCount, double? GrantedMemoryMb);
internal readonly record struct ThreadsRow(int? TotalThreads, int? CurrentWorkers, int RunnableTasks, long WorkQueue);
- internal readonly record struct BlockingRow(int XeCount, long XeMaxWait, int DmvCount, long DmvMaxWait);
+ /// One server's blocking in the window: XeCount / DmvCount are WAITS (what the card shows),
+ /// XeReports / DmvReports the raw report / snapshot rows the band's per-hour tiers were measured in
+ /// (#5644). Null reports mean "not read separately": the band reads the count.
+ internal readonly record struct BlockingRow(
+ int XeCount, long XeMaxWait, int DmvCount, long DmvMaxWait, int? XeReports = null, int? DmvReports = null);
internal readonly record struct DeadlockRow(int Count, DateTime? LastSeen);
/// One server's row (#3935): its newest collection inside the
@@ -2501,7 +2542,20 @@ public sealed class FleetServerCard
[JsonPropertyName("has_memory_pressure")] public bool HasMemoryPressure { get; init; }
[JsonPropertyName("memory_severity")] public HealthSeverity MemorySeverity { get; init; }
- [JsonPropertyName("blocking_count")] public int BlockingCount { get; init; }
+ /// Blocking WAITS in the window (#5644 W1: a blocked session, its blocker and the wait's start): what the
+ /// card, the totals and the MCP counts show. The band does NOT read this; it reads
+ /// , the unit its per-hour tiers were measured in.
+ [JsonPropertyName("blocking_count")]
+ public int BlockingCount { get; init; }
+
+ /// The winning source's blocked-process REPORT (or DMV snapshot) rows in the window, before they are
+ /// collapsed to waits (#5644): the count blocking_severity and blocking_rate_per_hour band on,
+ /// because ServerHealthThresholds.BlockingWarnPerHour / BlockingCriticalPerHour were measured in
+ /// reports per server-hour. Not published (the contract is the wait count above); null on a card built
+ /// without one, which re-bands on .
+ [JsonIgnore]
+ public int? BlockingReportCount { get; init; }
+
[JsonPropertyName("max_blocking_wait_ms")] public long MaxBlockingWaitMs { get; init; }
/// Blocking events per HOUR over the card's window — the figure the count arm of
/// blocking_severity banded on (#3539 A3), or null when the window was too short to normalise.
@@ -2651,7 +2705,8 @@ Insights arm and the source is what decides which. Omitting them here would leav
deliberately left as zeros (#3017) — reading them here would hand the ranking a measurement the
card's own severity says it does not have. */
HasMemoryPressure = ServerMetricSources.DmvSourced(HasMemoryPressure, IsPostgres),
- BlockingCount = ServerMetricSources.DmvSourced(BlockingCount, IsPostgres),
+ /* The REPORT count the card banded on (#5644), not the wait count it shows. */
+ BlockingCount = ServerMetricSources.DmvSourced(BlockingReportCount ?? BlockingCount, IsPostgres),
MaxBlockedSeconds = MaxBlockingWaitMs / 1000.0,
/* #3539 A3: the count's denominator, for the same reason the deadlock window travels below. */
BlockingWindow = BlockingWindow,
diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.AzureMasterScope.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.AzureMasterScope.cs
index 9b64ec35ff..6c723e68db 100644
--- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.AzureMasterScope.cs
+++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.AzureMasterScope.cs
@@ -150,7 +150,7 @@ private static IReadOnlyList SeparatelyMonitoredFrom(
/// The master's windowed XE blocking count and worst wait without the separately monitored databases'
/// events (, the read the service's analysis uses).
///
- private async Task<(int Count, long MaxWaitMs)> ReadScopedBlockingAsync(
+ private async Task<(int Count, long MaxWaitMs, int Reports)> ReadScopedBlockingAsync(
int serverId, DateTime start, DateTime end, IReadOnlyList separate, CancellationToken cancellationToken)
{
await ScopeReadStageAsync("blocking");
@@ -164,12 +164,13 @@ private static IReadOnlyList SeparatelyMonitoredFrom(
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
if (!await reader.ReadAsync(cancellationToken))
{
- return (0, 0);
+ return (0, 0, 0);
}
return (
reader.IsDBNull(0) ? 0 : Convert.ToInt32(reader.GetValue(0)),
- reader.IsDBNull(2) ? 0L : Convert.ToInt64(reader.GetValue(2)));
+ reader.IsDBNull(2) ? 0L : Convert.ToInt64(reader.GetValue(2)),
+ reader.IsDBNull(6) ? 0 : Convert.ToInt32(reader.GetValue(6)));
}
/// The master's windowed deadlock count without the deadlocks wholly in the separately monitored databases.
diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Overview.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Overview.cs
index b3e6ebbb16..b28b0dfbf1 100644
--- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Overview.cs
+++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Overview.cs
@@ -159,7 +159,9 @@ WITH newest AS
/// and probe the window's chunks instead of every retained one. The two "ever" reads keep no bound:
/// they ask for the newest event in all of history, which a floor would change the answer to.
///
- /* #5644 W1: the two counts are WAITS (BlockingWaits.WaitsCteSql), not report rows, as the fleet tile's are. */
+ /* #5644 W1: the two counts are WAITS (BlockingWaits.WaitsCteSql), not report rows, as the fleet tile's are.
+ The last two columns are the raw report / snapshot rows (the CTE's *_marked relation holds every source row):
+ the unit the health band's per-hour count tiers were measured in, so the band reads them (#5644 review). */
public static readonly string ServerSummaryBlockingSql = @"
WITH " + BlockingWaits.WaitsCteSql(
BlockingWaits.SqlDialect.Postgres, "xe_waits",
@@ -174,7 +176,9 @@ WITH newest AS
(SELECT COUNT(*) FROM dmv_waits),
(SELECT MAX(wait_time_ms) FROM dmv_waits),
(SELECT MAX(event_time) FROM v_blocked_process_reports WHERE server_id = $1),
- (SELECT MAX(event_time) FROM v_dmv_blocking_snapshots WHERE server_id = $1)";
+ (SELECT MAX(event_time) FROM v_dmv_blocking_snapshots WHERE server_id = $1),
+ (SELECT COUNT(*) FROM xe_waits_marked),
+ (SELECT COUNT(*) FROM dmv_waits_marked)";
///
/// Deadlock count in the window plus the newest deadlock ever — the windowed count for the card value,
@@ -287,6 +291,7 @@ expressions of "one hour" is how a denominator drifts away from its numerator. *
double? memoryMb = null;
double? bufferPoolMb = null;
var blockingCount = 0;
+ var blockingReportCount = 0;
long maxBlockingWaitMs = 0;
int? lastBlockingMinutesAgo = null;
var deadlockCount = 0;
@@ -403,13 +408,15 @@ Unknown rather than Healthy. */
{
var xeCount = reader.IsDBNull(0) ? 0 : Convert.ToInt32(reader.GetValue(0));
var xeMaxWait = reader.IsDBNull(1) ? 0L : Convert.ToInt64(reader.GetValue(1));
+ var xeReports = reader.IsDBNull(6) ? 0 : Convert.ToInt32(reader.GetValue(6));
+ var dmvReports = reader.IsDBNull(7) ? 0 : Convert.ToInt32(reader.GetValue(7));
if (separate.Count > 0)
{
/* The XE arm without the separately monitored databases' reports; the DMV arm and the
XE-then-DMV fallback below are unchanged. */
try
{
- (xeCount, xeMaxWait) = await ReadScopedBlockingAsync(serverId, windowStart, scopeEnd, separate, cancellationToken);
+ (xeCount, xeMaxWait, xeReports) = await ReadScopedBlockingAsync(serverId, windowStart, scopeEnd, separate, cancellationToken);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
@@ -426,11 +433,13 @@ is better than a card with no blocking figure. */
if (xeCount > 0)
{
blockingCount = xeCount;
+ blockingReportCount = xeReports;
maxBlockingWaitMs = xeMaxWait;
}
else
{
blockingCount = dmvCount;
+ blockingReportCount = dmvReports;
maxBlockingWaitMs = dmvMaxWait;
}
@@ -544,6 +553,7 @@ know the engine (the loader stamps IsPostgres afterwards), and one arm is always
MemoryTimeoutCount = memoryTimeoutCount,
MemoryForcedCount = memoryForcedCount,
BlockingCount = blockingCount,
+ BlockingReportCount = blockingReportCount,
MaxBlockingWaitMs = maxBlockingWaitMs,
LastBlockingMinutesAgo = lastBlockingMinutesAgo,
DeadlockCount = deadlockCount,
@@ -1186,9 +1196,15 @@ decision the service's fleet card uses so the two cannot disagree about whether
/// semaphore to read (every PostgreSQL target).
public bool? MemoryPressureForBand => ServerMetricSources.DmvSourced(HasMemoryPressure, IsPostgres);
- /// Blocking events as a BANDABLE reading — null when this card reads no blocking source for
- /// this engine.
- public int? BlockingCountForBand => ServerMetricSources.DmvSourced(BlockingCount, IsPostgres);
+ /// Blocking as a BANDABLE reading — null when this card reads no blocking source for this engine.
+ /// It is the REPORT count (), not the wait count the card shows
+ /// (, #5644 W1): the band's per-hour count tiers (5 / 20) were measured in blocked-process
+ /// reports per server-hour, and a long wait is many reports and one wait.
+ public int? BlockingCountForBand => ServerMetricSources.DmvSourced(BlockingReportCount ?? BlockingCount, IsPostgres);
+
+ /// The winning source's blocked-process report (or DMV snapshot) rows in the window, before they collapse
+ /// to waits (#5644). Set by the read; null on a card built without one, which bands on .
+ public int? BlockingReportCount { get; set; }
/// Deadlocks as a BANDABLE reading (#3539). On a SQL Server card it is the count itself — a
/// graph count is an observation even at zero (#3272's engine-not-collector rule). On a PostgreSQL card
From d47ce50b54a1894035e79e147713683b7a1ea108 Mon Sep 17 00:00:00 2001
From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com>
Date: Tue, 13 Oct 2026 01:54:38 -0400
Subject: [PATCH 40/42] Band the daily summary and the fleet sweep on
blocked-process report counts; displayed counts stay waits (#5644)
---
.../DailySummaryBlockingWaitsLiveTests.cs | 94 ++++++++++++++++---
.../Darling.Tests/FleetSweepEngineTests.cs | 32 +++++++
.../ViewerCalendarRetentionPortTests.cs | 5 +-
.../Darling.Tests/ViewerDailyHealthTests.cs | 2 +-
.../FleetSweepEngine.cs | 18 +++-
.../Mcp/DarlingHealthReader.cs | 9 ++
.../DailySummaryAzureMasterScope.cs | 20 ++++
.../DailySummarySql.cs | 18 +++-
.../ViewerDataService.DailySummary.cs | 11 +++
Lite.Tests/BlockingWaitDuckDbTests.cs | 42 ++++++++-
Lite.Tests/PerformanceCalendarDataTests.cs | 4 +-
.../Services/LocalDataService.DailySummary.cs | 25 ++++-
PerformanceMonitor.Common/BlockingWaits.cs | 17 +++-
PerformanceMonitor.Common/DailyHealthBand.cs | 31 ++++--
14 files changed, 283 insertions(+), 45 deletions(-)
diff --git a/Darling/Darling.Tests/DailySummaryBlockingWaitsLiveTests.cs b/Darling/Darling.Tests/DailySummaryBlockingWaitsLiveTests.cs
index 5eb1e7fa15..0975bc04fa 100644
--- a/Darling/Darling.Tests/DailySummaryBlockingWaitsLiveTests.cs
+++ b/Darling/Darling.Tests/DailySummaryBlockingWaitsLiveTests.cs
@@ -10,10 +10,13 @@
using System.Globalization;
using System.Threading;
using System.Threading.Tasks;
+using Microsoft.Extensions.Logging.Abstractions;
using Npgsql;
using NpgsqlTypes;
using PerformanceMonitor.Collectors;
using PerformanceMonitor.Common;
+using PerformanceMonitor.Darling.Service;
+using PerformanceMonitor.Darling.Service.Mcp;
using PerformanceMonitor.Darling.Storage;
using Xunit;
@@ -46,17 +49,17 @@ private static async Task ExecAsync(NpgsqlConnection c, string sql, Cancellation
/* One wait as SQL Server reports it: a report every 5 s while the session stays blocked, the k-th carrying
wait_time_ms = k * 5 s. */
private static async Task PlantWaitAsync(
- NpgsqlConnection c, DateTime start, int blocked, int blocker, int reports, string database, bool dmv, CancellationToken ct)
+ NpgsqlConnection c, DateTime start, int blocked, int blocker, int reports, string database, bool dmv, CancellationToken ct, int spacingMs = 5000)
{
for (var k = 1; k <= reports; k++)
{
- var at = start.AddSeconds(5 * k);
+ var at = start.AddMilliseconds(spacingMs * k);
if (dmv)
await ExecAsync(c, "INSERT INTO dmv_blocking_snapshots (collection_id, collection_time, server_id, server_name, event_time, database_name, blocked_spid, blocking_spid, wait_time_ms, blocking_status) VALUES ($1,$2,$3,$4,$2,$5,$6,$7,$8,'suspended')",
- ct, CollectionIdGenerator.Next(), at, ServerId, ServerName, database, blocked, blocker, (long)(5000 * k));
+ ct, CollectionIdGenerator.Next(), at, ServerId, ServerName, database, blocked, blocker, (long)(spacingMs * k));
else
await ExecAsync(c, "INSERT INTO blocked_process_reports (blocked_report_id, collection_time, server_id, server_name, event_time, wait_time_ms, blocking_spid, blocked_spid, blocking_status, database_name) VALUES ($1,$2,$3,$4,$2,$5,$6,$7,'suspended',$8)",
- ct, CollectionIdGenerator.Next(), at, ServerId, ServerName, (long)(5000 * k), blocker, blocked, database);
+ ct, CollectionIdGenerator.Next(), at, ServerId, ServerName, (long)(spacingMs * k), blocker, blocked, database);
}
}
@@ -64,7 +67,7 @@ await ExecAsync(c, "INSERT INTO blocked_process_reports (blocked_report_id, coll
/// 2 waits, on a quiet UTC day (as DMV snapshots when ), then reads that day's
/// blocking_events through the statement, or the master-scoped one with the sibling list at $5 when
/// .
- private static async Task DayBlockingEventsAsync(bool dmv, bool scoped)
+ private static async Task<(long Events, long Reports)> DayBlockingCountsAsync(bool dmv, bool scoped)
{
var cs = ConnectionString;
Assert.SkipWhen(string.IsNullOrEmpty(cs), "Set DARLING_TEST_PG to a Postgres connection string to run the live test.");
@@ -86,16 +89,19 @@ INSERT INTO servers (server_id, server_name, display_name, is_enabled, sql_major
await PlantWaitAsync(connection, day.AddHours(3), blocked: 53, blocker: 51, reports: 6, "GP", dmv, ct);
var sql = scoped ? DailySummaryAzureMasterScope.Scope(DailySummarySql.RangeSql) : DailySummarySql.RangeSql;
- using var read = new NpgsqlCommand("SELECT blocking_events FROM (" + sql + ") AS calendar", connection);
+ using var read = new NpgsqlCommand("SELECT blocking_events, blocking_reports FROM (" + sql + ") AS calendar", connection);
read.Parameters.Add(new NpgsqlParameter { TypedValue = ServerId });
read.Parameters.Add(new NpgsqlParameter { TypedValue = day });
read.Parameters.Add(new NpgsqlParameter { TypedValue = day.AddDays(1) });
read.Parameters.Add(new NpgsqlParameter { TypedValue = EventWindowFloor.For(day) });
if (scoped)
read.Parameters.Add(new NpgsqlParameter { Value = new[] { "GP" }, NpgsqlDbType = NpgsqlDbType.Array | NpgsqlDbType.Text });
- var count = Convert.ToInt64(await read.ExecuteScalarAsync(ct), CultureInfo.InvariantCulture);
+ using var reader = await read.ExecuteReaderAsync(ct);
+ Assert.True(await reader.ReadAsync(ct));
+ var counts = (Convert.ToInt64(reader.GetValue(0), CultureInfo.InvariantCulture),
+ Convert.ToInt64(reader.GetValue(1), CultureInfo.InvariantCulture));
bodySucceeded = true;
- return count;
+ return counts;
}
finally
{
@@ -107,20 +113,85 @@ await LiveStoreCleanup.RunAsync(cs!, bodySucceeded, async (cleanup, cleanupCt) =
[Fact]
public async Task TheDay_CountsBlockedProcessWaits_NotReports()
{
- Assert.Equal(2, await DayBlockingEventsAsync(dmv: false, scoped: false));
+ var (events, reports) = await DayBlockingCountsAsync(dmv: false, scoped: false);
+ Assert.Equal(2, events);
+ Assert.Equal(15, reports); // #5644: the band's input stays the report rows
}
[Fact]
public async Task TheDay_CountsDmvSnapshotWaits_NotSnapshots_WhenThereAreNoReports()
{
- Assert.Equal(2, await DayBlockingEventsAsync(dmv: true, scoped: false));
+ var (events, reports) = await DayBlockingCountsAsync(dmv: true, scoped: false);
+ Assert.Equal(2, events);
+ Assert.Equal(15, reports); // the DMV fallback's rows, as the band counted them before waits
}
[Fact]
public async Task TheAzureMasterScope_StillSkipsTheSiblingDatabase_AndCountsWaits()
{
/* The GP wait belongs to a database monitored as its own target: the master's day keeps the one master wait. */
- Assert.Equal(1, await DayBlockingEventsAsync(dmv: false, scoped: true));
+ var (events, reports) = await DayBlockingCountsAsync(dmv: false, scoped: true);
+ Assert.Equal(1, events);
+ Assert.Equal(9, reports); // the same scope filter reaches the report count
+ }
+
+ /// #5644 review: one long wait written as 130 reports (a report every 50 ms, so the longest block is 6.5 s and
+ /// the wait arm stays out of it) is ONE blocking event, which every display and MCP count shows, but the band's count
+ /// tiers (5 and 20 per server-hour) were measured in reports, so the day bands Warning on 130 / 24 h = 5.4 per hour.
+ /// Read through the reader the calendar and the MCP tools use. On the waits-banded head the day read 1 event over 24 h
+ /// and came out Healthy.
+ [Fact]
+ public async Task TheBand_ReadsTheReportCount_WhileTheDisplayedCountStaysWaits()
+ {
+ var cs = ConnectionString;
+ Assert.SkipWhen(string.IsNullOrEmpty(cs), "Set DARLING_TEST_PG to a Postgres connection string to run the live test.");
+ var ct = TestContext.Current.CancellationToken;
+ using var connection = new NpgsqlConnection(cs);
+ await connection.OpenAsync(ct);
+ await PgMigrations.MigrateAsync(connection, ct);
+ await DeleteRowsAsync(connection, ct);
+ await using var postgres = NpgsqlDataSource.Create(DarlingStoreConnection.PinSessionTimeZoneUtc(cs!));
+ var bodySucceeded = false;
+ try
+ {
+ await ExecAsync(connection, @"
+INSERT INTO servers (server_id, server_name, display_name, is_enabled, sql_major_version, created_date, modified_date)
+VALUES ($1, $2, $2, TRUE, 16, now()::timestamp, now()::timestamp)
+ON CONFLICT (server_id) DO UPDATE SET is_enabled = TRUE", ct, ServerId, ServerName);
+
+ var day = DateTime.SpecifyKind(DateTime.UtcNow.Date.AddDays(-3), DateTimeKind.Unspecified);
+ await ExecAsync(connection, "INSERT INTO collection_log (log_id, server_id, server_name, collector_name, collection_time, status) VALUES ($1,$2,$3,'wait_stats',$4,'SUCCESS')",
+ ct, CollectionIdGenerator.Next(), ServerId, ServerName, day.AddHours(1));
+ await PlantWaitAsync(connection, day.AddHours(2), blocked: 52, blocker: 51, reports: 130, "master", dmv: false, ct, spacingMs: 50);
+
+ DarlingHealthReader.ResetRangeCacheForTests();
+ var rows = (await DarlingHealthReader.GetDailySummaryRangeAsync(
+ postgres, ServerId, day, day.AddDays(1), separatelyMonitored: null, logger: NullLogger.Instance, cancellationToken: ct)).Rows;
+ var row = Assert.Single(rows);
+
+ Assert.Equal(1, row.BlockingEvents);
+ Assert.Equal(130, row.BlockingReports);
+ Assert.Equal(6_500, row.MaxBlockDurationMs);
+ Assert.Equal(DailyHealthBand.Warning, row.HealthBand);
+ Assert.Equal(1, row.ToSignals().BlockingEvents);
+ Assert.Equal(130, row.ToSignals().BlockingReports);
+
+ /* The fleet sweep reads the same rows (#5644): its signals carry the report count its blocking band reads,
+ and the wait count it shows. */
+ var sweep = await FleetSweepEngine.ReadServerSignalsAsync(postgres, ServerId, ServerName, day, day.AddDays(1), ct);
+ Assert.Null(sweep.ReadFault);
+ Assert.Equal(1, sweep.Signals.BlockingEvents);
+ Assert.Equal(130, sweep.Signals.BlockingReports);
+ Assert.Equal(HealthSeverity.Warning, ServerHealthClassifier.BlockingSeverity(
+ sweep.Signals.BlockingReports, sweep.Signals.PeakBlockWaitMs / 1000.0, sweep.Signals.Window));
+ bodySucceeded = true;
+ }
+ finally
+ {
+ DarlingHealthReader.ResetRangeCacheForTests();
+ await LiveStoreCleanup.RunAsync(cs!, bodySucceeded, async (cleanup, cleanupCt) =>
+ await DeleteRowsAsync(cleanup, cleanupCt));
+ }
}
private static async Task DeleteRowsAsync(NpgsqlConnection connection, CancellationToken ct)
@@ -128,6 +199,7 @@ private static async Task DeleteRowsAsync(NpgsqlConnection connection, Cancellat
using var cleanup = new NpgsqlCommand(
$"DELETE FROM blocked_process_reports WHERE server_id = {ServerId}; " +
$"DELETE FROM dmv_blocking_snapshots WHERE server_id = {ServerId}; " +
+ $"DELETE FROM collection_log WHERE server_id = {ServerId}; " +
$"DELETE FROM servers WHERE server_id = {ServerId};", connection);
await cleanup.ExecuteNonQueryAsync(ct);
}
diff --git a/Darling/Darling.Tests/FleetSweepEngineTests.cs b/Darling/Darling.Tests/FleetSweepEngineTests.cs
index 4c5afe24e7..074680767b 100644
--- a/Darling/Darling.Tests/FleetSweepEngineTests.cs
+++ b/Darling/Darling.Tests/FleetSweepEngineTests.cs
@@ -613,6 +613,38 @@ public void TheBlockingFamily_FiresOnTheRateOrTheWaitArm_AndNamesWhich()
Assert.Empty(dayComposition.WouldHavePaged);
}
+ ///
+ /// #5644 review: the blocking family bands on the span's blocked-process REPORT count (the unit its 20/hr tier was
+ /// measured in), while the evidence's blocking_count stays the count of WAITS. One long wait written as 20
+ /// reports in an hour pages as it did before waits were counted; the same wait with no report count, as 1 event,
+ /// does not.
+ ///
+ [Fact]
+ public void TheBlockingFamily_BandsOnReports_AndShowsWaits()
+ {
+ var oneLongWait = new FleetSweepServerReading(1, "server-a", new DailyHealthSignals
+ {
+ HasData = true,
+ BlockingEvents = 1,
+ BlockingReports = 20,
+ Window = FixtureSpan,
+ }, 0, null);
+ var row = ComposeSimple(new[] { oneLongWait }, alertsEnabled: false)
+ .WouldHavePaged.Single(w => w.AlertFamily == FleetSweepEngine.FamilyBlocking);
+ using var evidence = JsonDocument.Parse(row.EvidenceJson);
+ Assert.Equal("blocked-process reports per hour over the sweep span", evidence.RootElement.GetProperty("trigger").GetString());
+ Assert.Equal(20.0, evidence.RootElement.GetProperty("value").GetDouble());
+ Assert.Equal(1, evidence.RootElement.GetProperty("blocking_count").GetInt64());
+
+ var withoutReports = new FleetSweepServerReading(1, "server-a", new DailyHealthSignals
+ {
+ HasData = true,
+ BlockingEvents = 1,
+ Window = FixtureSpan,
+ }, 0, null);
+ Assert.Empty(ComposeSimple(new[] { withoutReports }, alertsEnabled: false).WouldHavePaged);
+ }
+
///
/// The tiers handed to Compose are the tiers the verdicts band on (#3525) — the store's pair
/// travels into the shared scorer, so a fleet whose knobs were raised sweeps on the raised pair
diff --git a/Darling/Darling.Tests/ViewerCalendarRetentionPortTests.cs b/Darling/Darling.Tests/ViewerCalendarRetentionPortTests.cs
index 14a9c3c65c..1d198d9298 100644
--- a/Darling/Darling.Tests/ViewerCalendarRetentionPortTests.cs
+++ b/Darling/Darling.Tests/ViewerCalendarRetentionPortTests.cs
@@ -251,7 +251,10 @@ public void DailySummaryRetention_IsOneDefinition_ConsumedByBothReaders()
/* The viewer reads the aggregate's LAST column for presence, at the ordinal the MCP reader reads. */
Assert.Contains("SignalSourcesPresent = reader.IsDBNull(13) ? 0 : Convert.ToInt32(reader.GetValue(13)),", viewer, StringComparison.Ordinal);
Assert.Contains("SignalSourcesPresent = reader.IsDBNull(13) ? 0 : Convert.ToInt32(reader.GetValue(13)),", mcp, StringComparison.Ordinal);
- Assert.EndsWith("AS signal_sources_present", DailySummarySql.RangeSql.Split("FROM day_spine")[0].TrimEnd(), StringComparison.Ordinal);
+ /* #5644: blocking_reports (the band's report count) was appended after it, at ordinal 14 and last. */
+ var projection = DailySummarySql.RangeSql.Split("FROM day_spine")[0].TrimEnd();
+ Assert.Contains("AS signal_sources_present,", projection, StringComparison.Ordinal);
+ Assert.EndsWith("AS blocking_reports", projection, StringComparison.Ordinal);
}
///
diff --git a/Darling/Darling.Tests/ViewerDailyHealthTests.cs b/Darling/Darling.Tests/ViewerDailyHealthTests.cs
index 92f0cd878a..074f8ce735 100644
--- a/Darling/Darling.Tests/ViewerDailyHealthTests.cs
+++ b/Darling/Darling.Tests/ViewerDailyHealthTests.cs
@@ -574,7 +574,7 @@ trigger this replaced. */
Assert.Equal(2, summary!.BlockingEvents);
Assert.Equal(0, summary.DeadlockCount);
Assert.Equal("Healthy", summary.OverallHealth);
- Assert.Contains("2 blocking events (0.1/hr)", summary.SignalsTooltip, StringComparison.Ordinal);
+ Assert.Contains("2 blocking events (0.1 reports/hr)", summary.SignalsTooltip, StringComparison.Ordinal);
bodySucceeded = true;
}
diff --git a/Darling/PerformanceMonitor.Darling.Service/FleetSweepEngine.cs b/Darling/PerformanceMonitor.Darling.Service/FleetSweepEngine.cs
index 70b3d5477a..2391c0fb4d 100644
--- a/Darling/PerformanceMonitor.Darling.Service/FleetSweepEngine.cs
+++ b/Darling/PerformanceMonitor.Darling.Service/FleetSweepEngine.cs
@@ -752,17 +752,21 @@ wait row names the wait bar. */
/* The SIGNALS' peak, not the reading's: Classify sees only the signals, and the two must agree.
The production read fills both from one MAX. */
var peakBlockSeconds = signals.PeakBlockWaitMs / 1000.0;
- if (ServerHealthClassifier.BlockingSeverity(signals.BlockingEvents, peakBlockSeconds, signals.Window)
+ /* #5644: the band's count is the span's blocked-process REPORT rows (DailyHealthSignals.BlockingReports), the
+ unit its rate tiers were measured in; BlockingEvents counts WAITS and is what the evidence shows as the
+ count. Classify reads the same pair, so the ledger still cannot page on a count the verdict did not band. */
+ var bandedBlocking = signals.BlockingReports ?? signals.BlockingEvents;
+ if (ServerHealthClassifier.BlockingSeverity(bandedBlocking, peakBlockSeconds, signals.Window)
== HealthSeverity.Critical)
{
- yield return (FamilyBlocking, BlockingEvidence(signals.BlockingEvents, signals.Window, peakBlockSeconds));
+ yield return (FamilyBlocking, BlockingEvidence(signals.BlockingEvents, bandedBlocking, signals.BlockingReports.HasValue, signals.Window, peakBlockSeconds));
}
}
/// The blocking family's evidence (#3539 A3): the arm that banded is the one named. A 60 s
/// block is the wait arm's Critical whatever the rate; otherwise the RATE is the value, with the raw
/// count as its own member for the operator reconciling against the blocking grid.
- private static string BlockingEvidence(long count, TimeSpan window, double peakBlockSeconds)
+ private static string BlockingEvidence(long count, long bandedCount, bool bandedOnReports, TimeSpan window, double peakBlockSeconds)
{
if (peakBlockSeconds >= ServerHealthThresholds.BlockingCriticalWaitSeconds)
{
@@ -778,11 +782,13 @@ private static string BlockingEvidence(long count, TimeSpan window, double peakB
/* Critical without the wait arm implies a rateable window at or past the Critical tier, so the
rate is present by construction. */
- var ratePerHour = ServerHealthClassifier.BlockingRatePerHour(count, window)!.Value;
+ var ratePerHour = ServerHealthClassifier.BlockingRatePerHour(bandedCount, window)!.Value;
return JsonSerializer.Serialize(new
{
derivation = "summary-scoring critical trigger under alerts_enabled: false — not an alert-engine replay",
- trigger = "blocking events per hour over the sweep span",
+ trigger = bandedOnReports
+ ? "blocked-process reports per hour over the sweep span"
+ : "blocking events per hour over the sweep span",
value = ratePerHour,
threshold = ServerHealthThresholds.BlockingCriticalPerHour,
blocking_count = count,
@@ -979,6 +985,8 @@ internal static async Task ReadServerSignalsAsync(
CollectionRuns = rows.Sum(r => r.CollectionRuns),
HighCpuEvents = rows.Sum(r => r.HighCpuEvents),
BlockingEvents = rows.Sum(r => r.BlockingEvents),
+ /* #5644: the report rows the band's count tiers were measured in; a hand-built row without them bands its events. */
+ BlockingReports = rows.Sum(r => r.BlockingReports ?? r.BlockingEvents),
/* #3539 A2: the longest block across the span's day buckets — a MAX, not a sum, because it
is a magnitude; the blocking band's wait arm reads it. */
PeakBlockWaitMs = peakBlock,
diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingHealthReader.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingHealthReader.cs
index 5f12dac077..e3ac56a029 100644
--- a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingHealthReader.cs
+++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingHealthReader.cs
@@ -303,6 +303,12 @@ public sealed record DailySummaryReadRow(
/// from a day the purge has not reached.
public int SignalSourcesPresent { get; init; }
+ /// The blocked-process report rows behind (#5644), from the aggregate's
+ /// trailing blocking_reports column. counts WAITS and is what every
+ /// payload shows; the band's count tiers were measured in reports, so the band reads this. null on a
+ /// hand-built row, which bands its .
+ public long? BlockingReports { get; init; }
+
public DailyHealthSignals ToSignals() => new()
{
/* #3541 A9: a purged or past-horizon day is a NoData day to the band, whatever the spine still
@@ -316,6 +322,7 @@ zero IS a measurement and the band stands. */
CollectionRuns = CollectionRuns,
HighCpuEvents = HighCpuEvents,
BlockingEvents = BlockingEvents,
+ BlockingReports = BlockingReports,
/* #3539 A2: the day bands blocking through the card's BlockingSeverity, whose wait arm reads the
longest block — so the peak travels in the signals rather than only into the reasons line. */
PeakBlockWaitMs = MaxBlockDurationMs,
@@ -731,5 +738,7 @@ positional reads above stay where they were. */
CollectionRuns = reader.IsDBNull(12) ? 0L : Convert.ToInt64(reader.GetValue(12)),
/* #3541 A9: the signal-presence count, after collection_runs. */
SignalSourcesPresent = reader.IsDBNull(13) ? 0 : Convert.ToInt32(reader.GetValue(13)),
+ /* #5644: the report count the band reads, after signal_sources_present. */
+ BlockingReports = reader.IsDBNull(14) ? 0L : Convert.ToInt64(reader.GetValue(14)),
};
}
diff --git a/Darling/PerformanceMonitor.Darling.Storage/DailySummaryAzureMasterScope.cs b/Darling/PerformanceMonitor.Darling.Storage/DailySummaryAzureMasterScope.cs
index b910e1b09e..89e5efe498 100644
--- a/Darling/PerformanceMonitor.Darling.Storage/DailySummaryAzureMasterScope.cs
+++ b/Darling/PerformanceMonitor.Darling.Storage/DailySummaryAzureMasterScope.cs
@@ -49,6 +49,10 @@ public static class DailySummaryAzureMasterScope
private const string DeadlockSelectLine = "SELECT date_trunc('day', deadlock_time) AS d, COUNT(*) AS c";
private const string BprSelectLine = "SELECT date_trunc('day', event_time) AS d, COUNT(*) AS c, MAX(wait_time_ms) AS max_wait_ms";
+ // #5644: the report count the health band reads, the bpr CTE's next select line. It takes the same scope filter
+ // as the wait count beside it, or a scoped day would band on reports of waits it no longer counts.
+ private const string BprReportsLine = "SUM(wait_reports)::bigint AS reports";
+
/// The graphs the every-process rule still has to decide, one row per stored deadlock: the row's
/// database is unknown, is master, or is a listed one. $1 server_id, $2/$3 the half-open window as
/// reads it, $4 the event-window floor for $2, $5 the list.
@@ -74,9 +78,25 @@ public static string Scope(string routedSql)
scoped = ReplaceOnce(scoped, BprSelectLine,
"SELECT date_trunc('day', event_time) AS d, COUNT(*) FILTER (WHERE " + BprKeepPredicate + ") AS c, "
+ "MAX(wait_time_ms) FILTER (WHERE " + BprKeepPredicate + ") AS max_wait_ms");
+ /* The line after it is the bpr CTE's own (the dmv CTE's reports column is the same text, so the bpr select line
+ comes first): replace the FIRST occurrence only, which is the bpr one. */
+ scoped = ReplaceFirst(scoped, BprReportsLine,
+ "SUM(wait_reports) FILTER (WHERE " + BprKeepPredicate + ")::bigint AS reports");
return scoped;
}
+ private static string ReplaceFirst(string sql, string fragment, string replacement)
+ {
+ var at = sql.IndexOf(fragment, StringComparison.Ordinal);
+ if (at < 0)
+ {
+ throw new InvalidOperationException(
+ "Daily-summary master scoping found no '" + fragment + "' to replace: DailySummarySql.RangeSql has drifted from DailySummaryAzureMasterScope (#5644).");
+ }
+
+ return sql.Remove(at, fragment.Length).Insert(at, replacement);
+ }
+
private static string ReplaceOnce(string sql, string fragment, string replacement)
{
var at = sql.IndexOf(fragment, StringComparison.Ordinal);
diff --git a/Darling/PerformanceMonitor.Darling.Storage/DailySummarySql.cs b/Darling/PerformanceMonitor.Darling.Storage/DailySummarySql.cs
index 4d4ac16bc6..c97b673ebd 100644
--- a/Darling/PerformanceMonitor.Darling.Storage/DailySummarySql.cs
+++ b/Darling/PerformanceMonitor.Darling.Storage/DailySummarySql.cs
@@ -92,12 +92,14 @@ GROUP BY 1
bpr AS (
/* #5644: counts WAITS, not report rows (BlockingWaits.WaitsCteSql, the one definition the alert, the
fleet tile and the MCP counts share), bucketed on the day of the wait's longest report. */
- SELECT date_trunc('day', event_time) AS d, COUNT(*) AS c, MAX(wait_time_ms) AS max_wait_ms
+ SELECT date_trunc('day', event_time) AS d, COUNT(*) AS c, MAX(wait_time_ms) AS max_wait_ms,
+ SUM(wait_reports)::bigint AS reports
FROM bpr_waits
GROUP BY 1
),
dmv AS (
- SELECT date_trunc('day', collection_time) AS d, COUNT(*) AS c, MAX(wait_time_ms) AS max_wait_ms
+ SELECT date_trunc('day', collection_time) AS d, COUNT(*) AS c, MAX(wait_time_ms) AS max_wait_ms,
+ SUM(wait_reports)::bigint AS reports
FROM dmv_waits
GROUP BY 1
),
@@ -208,7 +210,13 @@ purged shell whose one surviving "signal" is a day the tier never carried into p
+ (CASE WHEN b.d IS NULL THEN 0 ELSE 1 END)
+ (CASE WHEN dm.d IS NULL THEN 0 ELSE 1 END)
+ (CASE WHEN cp.d IS NULL THEN 0 ELSE 1 END)
- + (CASE WHEN m.d IS NULL THEN 0 ELSE 1 END) AS signal_sources_present
+ + (CASE WHEN m.d IS NULL THEN 0 ELSE 1 END) AS signal_sources_present,
+ /* #5644: the day's blocked-process REPORT rows (DMV snapshot rows on the fallback), from the SAME source
+ and day bucket as blocking_events, which counts WAITS. The health band's count tiers
+ (ServerHealthThresholds.BlockingWarnPerHour/CriticalPerHour) were measured in reports per server-hour,
+ so the band reads this column; every displayed count stays blocking_events. Appended LAST, after
+ signal_sources_present, so every existing ordinal read stays where it was. */
+ CASE WHEN COALESCE(b.c, 0) > 0 THEN b.reports ELSE COALESCE(dm.reports, 0) END AS blocking_reports
FROM day_spine s
LEFT JOIN waits w ON w.d = s.d
LEFT JOIN queries q ON q.d = s.d
@@ -234,10 +242,10 @@ ORDER BY s.d
BlockingWaits.WaitsCteSql(
BlockingWaits.SqlDialect.Postgres, "bpr_waits",
@"SELECT blocked_spid, blocking_spid, event_time, wait_time_ms, database_name FROM v_blocked_process_reports
- WHERE server_id = $1 AND event_time >= $2 AND event_time < $3 AND collection_time >= $4") + "," + BlockingWaits.WaitsCteSql(
+ WHERE server_id = $1 AND event_time >= $2 AND event_time < $3 AND collection_time >= $4", reportCount: true) + "," + BlockingWaits.WaitsCteSql(
BlockingWaits.SqlDialect.Postgres, "dmv_waits",
@"SELECT blocked_spid, blocking_spid, event_time, collection_time, wait_time_ms FROM v_dmv_blocking_snapshots
- WHERE server_id = $1 AND collection_time >= $2 AND collection_time < $3") + ",",
+ WHERE server_id = $1 AND collection_time >= $2 AND collection_time < $3", reportCount: true) + ",",
StringComparison.Ordinal);
///
diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.DailySummary.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.DailySummary.cs
index dffa0cbb2d..d17eb7bc7c 100644
--- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.DailySummary.cs
+++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.DailySummary.cs
@@ -67,6 +67,7 @@ private sealed record RawDailySummaryDay
public long? UniqueQueries { get; init; }
public long DeadlockCount { get; init; }
public long BlockingEvents { get; init; }
+ public long BlockingReports { get; init; }
public long HighCpuEvents { get; init; }
public long CollectionErrors { get; init; }
public long MemoryPressureEvents { get; init; }
@@ -290,6 +291,8 @@ skipped. The MCP reader (DarlingHealthReader) makes the same read at the same or
/* #3541 A9 / #3653: the aggregate's LAST column, how many of the seven signal sources hold a row for
the day — the fact that tells a purged shell from a day the purge has not reached. */
SignalSourcesPresent = reader.IsDBNull(13) ? 0 : Convert.ToInt32(reader.GetValue(13)),
+ /* #5644: the report count the band reads (blocking_events counts WAITS), after signal_sources_present. */
+ BlockingReports = reader.IsDBNull(14) ? 0L : Convert.ToInt64(reader.GetValue(14)),
};
/// #4232: the banding half of the old ReadDailySummaryRow — stamps a raw parsed day with the
@@ -306,6 +309,7 @@ private static DailySummaryRow ToDailySummaryRow(RawDailySummaryDay raw, DailyHe
UniqueQueries = raw.UniqueQueries,
DeadlockCount = raw.DeadlockCount,
BlockingEvents = raw.BlockingEvents,
+ BlockingReports = raw.BlockingReports,
HighCpuEvents = raw.HighCpuEvents,
CollectionErrors = raw.CollectionErrors,
MemoryPressureEvents = raw.MemoryPressureEvents,
@@ -347,6 +351,12 @@ public class DailySummaryRow
public long? UniqueQueries { get; set; }
public long DeadlockCount { get; set; }
public long BlockingEvents { get; set; }
+
+ /// The blocked-process report rows behind (#5644). The band's count tiers were
+ /// measured in reports, so carries this for the band while every display keeps
+ /// , the count of WAITS. null on a hand-built row, which bands its
+ /// .
+ public long? BlockingReports { get; set; }
public long HighCpuEvents { get; set; }
public long MemoryPressureEvents { get; set; }
public long MemoryCriticalEvents { get; set; }
@@ -407,6 +417,7 @@ row and the MCP reader's row make. */
CollectionRuns = CollectionRuns,
HighCpuEvents = HighCpuEvents,
BlockingEvents = BlockingEvents,
+ BlockingReports = BlockingReports,
/* #3539 A2: the blocking band's wait arm reads the longest block, so the peak rides in the signals. */
PeakBlockWaitMs = MaxBlockDurationMs,
MemoryPressureEvents = MemoryPressureEvents,
diff --git a/Lite.Tests/BlockingWaitDuckDbTests.cs b/Lite.Tests/BlockingWaitDuckDbTests.cs
index 9664afd12a..137a413cc6 100644
--- a/Lite.Tests/BlockingWaitDuckDbTests.cs
+++ b/Lite.Tests/BlockingWaitDuckDbTests.cs
@@ -73,12 +73,13 @@ await ExecAsync(
/// moves each report's wait time off the exact value, alternately up and down, so consecutive derived starts differ by twice that.
///
private async Task SeedWaitAsync(
- int blocked, int blocker, DateTime start, int reports, int intervalSeconds = 5, int jitterMs = 0, bool dmv = false)
+ int blocked, int blocker, DateTime start, int reports, int intervalSeconds = 5, int jitterMs = 0, bool dmv = false, int intervalMs = 0)
{
for (var k = 0; k < reports; k++)
{
- var waitMs = ((k + 1) * intervalSeconds * 1000L) + (k % 2 == 0 ? jitterMs : -jitterMs);
- var eventTime = start.AddSeconds((k + 1) * intervalSeconds);
+ var step = intervalMs > 0 ? intervalMs : intervalSeconds * 1000;
+ var waitMs = ((k + 1) * step * 1L) + (k % 2 == 0 ? jitterMs : -jitterMs);
+ var eventTime = start.AddMilliseconds((k + 1) * step);
_seeded.Add(new Report(blocked, blocker, eventTime, waitMs));
if (dmv)
await ExecAsync(
@@ -219,6 +220,41 @@ public async Task TheServerTabBadgeAndTheDailySummary_CountWaitsOnTheDmvFallback
Assert.Equal(2, await DailySummaryBlockingEventsAsync());
}
+ /// #5644 review: one long wait written as 130 reports (a report every 50 ms, so the longest block is 6.5 s and
+ /// the wait arm stays out of it) is ONE blocking event, which the overview card, the badge and the MCP counts show,
+ /// but the band's count tiers (5 and 20 per server-hour) were measured in reports, so the day bands Warning on
+ /// 130 / 24 h = 5.4 per hour. On the waits-banded head the day read 1 event over 24 h and came out Healthy. The
+ /// overview card's last-hour count stays one wait, and that card does not band by the tiers at all (it is elevated
+ /// on any blocking).
+ [Fact]
+ public async Task TheDailyBand_ReadsTheReportCount_WhileTheDisplayedCountsStayWaits()
+ {
+ var yesterday = DateTime.UtcNow.Date.AddDays(-1);
+ await SeedWaitAsync(52, 51, yesterday.AddHours(2), reports: 130, intervalMs: 50);
+
+ var row = Assert.Single(await new LocalDataService(_duckDb).GetDailySummaryRangeAsync(ServerId, yesterday, yesterday.AddDays(1)));
+
+ Assert.Equal(1, row.BlockingEvents);
+ Assert.Equal(130, row.BlockingReports);
+ Assert.Equal(6_500, row.MaxBlockDurationMs);
+ Assert.Equal(DailyHealthBand.Warning, row.HealthBand);
+ Assert.Equal(1, CSharpWaitCount());
+ }
+
+ /// The report count follows the DMV snapshot fallback too (snapshot rows are what the band counted before waits).
+ [Fact]
+ public async Task TheDailyReportCount_FollowsTheDmvFallback()
+ {
+ var yesterday = DateTime.UtcNow.Date.AddDays(-1);
+ await SeedWaitAsync(52, 51, yesterday.AddHours(2), reports: 11, dmv: true);
+ await SeedWaitAsync(53, 51, yesterday.AddHours(3), reports: 6, dmv: true);
+
+ var row = Assert.Single(await new LocalDataService(_duckDb).GetDailySummaryRangeAsync(ServerId, yesterday, yesterday.AddDays(1)));
+
+ Assert.Equal(2, row.BlockingEvents);
+ Assert.Equal(17, row.BlockingReports);
+ }
+
/* ── #5644 wave-1 review: sub-millisecond starts and NULL times ── */
private sealed record Edge(int Blocked, int Blocker, DateTime? Time, long? Wait);
diff --git a/Lite.Tests/PerformanceCalendarDataTests.cs b/Lite.Tests/PerformanceCalendarDataTests.cs
index 2b0403cdf1..795ee09a0a 100644
--- a/Lite.Tests/PerformanceCalendarDataTests.cs
+++ b/Lite.Tests/PerformanceCalendarDataTests.cs
@@ -236,14 +236,14 @@ public async Task GetDailySummaryRange_BucketsEachDay_AndBandsViaSharedCalculato
Assert.Equal(DailyHealthBand.Warning, byDate[Day(9)].HealthBand);
Assert.Equal(15_000, byDate[Day(9)].MaxBlockDurationMs);
- Assert.Contains("1 blocking event (0.0/hr, peak block 15.0 s)", byDate[Day(9)].SignalsTooltip, StringComparison.Ordinal);
+ Assert.Contains("1 blocking event (0.0 reports/hr, peak block 15.0 s)", byDate[Day(9)].SignalsTooltip, StringComparison.Ordinal);
Assert.Equal(DailyHealthBand.Warning, byDate[Day(10)].HealthBand);
Assert.Equal(1, byDate[Day(10)].AlertCount); // dismissed + resolution excluded
Assert.Equal(DailyHealthBand.Warning, byDate[Day(11)].HealthBand);
Assert.Equal(120, byDate[Day(11)].BlockingEvents);
- Assert.Contains("120 blocking events (5.0/hr, peak block 6.0 s)", byDate[Day(11)].SignalsTooltip, StringComparison.Ordinal);
+ Assert.Contains("120 blocking events (5.0 reports/hr, peak block 6.0 s)", byDate[Day(11)].SignalsTooltip, StringComparison.Ordinal);
Assert.Equal(DailyHealthBand.Healthy, byDate[Day(12)].HealthBand);
Assert.True(byDate[Day(12)].HasData);
diff --git a/Lite/Services/LocalDataService.DailySummary.cs b/Lite/Services/LocalDataService.DailySummary.cs
index caede84489..9c3b34f74a 100644
--- a/Lite/Services/LocalDataService.DailySummary.cs
+++ b/Lite/Services/LocalDataService.DailySummary.cs
@@ -55,20 +55,22 @@ GROUP BY 1
" + BlockingWaits.WaitsCteSql(
BlockingWaits.SqlDialect.DuckDb, "bpr_waits",
@"SELECT blocked_spid, blocking_spid, event_time, wait_time_ms
- FROM " + StoredEventCopies.BlockedProcessReports("server_id = $1 AND event_time >= $2 AND event_time < $3/*scope*/") + @" AS ev") + "," + BlockingWaits.WaitsCteSql(
+ FROM " + StoredEventCopies.BlockedProcessReports("server_id = $1 AND event_time >= $2 AND event_time < $3/*scope*/") + @" AS ev", reportCount: true) + "," + BlockingWaits.WaitsCteSql(
BlockingWaits.SqlDialect.DuckDb, "dmv_waits",
@"SELECT blocked_spid, blocking_spid, event_time, collection_time, wait_time_ms
FROM v_dmv_blocking_snapshots
- WHERE server_id = $1 AND collection_time >= $2 AND collection_time < $3/*scope*/") + @",
+ WHERE server_id = $1 AND collection_time >= $2 AND collection_time < $3/*scope*/", reportCount: true) + @",
bpr AS (
/* #5644: counts WAITS, not report rows (BlockingWaits.WaitsCteSql, the definition the alert, the Overview tile
and the analysis fact share), bucketed on the day of the wait's longest report. */
- SELECT date_trunc('day', event_time) AS d, COUNT(*) AS c, MAX(wait_time_ms) AS max_wait_ms
+ SELECT date_trunc('day', event_time) AS d, COUNT(*) AS c, MAX(wait_time_ms) AS max_wait_ms,
+ CAST(SUM(wait_reports) AS BIGINT) AS reports
FROM bpr_waits
GROUP BY 1
),
dmv AS (
- SELECT date_trunc('day', collection_time) AS d, COUNT(*) AS c, MAX(wait_time_ms) AS max_wait_ms
+ SELECT date_trunc('day', collection_time) AS d, COUNT(*) AS c, MAX(wait_time_ms) AS max_wait_ms,
+ CAST(SUM(wait_reports) AS BIGINT) AS reports
FROM dmv_waits
GROUP BY 1
),
@@ -162,7 +164,11 @@ share. Appended after peak_block_wait_ms so every existing ordinal read stays wh
+ (CASE WHEN b.d IS NULL THEN 0 ELSE 1 END)
+ (CASE WHEN dm.d IS NULL THEN 0 ELSE 1 END)
+ (CASE WHEN cp.d IS NULL THEN 0 ELSE 1 END)
- + (CASE WHEN m.d IS NULL THEN 0 ELSE 1 END) AS signal_sources_present
+ + (CASE WHEN m.d IS NULL THEN 0 ELSE 1 END) AS signal_sources_present,
+ /* #5644: the day's blocked-process REPORT rows (DMV snapshot rows on the fallback), from the SAME source and day
+ bucket as blocking_events, which counts WAITS. The health band's count tiers were measured in reports per
+ server-hour, so the band reads this column; every displayed count stays blocking_events. Appended LAST. */
+ CASE WHEN COALESCE(b.c, 0) > 0 THEN b.reports ELSE COALESCE(dm.reports, 0) END AS blocking_reports
FROM day_spine s
LEFT JOIN waits w ON w.d = s.d
LEFT JOIN queries q ON q.d = s.d
@@ -289,6 +295,8 @@ private static DailySummaryRow ReadDailySummaryRow(System.Data.Common.DbDataRead
CollectionRuns = reader.IsDBNull(12) ? 0L : Convert.ToInt64(reader.GetValue(12)),
/* #3541 A9: the signal-presence count, after collection_runs. */
SignalSourcesPresent = reader.IsDBNull(13) ? 0 : Convert.ToInt32(reader.GetValue(13)),
+ /* #5644: the report count the band reads (blocking_events counts WAITS), after signal_sources_present. */
+ BlockingReports = reader.IsDBNull(14) ? 0L : Convert.ToInt64(reader.GetValue(14)),
HasData = true,
};
if (scopedDeadlocks != null)
@@ -317,6 +325,12 @@ public class DailySummaryRow
public long UniqueQueries { get; set; }
public long DeadlockCount { get; set; }
public long BlockingEvents { get; set; }
+
+ /// The blocked-process report rows behind (#5644). The band's count tiers were
+ /// measured in reports, so carries this for the band while every display keeps
+ /// , the count of WAITS. null on a hand-built row, which bands its
+ /// .
+ public long? BlockingReports { get; set; }
public long HighCpuEvents { get; set; }
public long MemoryPressureEvents { get; set; }
public long MemoryCriticalEvents { get; set; }
@@ -378,6 +392,7 @@ public class DailySummaryRow
CollectionRuns = CollectionRuns,
HighCpuEvents = HighCpuEvents,
BlockingEvents = BlockingEvents,
+ BlockingReports = BlockingReports,
/* #3539 A2: the blocking band's wait arm reads the longest block, so the peak rides in the signals. */
PeakBlockWaitMs = MaxBlockDurationMs,
MemoryPressureEvents = MemoryPressureEvents,
diff --git a/PerformanceMonitor.Common/BlockingWaits.cs b/PerformanceMonitor.Common/BlockingWaits.cs
index f85c33fb04..cc3a8b0016 100644
--- a/PerformanceMonitor.Common/BlockingWaits.cs
+++ b/PerformanceMonitor.Common/BlockingWaits.cs
@@ -26,7 +26,9 @@ namespace PerformanceMonitor.Common;
/// being read, so two reports of one (blocked, blocker) pair belong to the same wait when their derived starts are
/// within of the previous one (reports sorted by derived start). A different
/// wait of the same pair that was reported at all lasted at least the threshold (5 s) before its first report,
-/// so its start is at least that far from the previous wait's start; the tolerance sits well below it.
+/// so its start is at least that far from the previous wait's start; the tolerance sits well below it. That
+/// holds for blocked-process reports only. A DMV blocking snapshot catches a wait of any length, so two separate
+/// short waits of one pair can start less than 2 s apart and count as one wait; issue #5743 tracks the fix.
///
/// The same rule is written twice because the counts live in two places: for rows
/// already in memory (the alert engine's read), and for the counts that run in the
@@ -122,12 +124,21 @@ public enum SqlDialect
/// wait_time_ms is the longest wait of that wait's reports and whose other columns are that
/// report's. The caller continues the WITH list after it (the text ends with the closing parenthesis, no
/// trailing comma).
+ ///
+ /// adds a wait_reports column to the chain's last CTE: how many source
+ /// rows (blocked-process reports, or DMV snapshot rows) the wait collapsed. It is the count a health band
+ /// whose tiers were measured in REPORTS per server-hour reads (#5644), while the displayed count stays one per
+ /// wait. Off by default so every other caller's CTE keeps its shape.
///
public static string WaitsCteSql(
- SqlDialect dialect, string name, string source, IReadOnlyList? partitionColumns = null)
+ SqlDialect dialect, string name, string source, IReadOnlyList? partitionColumns = null,
+ bool reportCount = false)
{
var partition = string.Join(
", ", (partitionColumns ?? Array.Empty()).Concat(new[] { "blocked_spid", "blocking_spid" }));
+ var reportColumn = reportCount
+ ? $", COUNT(*) OVER (PARTITION BY {partition}, n.wait_no) AS wait_reports"
+ : string.Empty;
var epochMs = dialect == SqlDialect.DuckDb
? "(CAST(epoch_us(s.event_time) AS DOUBLE) / 1000.0)"
: "(date_part('epoch', s.event_time) * 1000.0)";
@@ -154,7 +165,7 @@ END AS starts_wait
),
{name}_ranked AS (
SELECT n.*,
- ROW_NUMBER() OVER (PARTITION BY {partition}, n.wait_no ORDER BY n.wait_time_ms DESC NULLS LAST, n.event_time DESC NULLS LAST) AS wait_rank
+ ROW_NUMBER() OVER (PARTITION BY {partition}, n.wait_no ORDER BY n.wait_time_ms DESC NULLS LAST, n.event_time DESC NULLS LAST) AS wait_rank{reportColumn}
FROM {name}_numbered AS n
),
{name} AS (
diff --git a/PerformanceMonitor.Common/DailyHealthBand.cs b/PerformanceMonitor.Common/DailyHealthBand.cs
index 6d0783923e..adb9176412 100644
--- a/PerformanceMonitor.Common/DailyHealthBand.cs
+++ b/PerformanceMonitor.Common/DailyHealthBand.cs
@@ -103,11 +103,20 @@ public readonly record struct DailyHealthSignals
/// rate — see .
public long HighCpuEvents { get; init; }
- /// Blocking events in the window (blocked-process reports, falling back to DMV blocking
- /// snapshots). Banded as a RATE over through the card band's own
+ /// Blocking events in the window, counted as WAITS (#5644): one per blocked session, blocker and wait
+ /// start, from blocked-process reports and falling back to DMV blocking snapshots. The figure every surface
+ /// DISPLAYS. The band does not read it when is set.
+ public long BlockingEvents { get; init; }
+
+ /// The blocked-process REPORT rows (DMV snapshot rows on the fallback) behind
+ /// (#5644), or null when the producer does not carry the report count,
+ /// in which case is banded. SQL Server writes about eleven reports for one
+ /// one-minute wait, and and
+ /// were measured in reports per server-hour, so the
+ /// band keeps that unit: it reads this count as a RATE over through the card band's own
/// (#3539 A2/A3), together with
/// .
- public long BlockingEvents { get; init; }
+ public long? BlockingReports { get; init; }
/// The longest single block in the window, in milliseconds (#3539 A2) — the blocking
/// band's rate-independent wait arm: a 60-second block is Critical and a 10-second one Warning
@@ -266,7 +275,7 @@ arm are documented on ServerHealthThresholds. Delegating rather than restating i
undeclared span) is the fallback this classifier wants: a non-zero count reads Warning, a zero
count (Unknown) stays out of the blocking trigger. */
var blockingSeverity = ServerHealthClassifier.BlockingSeverity(
- signals.BlockingEvents, signals.PeakBlockWaitMs / 1000.0, signals.Window);
+ signals.BlockingReports ?? signals.BlockingEvents, signals.PeakBlockWaitMs / 1000.0, signals.Window);
var cpuSeverity = HighCpuSeverity(signals.HighCpuEvents, signals.Window, t);
var collectionSeverity = CollectionErrorSeverity(signals.CollectionErrors, signals.CollectionRuns);
@@ -626,9 +635,12 @@ private static void AppendCollectionErrors(List lines, in DailyHealthSig
lines.Add(line);
}
- /// The blocking line — the count, then the per-hour rate the band evaluated when the window
- /// is rateable (#3539 A3, the deadlock line's rule), then the peak block duration when one is known,
- /// e.g. "42 blocking events (1.8/hr, peak block 12.5 s)". An unrateable window prints no rate.
+ /// The blocking line — the count (of WAITS, #5644), then the per-hour rate the band evaluated when the
+ /// window is rateable (#3539 A3, the deadlock line's rule), then the peak block duration when one is known,
+ /// e.g. "42 blocking events (1.8/hr, peak block 12.5 s)". The band's tiers are measured in blocked-process
+ /// reports per hour, so when is carried the rate is that
+ /// count's and says so ("19.8 reports/hr"), instead of a wait count's rate the band never read. An unrateable
+ /// window prints no rate.
private static void AppendBlocking(List lines, in DailyHealthSignals signals, long peakBlockMs)
{
var count = signals.BlockingEvents;
@@ -639,9 +651,10 @@ private static void AppendBlocking(List lines, in DailyHealthSignals sig
var line = count.ToString("N0", CultureInfo.InvariantCulture) + " " + noun;
var qualifiers = new List(2);
- var rate = ServerHealthClassifier.BlockingRatePerHour(count, signals.Window);
+ var rate = ServerHealthClassifier.BlockingRatePerHour(signals.BlockingReports ?? count, signals.Window);
if (rate.HasValue)
- qualifiers.Add(rate.Value.ToString("0.0", CultureInfo.InvariantCulture) + "/hr");
+ qualifiers.Add(rate.Value.ToString("0.0", CultureInfo.InvariantCulture)
+ + (signals.BlockingReports.HasValue ? " reports/hr" : "/hr"));
if (peakBlockMs > 0)
qualifiers.Add("peak block " + FormatDurationMs(peakBlockMs));
if (qualifiers.Count > 0)
From 6e8fa8aa89cf83c6484e030d45f8ec662ee06836 Mon Sep 17 00:00:00 2001
From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com>
Date: Tue, 13 Oct 2026 02:50:19 -0400
Subject: [PATCH 41/42] The fleet bounded-read reference query returns the
band's report counts too (#5644)
---
Darling/Darling.Tests/FleetReadsAreBoundedByTheFleetTests.cs | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
diff --git a/Darling/Darling.Tests/FleetReadsAreBoundedByTheFleetTests.cs b/Darling/Darling.Tests/FleetReadsAreBoundedByTheFleetTests.cs
index f00c433488..aee29b590a 100644
--- a/Darling/Darling.Tests/FleetReadsAreBoundedByTheFleetTests.cs
+++ b/Darling/Darling.Tests/FleetReadsAreBoundedByTheFleetTests.cs
@@ -319,7 +319,9 @@ FROM v_cpu_scheduler_stats
COALESCE(xe.cnt, 0) AS xe_count,
COALESCE(xe.max_wait, 0) AS xe_max_wait,
COALESCE(dmv.cnt, 0) AS dmv_count,
- COALESCE(dmv.max_wait, 0) AS dmv_max_wait
+ COALESCE(dmv.max_wait, 0) AS dmv_max_wait,
+ COALESCE(xe.cnt, 0) AS xe_reports,
+ COALESCE(dmv.cnt, 0) AS dmv_reports
FROM
(
SELECT server_id, COUNT(*) AS cnt, MAX(wait_time_ms) AS max_wait
From 3f6f8ae778696d3f30217fb81bffde39b8c7a979 Mon Sep 17 00:00:00 2001
From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com>
Date: Tue, 13 Oct 2026 09:51:52 -0400
Subject: [PATCH 42/42] Pin that the daily summary SQL carries the skip
collector's name (#5644)
---
Lite.Tests/CollectionCycleSkipTests.cs | 566 +++++++++++++++++++++++++
1 file changed, 566 insertions(+)
create mode 100644 Lite.Tests/CollectionCycleSkipTests.cs
diff --git a/Lite.Tests/CollectionCycleSkipTests.cs b/Lite.Tests/CollectionCycleSkipTests.cs
new file mode 100644
index 0000000000..d2bd6a0577
--- /dev/null
+++ b/Lite.Tests/CollectionCycleSkipTests.cs
@@ -0,0 +1,566 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Runtime.CompilerServices;
+using System.Threading;
+using System.Threading.Tasks;
+using DuckDB.NET.Data;
+using Microsoft.Extensions.Logging;
+using PerformanceMonitorLite.Database;
+using PerformanceMonitorLite.Mcp;
+using PerformanceMonitorLite.Models;
+using PerformanceMonitorLite.Services;
+using Xunit;
+
+namespace PerformanceMonitorLite.Tests;
+
+///
+/// #5643: a collection cycle that runs late skips the grid slots it missed, and used to say nothing. The loop now counts
+/// the skipped slots, logs one Warning naming the slowest steps (or the wait that overran), and writes one
+/// collection_log row per enabled server. The count is driven here by the production wait
+/// () over a fake clock and a fake delay, the way
+/// drives it.
+///
+/* Collection app-logger-statics: AFailedRowWrite_... reads AppLogger's process-wide buffer, which the other classes in the
+ collection also drain. */
+[Collection("app-logger-statics")]
+public sealed class CollectionCycleSkipTests : IDisposable
+{
+ private static readonly DateTime T0 = new(2026, 10, 12, 10, 0, 0, DateTimeKind.Utc);
+ private static readonly TimeSpan Interval = TimeSpan.FromMinutes(1);
+
+ private readonly string _dir = Directory.CreateTempSubdirectory("pm-lite-skip-tests-").FullName;
+ private readonly List _initializers = [];
+
+ public void Dispose()
+ {
+ foreach (var initializer in _initializers)
+ {
+ initializer.Dispose();
+ }
+
+ try { Directory.Delete(_dir, recursive: true); } catch { /* best effort */ }
+ }
+
+ ///
+ /// One loop pass from slot : the cycle's work takes , the wall clock
+ /// steps by before the wait begins, and the wait's delay comes back
+ /// late (negative: the clock stepped back while it ran).
+ ///
+ private static async Task<(DateTime Next, int Skipped)> PassAsync(DateTime start, TimeSpan cycleTook,
+ TimeSpan stepDuringCycle = default, TimeSpan oversleepDuringWait = default)
+ {
+ var now = start + cycleTook + stepDuringCycle;
+ var next = await CollectionBackgroundService.WaitForNextCycleAsync(start, Interval, () => now,
+ (wait, _) =>
+ {
+ now += wait + oversleepDuringWait;
+ return Task.CompletedTask;
+ },
+ CancellationToken.None);
+ return (next, CollectionCycleSkip.CountSkippedSlots(start, next, Interval));
+ }
+
+ [Theory]
+ [InlineData(5, 0)] // an ordinary cycle
+ [InlineData(59, 0)] // finishes just inside the interval
+ [InlineData(61, 1)] // 1 second over: the slot at +60 s was missed
+ [InlineData(130, 2)] // slots at +60 s and +120 s were missed
+ [InlineData(312, 5)] // 5 minutes 12 s: slots +60 s through +300 s
+ public async Task ACycleThatRunsLong_SkipsTheWholeIntervalsItMissed(int cycleSeconds, int expectedSkipped)
+ {
+ var (_, skipped) = await PassAsync(T0, TimeSpan.FromSeconds(cycleSeconds));
+
+ Assert.Equal(expectedSkipped, skipped);
+ }
+
+ [Fact]
+ public async Task ACycleThatEndsAHairBeforeItsSlotOrAfterIt_SkipsNothing()
+ {
+ // The wait returns a second after its slot.
+ var (_, late) = await PassAsync(T0, TimeSpan.FromSeconds(5), oversleepDuringWait: TimeSpan.FromSeconds(1));
+ // The wait returns before its slot (an early delay): the stamp keeps its grid slot.
+ var (_, early) = await PassAsync(T0, TimeSpan.FromSeconds(5), oversleepDuringWait: TimeSpan.FromMilliseconds(-300));
+
+ Assert.Equal(0, late);
+ Assert.Equal(0, early);
+ }
+
+ [Fact]
+ public async Task AWallClockThatStepsBackwards_IsNotASkippedSlot()
+ {
+ /* #4732: the step re-anchors the grid at the clock's reading, which lands BEFORE the slot that should have come
+ next. Counting that as a skip (or a negative count) would write a SKIPPED row for every clock correction. */
+ var (stepDuringCycle, _) = await PassAsync(T0, TimeSpan.FromSeconds(5), stepDuringCycle: TimeSpan.FromMinutes(-10));
+ var (stepDuringWait, _) = await PassAsync(T0, TimeSpan.FromSeconds(5), oversleepDuringWait: TimeSpan.FromMinutes(-10));
+
+ Assert.Equal(0, CollectionCycleSkip.CountSkippedSlots(T0, stepDuringCycle, Interval));
+ Assert.Equal(0, CollectionCycleSkip.CountSkippedSlots(T0, stepDuringWait, Interval));
+ Assert.Equal(0, CollectionCycleSkip.CountSkippedSlots(T0, T0 - TimeSpan.FromDays(1), Interval));
+ }
+
+ [Fact]
+ public async Task AWaitThatOverran_CountsTheSlotsOfTheSleep_WhileTheCycleWasShort()
+ {
+ /* A 47-minute sleep inside the wait: the cycle took 5 s, the wait ran 47 minutes past its slot. */
+ var (next, skipped) = await PassAsync(T0, TimeSpan.FromSeconds(5), oversleepDuringWait: TimeSpan.FromMinutes(47));
+
+ Assert.Equal(47, skipped);
+ Assert.Equal(T0 + TimeSpan.FromMinutes(1) + TimeSpan.FromMinutes(47), next);
+ }
+
+ [Fact]
+ public void AnIntervalThatIsNotPositive_CountsNothing()
+ {
+ Assert.Equal(0, CollectionCycleSkip.CountSkippedSlots(T0, T0.AddHours(1), TimeSpan.Zero));
+ }
+
+ // ── the report's text ────────────────────────────────────────────────────────────────────────────────
+
+ private static List Steps() =>
+ [
+ new("connection_check", 400),
+ new("collectors", 61_000),
+ new("archival", 240_000),
+ new("query_store_backfill", 0),
+ new("retention", 9_000),
+ new("analysis", 2_000),
+ ];
+
+ [Fact]
+ public void ALongCycle_NamesItsTotalTheSlotsAndItsThreeSlowestSteps()
+ {
+ var report = CollectionCycleSkip.Build(4, TimeSpan.FromSeconds(312), TimeSpan.FromSeconds(1), Steps(), Interval);
+
+ Assert.NotNull(report);
+ Assert.Equal(
+ "Collection cycle took 312s, so 4 collection slot(s) were skipped. Slowest steps: archival 240s, collectors 61s, retention 9s.",
+ report!.Message);
+ Assert.Equal(4, report.SlotsSkipped);
+ Assert.Equal(312_000, report.CycleMs);
+ Assert.Equal("archival", report.SlowestStep);
+ Assert.Equal(240_000, report.SlowestStepMs);
+ }
+
+ [Fact]
+ public void AStepNameWithUnderscores_IsReadableInTheLine_AndKeepsItsNameInTheRow()
+ {
+ var steps = new List { new("query_store_backfill", 90_000), new("collectors", 1_500) };
+
+ var report = CollectionCycleSkip.Build(1, TimeSpan.FromSeconds(95), TimeSpan.Zero, steps, Interval);
+
+ Assert.Contains("Slowest steps: query store backfill 90s, collectors 1.5s.", report!.Message);
+ Assert.Equal("query_store_backfill", report.SlowestStep);
+ }
+
+ [Fact]
+ public void AShortCycleWithAnOverrunWait_SaysTheWaitOverran_AndNamesTheWaitAsTheSlowestStep()
+ {
+ var report = CollectionCycleSkip.Build(46, TimeSpan.FromSeconds(5), TimeSpan.FromMinutes(47), Steps(), Interval);
+
+ Assert.NotNull(report);
+ Assert.Equal(
+ "Collection wait overran: it lasted 2820s against a 60s interval, so 46 collection slot(s) were skipped. "
+ + "The cycle itself took 5s; the computer may have slept or hibernated, or the clock stepped forward.",
+ report!.Message);
+ Assert.Equal("wait", report.SlowestStep);
+ Assert.Equal(2_820_000, report.SlowestStepMs);
+ Assert.Equal(5_000, report.CycleMs);
+ }
+
+ [Fact]
+ public void ALongCycleFollowedByAnOverrunWait_PrintsBothCauses_AndDoesNotBlameTheCycleAlone()
+ {
+ /* A 65 s cycle (4 s over the interval), then the computer slept: the wait lasted 2 h. The slot count is the whole gap. */
+ var report = CollectionCycleSkip.Build(124, TimeSpan.FromSeconds(65), TimeSpan.FromHours(2), Steps(), Interval);
+
+ Assert.NotNull(report);
+ Assert.Equal(
+ "Collection cycle took 65s and the wait overran, so 124 collection slot(s) were skipped across the two. "
+ + "Slowest steps: archival 240s, collectors 61s, retention 9s. "
+ + "The wait lasted 7200s against a 60s interval; the computer may have slept or hibernated, or the clock stepped forward.",
+ report!.Message);
+ Assert.Equal(124, report.SlotsSkipped);
+ Assert.Equal(65_000, report.CycleMs);
+ /* The row's slowest item is whichever is longer: here the wait. */
+ Assert.Equal("wait", report.SlowestStep);
+ Assert.Equal(7_200_000, report.SlowestStepMs);
+ Assert.DoesNotContain("so 124 collection slot(s) were skipped. ", report.Message, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void ALongCycleWhoseWaitRanOnlyItsShortSlack_IsTheCyclesCauseAlone()
+ {
+ /* A 130 s cycle then a wait that only used up its own slack (0 s left after the cycle): no overrun, so one cause. */
+ var report = CollectionCycleSkip.Build(2, TimeSpan.FromSeconds(130), TimeSpan.FromSeconds(30), Steps(), Interval);
+
+ Assert.StartsWith("Collection cycle took 130s, so 2 collection slot(s) were skipped.", report!.Message, StringComparison.Ordinal);
+ Assert.DoesNotContain("overran", report.Message, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void TheGapSentence_NamesBothEndsOfTheGap_WhenTheStampsAreGiven()
+ {
+ var late = T0 + TimeSpan.FromHours(25);
+ var report = CollectionCycleSkip.Build(1499, TimeSpan.FromSeconds(5), TimeSpan.FromHours(25), Steps(), Interval, T0, late);
+
+ Assert.EndsWith(" The gap ran from 2026-10-12 10:00:00 UTC to 2026-10-13 11:00:00 UTC.", report!.Message, StringComparison.Ordinal);
+ Assert.DoesNotContain("=", report.Message);
+ }
+
+ [Fact]
+ public void NoSkippedSlots_IsNoReport()
+ {
+ Assert.Null(CollectionCycleSkip.Build(0, TimeSpan.FromSeconds(300), TimeSpan.Zero, Steps(), Interval));
+ }
+
+ [Fact]
+ public void TheReportText_CarriesNoEpochLabel_SoTheBaselineMarkerReadNeverPicksItUp()
+ {
+ /* BaselineDiscontinuity.MarkerRowsSql selects every collection_log row whose note carries an epoch label. */
+ var report = CollectionCycleSkip.Build(3, TimeSpan.FromSeconds(200), TimeSpan.Zero, Steps(), Interval)!;
+ var overran = CollectionCycleSkip.Build(3, TimeSpan.FromSeconds(2), TimeSpan.FromMinutes(5), Steps(), Interval)!;
+
+ foreach (var message in new[] { report.Message, overran.Message })
+ {
+ Assert.DoesNotContain("=", message);
+ }
+ }
+
+ // ── the log line and the rows ────────────────────────────────────────────────────────────────────────
+
+ private sealed class CapturingLogger(List<(LogLevel Level, string Message)> sink) : ILogger
+ {
+ public IDisposable? BeginScope(TState state) where TState : notnull => null;
+
+ public bool IsEnabled(LogLevel logLevel) => true;
+
+ public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception,
+ Func formatter) => sink.Add((logLevel, formatter(state, exception)));
+ }
+
+ private sealed record Fixture(
+ DuckDbInitializer DuckDb,
+ CollectionBackgroundService Loop,
+ ServerConnection Enabled1,
+ ServerConnection Enabled2,
+ ServerConnection Disabled,
+ List<(LogLevel Level, string Message)> Log);
+
+ private async Task OpenAsync(bool dropLogTable = false)
+ {
+ var duckDb = new DuckDbInitializer(Path.Combine(_dir, "pm.duckdb"));
+ _initializers.Add(duckDb);
+ await duckDb.InitializeFromTemplateAsync();
+
+ var servers = new ServerManager(_dir);
+ var one = new ServerConnection { ServerName = "skip-one", DisplayName = "skip-one" };
+ var two = new ServerConnection { ServerName = "skip-two", DisplayName = "skip-two" };
+ var off = new ServerConnection { ServerName = "skip-off", DisplayName = "skip-off", IsEnabled = false };
+ servers.AddServer(one);
+ servers.AddServer(two);
+ servers.AddServer(off);
+
+ if (dropLogTable)
+ {
+ using var readLock = duckDb.AcquireReadLock();
+ using var connection = duckDb.CreateConnection();
+ await connection.OpenAsync(TestContext.Current.CancellationToken);
+ using var drop = connection.CreateCommand();
+ drop.CommandText = "DROP TABLE collection_log";
+ await drop.ExecuteNonQueryAsync(TestContext.Current.CancellationToken);
+ }
+
+ var collector = new RemoteCollectorService(duckDb, servers, new ScheduleManager(_dir));
+ var log = new List<(LogLevel, string)>();
+ var loop = new CollectionBackgroundService(collector, duckDb, serverManager: servers,
+ logger: new CapturingLogger(log));
+ return new Fixture(duckDb, loop, one, two, off, log);
+ }
+
+ private sealed record LogRow(string Collector, string Status, DateTime Time, int? DurationMs, int? Rows, int? SqlMs,
+ int? DuckMs, int? Items, string? SlowestItem, int? SlowestItemMs, string? Message, string ServerName);
+
+ private static async Task> RowsAsync(DuckDbInitializer duckDb)
+ {
+ var rows = new Dictionary();
+ using var readLock = duckDb.AcquireReadLock();
+ using var connection = duckDb.CreateConnection();
+ await connection.OpenAsync(TestContext.Current.CancellationToken);
+ using var command = connection.CreateCommand();
+ command.CommandText = @"SELECT server_id, collector_name, status, collection_time, duration_ms, rows_collected,
+ sql_duration_ms, duckdb_duration_ms, fanout_item_count, slowest_item, slowest_item_ms, error_message, server_name
+ FROM collection_log";
+ using var reader = await command.ExecuteReaderAsync(TestContext.Current.CancellationToken);
+ while (await reader.ReadAsync(TestContext.Current.CancellationToken))
+ {
+ int? Int(int i) => reader.IsDBNull(i) ? null : Convert.ToInt32(reader.GetValue(i));
+ rows[reader.GetInt32(0)] = new LogRow(reader.GetString(1), reader.GetString(2), reader.GetDateTime(3), Int(4), Int(5),
+ Int(6), Int(7), Int(8), reader.IsDBNull(9) ? null : reader.GetString(9), Int(10),
+ reader.IsDBNull(11) ? null : reader.GetString(11), reader.GetString(12));
+ }
+
+ return rows;
+ }
+
+ [Fact]
+ public async Task ASkippedCycle_LogsOneWarning_AndWritesOneRowPerEnabledServer_WithTheAgreedShape()
+ {
+ var f = await OpenAsync();
+ var previous = T0;
+ var next = T0 + TimeSpan.FromMinutes(6);
+
+ await f.Loop.ReportSkippedSlotsAsync(previous, next, TimeSpan.FromSeconds(312), TimeSpan.FromSeconds(48), Steps(),
+ CancellationToken.None);
+
+ var expected = CollectionCycleSkip.Build(5, TimeSpan.FromSeconds(312), TimeSpan.FromSeconds(48), Steps(), Interval, previous, next)!;
+ Assert.Contains("2026-10-12 10:00:00 UTC to 2026-10-12 10:06:00 UTC", expected.Message, StringComparison.Ordinal);
+ var warning = Assert.Single(f.Log);
+ Assert.Equal(LogLevel.Warning, warning.Level);
+ Assert.Equal(expected.Message, warning.Message);
+
+ var rows = await RowsAsync(f.DuckDb);
+ Assert.Equal(2, rows.Count);
+ Assert.False(rows.ContainsKey(RemoteCollectorService.GetServerId(f.Disabled)));
+ foreach (var server in new[] { f.Enabled1, f.Enabled2 })
+ {
+ var row = rows[RemoteCollectorService.GetServerId(server)];
+ Assert.Equal(CollectionCycleSkip.CollectorName, row.Collector);
+ Assert.Equal("SKIPPED", row.Status);
+ /* The late cycle's own start (the stamp the wait returned), not the previous cycle's: the message names that end. */
+ Assert.Equal(next, row.Time);
+ Assert.Equal(312_000, row.DurationMs);
+ Assert.Equal(5, row.Items);
+ Assert.Equal("archival", row.SlowestItem);
+ Assert.Equal(240_000, row.SlowestItemMs);
+ Assert.Equal(expected.Message, row.Message);
+ Assert.Equal(0, row.Rows);
+ Assert.Null(row.SqlMs);
+ Assert.Null(row.DuckMs);
+ Assert.Equal(server.DisplayName, row.ServerName);
+ }
+ }
+
+ [Fact]
+ public async Task ATwentyFiveHourGapThatEndedAMinuteAgo_ShowsInTheMcpBlockAndTheCollectionHealthLine()
+ {
+ /* The row is stamped at the late cycle's own start, so a gap longer than the 24-hour window is still inside it for
+ 24 hours after it ends. Stamped at the previous cycle (25 hours back) it would read count_24h 0 from the start. */
+ var f = await OpenAsync();
+ var lateStamp = DateTime.UtcNow.AddMinutes(-1);
+ var previous = lateStamp.AddHours(-25);
+
+ await f.Loop.ReportSkippedSlotsAsync(previous, lateStamp, TimeSpan.FromSeconds(5), TimeSpan.FromHours(25), Steps(),
+ CancellationToken.None);
+
+ var summary = await new LocalDataService(f.DuckDb)
+ .GetSkippedCycleSummaryAsync(RemoteCollectorService.GetServerId(f.Enabled1), cancellationToken: TestContext.Current.CancellationToken);
+ var block = System.Text.Json.JsonSerializer.SerializeToElement(McpHealthTools.SkippedCyclesBlock(summary));
+
+ Assert.Equal(1, block.GetProperty("count_24h").GetInt64());
+ Assert.Equal(1, summary.Count);
+ Assert.StartsWith("Skipped cycles in 24 h: 1 late cycle(s), 1,499 slot(s) skipped.", summary.SummaryLine(), StringComparison.Ordinal);
+ Assert.Contains("The gap ran from", summary.LastMessage, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public async Task AWaitThatOverran_WritesTheWaitAsTheSlowestItem()
+ {
+ var f = await OpenAsync();
+
+ await f.Loop.ReportSkippedSlotsAsync(T0, T0 + TimeSpan.FromMinutes(48), TimeSpan.FromSeconds(5),
+ TimeSpan.FromMinutes(47), Steps(), CancellationToken.None);
+
+ var row = (await RowsAsync(f.DuckDb))[RemoteCollectorService.GetServerId(f.Enabled1)];
+ Assert.Equal("wait", row.SlowestItem);
+ Assert.Equal(2_820_000, row.SlowestItemMs);
+ Assert.Equal(5_000, row.DurationMs);
+ Assert.Equal(47, row.Items);
+ Assert.StartsWith("Collection wait overran", row.Message);
+ }
+
+ [Fact]
+ public async Task ACycleOnTime_AndABackwardClockStep_LogNothingAndWriteNothing()
+ {
+ var f = await OpenAsync();
+
+ await f.Loop.ReportSkippedSlotsAsync(T0, T0 + Interval, TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(55), Steps(), CancellationToken.None);
+ await f.Loop.ReportSkippedSlotsAsync(T0, T0 - TimeSpan.FromMinutes(10), TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(55), Steps(), CancellationToken.None);
+
+ Assert.Empty(f.Log);
+ Assert.Empty(await RowsAsync(f.DuckDb));
+ }
+
+ [Fact]
+ public async Task AFailedRowWrite_IsLoggedAndNeverLeavesTheLoop()
+ {
+ var f = await OpenAsync(dropLogTable: true);
+ AppLogger.DrainBufferedLines();
+
+ var ex = await Record.ExceptionAsync(() => f.Loop.ReportSkippedSlotsAsync(T0, T0 + TimeSpan.FromMinutes(6),
+ TimeSpan.FromSeconds(312), TimeSpan.Zero, Steps(), CancellationToken.None));
+
+ Assert.Null(ex);
+ /* The loop's Warning is written before the row, on every skip; it is not the failure. The write failure goes to
+ AppLogger from RemoteCollectorService.WriteCollectionLogRowAsync, once per enabled server, read here from
+ AppLogger's own buffer (the seam its other pins use). */
+ Assert.Single(f.Log);
+ var failures = AppLogger.DrainBufferedLines()
+ .Where(l => l.Contains("[ERROR]", StringComparison.Ordinal)
+ && l.Contains("[Collector]", StringComparison.Ordinal)
+ && l.Contains("COLLECTION LOGGING FAILED", StringComparison.Ordinal))
+ .ToList();
+ Assert.Equal(2, failures.Count);
+ }
+
+ // ── the collector name has one home ──────────────────────────────────────────────────────────────────
+
+ [Fact]
+ public void NoFileUnderLite_CopiesTheCollectorNameAsASingleQuotedSqlLiteral()
+ {
+ /* The nine reader predicates interpolate CollectionCycleSkip.CollectorName; a rename must move writer and readers
+ together. The census has no exceptions: doc comments write the name in tags, never in single quotes. */
+ var literal = "'" + CollectionCycleSkip.CollectorName + "'";
+ var lite = Path.GetFullPath(Path.Combine(RepoRootFromThisFile(), "Lite"));
+ var offenders = Directory.EnumerateFiles(lite, "*", SearchOption.AllDirectories)
+ .Where(f => !f.Contains(Path.DirectorySeparatorChar + "bin" + Path.DirectorySeparatorChar, StringComparison.Ordinal)
+ && !f.Contains(Path.DirectorySeparatorChar + "obj" + Path.DirectorySeparatorChar, StringComparison.Ordinal)
+ && !string.Equals(Path.GetFileName(f), "CollectionCycleSkip.cs", StringComparison.Ordinal)
+ && (f.EndsWith(".cs", StringComparison.Ordinal) || f.EndsWith(".xaml", StringComparison.Ordinal)
+ || f.EndsWith(".sql", StringComparison.Ordinal)))
+ .Where(f => File.ReadAllText(f).Contains(literal, StringComparison.Ordinal))
+ .Select(f => Path.GetRelativePath(lite, f))
+ .ToList();
+
+ Assert.Empty(offenders);
+ }
+
+ [Fact]
+ public void TheNinePredicates_InterpolateTheCollectorName()
+ {
+ var services = Path.Combine(RepoRootFromThisFile(), "Lite", "Services");
+ var count = Directory.EnumerateFiles(services, "LocalDataService.*.cs")
+ .Sum(f => System.Text.RegularExpressions.Regex.Matches(File.ReadAllText(f),
+ @"collector_name <> '\{CollectionCycleSkip\.CollectorName\}'").Count);
+
+ Assert.Equal(9, count);
+ }
+
+ [Fact]
+ public void TheDailySummaryAndCollectionHealthSql_CarryTheCollectorNamesValue_NotTheBracedTemplate()
+ {
+ /* #5644 merge guard: a literal that holds the {CollectionCycleSkip.CollectorName} hole but lost its $ prefix still
+ compiles, and the daily summary then compares collector_name against the template text and counts skipped
+ cycles as runs again. Read the built SQL, not the source. DailySummaryRangeSql is private, so it is read by
+ reflection; CollectionHealthSql is internal. */
+ var daily = (string)typeof(LocalDataService)
+ .GetField("DailySummaryRangeSql", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static)!
+ .GetValue(null)!;
+
+ foreach (var sql in new[] { daily, LocalDataService.CollectionHealthSql })
+ {
+ Assert.Contains("collector_name <> '" + CollectionCycleSkip.CollectorName + "'", sql, StringComparison.Ordinal);
+ Assert.DoesNotContain("{CollectionCycleSkip", sql, StringComparison.Ordinal);
+ }
+ }
+
+ [Fact]
+ public void EveryPredicateHole_SitsInAnInterpolatedLiteral()
+ {
+ /* The twin of the pin above for the readers whose SQL is composed inside a method (Overview, the collection-health
+ lookups, runs, config changes, the runtime precondition): walk each file's string literals and require that every
+ literal holding the hole starts with $. */
+ var services = Path.Combine(RepoRootFromThisFile(), "Lite", "Services");
+ const string hole = "{CollectionCycleSkip.CollectorName}";
+ var holes = 0;
+ var bare = new List();
+ foreach (var file in Directory.EnumerateFiles(services, "LocalDataService.*.cs"))
+ {
+ var s = File.ReadAllText(file);
+ var i = 0;
+ while (i < s.Length)
+ {
+ if (string.CompareOrdinal(s, i, "//", 0, 2) == 0) { var e = s.IndexOf('\n', i); i = e < 0 ? s.Length : e; }
+ else if (string.CompareOrdinal(s, i, "/*", 0, 2) == 0) { i = s.IndexOf("*/", i + 2, StringComparison.Ordinal) + 2; }
+ else if (s[i] == '\'') { i++; while (s[i] != '\'') i += s[i] == '\\' ? 2 : 1; i++; }
+ else
+ {
+ var start = i;
+ while (i < s.Length && (s[i] == '$' || s[i] == '@')) i++;
+ if (i < s.Length && s[i] == '"')
+ {
+ var prefix = s[start..i];
+ var verbatim = prefix.Contains('@', StringComparison.Ordinal);
+ var j = i + 1;
+ while (true)
+ {
+ if (prefix.Contains('$', StringComparison.Ordinal) && s[j] == '{')
+ {
+ /* An interpolation hole may hold its own quotes (string.Join(", ", ids)); skip it whole. */
+ if (s[j + 1] == '{') { j += 2; continue; }
+ var depth = 0;
+ do
+ {
+ if (s[j] == '{') depth++;
+ else if (s[j] == '}') depth--;
+ else if (s[j] == '"') { j++; while (s[j] != '"') j += s[j] == '\\' ? 2 : 1; }
+ j++;
+ } while (depth > 0);
+ }
+ else if (verbatim && string.CompareOrdinal(s, j, "\"\"", 0, 2) == 0) j += 2;
+ else if (!verbatim && s[j] == '\\') j += 2;
+ else if (s[j] == '"') break;
+ else j++;
+ }
+ var body = s[i..j];
+ var at = body.IndexOf(hole, StringComparison.Ordinal);
+ if (at >= 0)
+ {
+ holes++;
+ if (!prefix.Contains('$', StringComparison.Ordinal))
+ bare.Add(Path.GetFileName(file) + ":" + (s.Take(i + at).Count(c => c == '\n') + 1));
+ }
+ i = j + 1;
+ }
+ else if (i == start) i++;
+ }
+ }
+ }
+
+ Assert.Empty(bare);
+ Assert.True(holes >= 8, "expected the nine predicates to sit in at least eight literals, saw " + holes);
+ }
+
+ private static string RepoRootFromThisFile([CallerFilePath] string thisFile = "")
+ => Path.GetFullPath(Path.Combine(Path.GetDirectoryName(thisFile)!, ".."));
+
+ // ── the loop is wired to it ──────────────────────────────────────────────────────────────────────────
+
+ private static string LoopSource([CallerFilePath] string thisFile = "")
+ => File.ReadAllText(Path.GetFullPath(Path.Combine(Path.GetDirectoryName(thisFile)!, "..", "Lite", "Services", "CollectionBackgroundService.cs")));
+
+ [Fact]
+ public void TheLoop_TimesEveryAwaitedStep_AndReportsAfterTheWait()
+ {
+ var source = LoopSource();
+ var body = source[source.IndexOf("protected override async Task ExecuteAsync", StringComparison.Ordinal)..];
+ body = body[..body.IndexOf("The pileup loop ends on the same token", StringComparison.Ordinal)];
+
+ var lastIndex = -1;
+ foreach (var step in new[] { "connection_check", "collectors", "archival", "query_store_backfill", "retention",
+ "findings_cleanup", "dismissed_alerts_cleanup", "analysis" })
+ {
+ var index = body.IndexOf($"Lap(\"{step}\")", StringComparison.Ordinal);
+ Assert.True(index > lastIndex, $"step {step} is not timed in loop order");
+ lastIndex = index;
+ }
+
+ var wait = body.IndexOf("await WaitForNextCycleAsync(", StringComparison.Ordinal);
+ var report = body.IndexOf("await ReportSkippedSlotsAsync(previousCycleStart, cycleStart", StringComparison.Ordinal);
+ Assert.True(wait > 0 && report > wait, "the skipped-slot report must follow the wait that returns the new stamp");
+ /* A step added to the loop without a Lap falls out of the report: every awaited housekeeping call is followed by one. */
+ Assert.Equal(8, System.Text.RegularExpressions.Regex.Matches(body, @"Lap[(]""").Count);
+ Assert.Equal(6, System.Text.RegularExpressions.Regex.Matches(body, @"await Run\w+IfDueAsync\([^)]*\);\s*Lap\(").Count);
+ }
+}