Skip to content

Serve the Query Heatmap on the viewer's own 5-minute bins (#2484) - #2509

Merged
erikdarlingdata merged 4 commits into
devfrom
feat/2484-query-heatmap
Aug 22, 2026
Merged

Serve the Query Heatmap on the viewer's own 5-minute bins (#2484)#2509
erikdarlingdata merged 4 commits into
devfrom
feat/2484-query-heatmap

Conversation

@erikdarlingdata

@erikdarlingdata erikdarlingdata commented Aug 22, 2026

Copy link
Copy Markdown
Owner

Closes the last item on #2484. The other nine shipped in #2494, #2496, #2497, #2498, #2503, #2507 and #2508.

The interactive plot stays desktop-only by design (#2484 group (c)) — there is no heatmap viz in the web page's vocabulary and inventing a fifth one is not what the issue asked for. The read behind it is portable, and a bucketed table is the same answer: one row per (time bin x magnitude bucket) cell, with the most-executed query in each.

What it adds over every other query read is the time axis. get_top_queries_by_cpu ranks a whole window and cannot show that the window had a quiet half and a bad half, which is the first thing anyone asks about an incident that has already ended.

What the viewer's bucketing turned out to be

A constant, not a derivation. ViewerDataService.QueryHeatmap.cs bins with a literal INTERVAL '5 minutes' whatever range is on screen — it is Lite's time_bucket(INTERVAL '5 minutes', collection_time) ported to date_bin, and neither one looks at the window length. So there was no derivation to reproduce, only a default to honor:

  • Time bins: 5 minutes, exposed as bucket_minutes and defaulting to exactly that. A browser, an agent and a desktop pointed at the same server over the same window draw the same grid unless the caller asks for something else. The result echoes bucket_minutes_matches_desktop_viewer so a consumer can tell at a glance whether it is looking at the viewer's picture.
  • Magnitude buckets: the viewer's seven, < 1 / 1-10 / 10-100 / 100-1K / 1K-10K / 10K-100K / > 100K, labelled per metric family (milliseconds for duration and CPU, plain counts for the other three). The labels ship with every result — a bare bucket_index is unreadable.
  • The metric expressions are byte-identical with ViewerDataService.HeatmapMetricExpr and Lite's GetMetricColumn, all five of them.

The SQL is the viewer's verbatim apart from two things a desktop chart does not need: the bin width is a bound parameter, and the tail carries ORDER BY time_bin DESC + LIMIT so a capped call keeps the recent end of the window.

The divergence the parameter exposed

Making the width a parameter surfaced a cross-SKU bug the default was hiding.

Postgres date_bin requires an origin and the viewer passes the Unix epoch. DuckDB's time_bucket defaults to a different origin — 2000-01-03. The two are 15,780,960 minutes apart, and 5 divides that exactly, so the default agrees by luck. 7 does not:

stride Postgres date_bin DuckDB time_bucket (default origin)
5 min 10:05:00 10:05:00
7 min 10:02:00 10:01:00

The first caller to pass an odd bucket_minutes would have had Lite and Darling bin the same row a minute apart. Lite's read now passes the epoch explicitly. Verified identical across {1, 5, 7, 13, 60, 90, 360, 1440}-minute strides against PostgreSQL 17 and DuckDB 1.5.5 — 32 combinations, zero mismatches. Lite's WPF chart keeps its origin-less form deliberately: it only ever asks for 5 minutes, where the two agree.

Which kind of table this is, and why the empty branches are what they are

query_stats is a PERIODIC table, not an edge table. The collector writes rows every cycle for whatever is in the plan cache, whether or not anything interesting happened. So an existence probe on the data is the right denominator here — the exact opposite of what #2508 corrected for blocking and deadlocks, where zero rows is the healthy answer and a data probe sends someone to fix collection that works. The probe reads v_query_stats, the same relation the read itself uses, so it cannot disagree with the read about which rows exist.

Zero cells is three states:

  1. unavailable — no rows for this server at all. Nobody looked; go find out why collection is not running.
  2. empty — rows exist outside the window, none in it. Widen hours_back, or check get_collection_health; a collector that stopped looks exactly like this.
  3. empty — captures exist in the window and every one recorded a zero execution delta. This is the branch only this read has: collection is healthy and the server is idle. Telling that caller to widen the window would be advice pointed at the wrong problem. The same sentence covers a database_name filter that matched nothing collected, and the two-cycle warm-up that delta collection needs.

All three are pinned on both SKUs, asserted to be three different sentences, and the three shared sentences are added to McpMissMessageParityPinTests so they cannot drift a dash apart between the trees.

The cap, and the partial-column problem

limit caps cells (default 500) and is validated by McpHelpers.ValidateTop — out of range is refused, never clamped. Over-fetch by one, so truncation is observed rather than inferred.

Rows come back newest-bin-first so the cap cuts the oldest end, which is what anyone looking at an incident wants. That creates a hazard nothing else on this surface has: the cut can land in the middle of a bin and hand back a column missing its low buckets, which reads as "nothing fast ran then" rather than "we stopped looking" — a quiet wrong answer that a grid makes very easy to believe. So the partial column is dropped, and first_time_bin / last_time_bin say which slice of the window actually came back. (It is kept only when it is the only column, i.e. a cap below one bin's seven cells, where there is nothing to fall back to.)

bucket_minutes is the lever to reach for before the cap: a 7-day window at 5-minute bins is 2,016 columns, and widening the bin covers the window in fewer cells.

An unknown metric is refused, not silently turned into duration — a caller who asked for CPU and got elapsed time would read the wrong grid with nothing to tell them so.

Also in here

docs/uat-onboarding.md §3.4 listed "data with no read endpoint at all", named the query heatmap in it, and said no amount of web work reaches it. That list is now empty, so it says so. Its counts were seven tools stale (the whole #2484 wave landed without them moving); they are re-derived from the source rather than incremented — 90 read endpoints out of 109 tools, 69 of them reached by the per-server page. The "desktop things a web imitation would be worse than" paragraph keeps the heatmap, narrowed to the interactive plot.

Both SKUs, not the divergence ratchet. Census recounted by scanning the McpServerTool(Name = ...) attributes: 108 → 109 tools, 83 → 84 shared, 25 Darling-unique unchanged.

The window anchor (#2495 / #2504)

as_of landed across 57 reads while this was open, and the heatmap now takes it too — on the shared McpHelpers.AsOfDescription constant, not a second wording of the same idea.

It earns the parameter more than most reads do. A heatmap is a time axis, so "the four hours ending Tuesday 03:00" is the shape of every question anyone brings to it. And widening hours_back until an old incident falls inside is not the same question here even in the weak sense the general argument makes: on this read the extra hours arrive as extra columns, which push the incident's own columns past the cell cap.

#2495's own failure mode is a tool that advertises the anchor, validates it, refuses a bad one correctly — and then queries DateTime.UtcNow anyway, so the validation succeeding is exactly what makes the caller believe the window moved. Eight of those shipped past a green suite during #2504. This is therefore proved by content on both SKUs: rows are seeded outside every default window, the anchored call returns them, and the same-length window at the default anchor does not. Neither tool names DateTime.UtcNow, and Lite threads the resolved instant into both GetQueryHeatmapCellsAsync and GetQueryHeatmapCoverageAsync rather than letting either compute its own.

That also subsumes the window-skew fix from the review below: one resolved instant now decides the query window, the coverage probe's window, and the window reported back.

Merge conflicts, and how they were taken

dev moved under this branch (#2504, #2505, #2507, #2508). Three files conflicted:

  • DarlingWebEndpoints.cs catalog + dispatch — dev's as_of on the neighbouring reads, plus PAsOf() / as_of: AsOf(c) on get_query_heatmap.
  • Both instructions tables — dev's rows win (they carry as_of); the heatmap row is re-inserted in place with as_of appended.

The census sentence did not conflict, and was recounted anyway rather than trusted: scanning the McpServerTool(Name = ...) attributes gives 109 / 84 / 25, which is what it says. The uat-onboarding counts were re-derived the same way and are unchanged at 90 read endpoints, 109 tools, 69 reached by the per-server page.

How this was verified

  • The shipped Postgres SQL was executed against real PostgreSQL 17 (a local timescaledb container) over a synthetic v_query_stats, and the text in DarlingQueryHeatmapReader is asserted equal to the text that ran, modulo whitespace. Five-minute and sixty-minute bins, the database filter, and the LIMIT path all produce what the tests expect.
  • The Lite SQL was executed against real DuckDB 1.5.5 — the exact version pinned in Directory.Packages.props — and produces cell-for-cell identical output to the Postgres run on the same rows.
  • The Lite test's whole scenario (bin counts, cell counts, the top-query-per-cell tie-break, the truncation trim, the idle-probe branch) was simulated end to end against that DuckDB before the C# was written.
  • Full solution builds clean on macOS with the EnableWindowsTargeting ritual; global.json is untouched in every commit.
  • The AsOfWindowAnchorTests source-scan rules (anchor reaches the query, no DateTime.UtcNow in an anchored body, no anchorable service call left un-anchored) were replayed locally over both trees before pushing: 58 anchored tools scanned on Darling, 51 on Lite, zero offenders.
  • node --check on server-tabs.js. The panel is built with table(...), not an object literal.
  • Locally re-derived and checked: line endings (CRLF, zero bare LF across all 13 files), stacked-<summary> hygiene, the census against a fresh attribute scan, the shared-sentence parity in both trees, and that every param key the panel sends is one the catalog binds.

What I could NOT verify

  • The test suites themselves. Both projects are net10.0-windows, so they build here and run in CI. The live Darling test is gated on DARLING_TEST_PG.
  • A real monitored server. Everything above is synthetic rows; nothing was run against a production store, so the shape of a real busy server's grid (and whether 500 cells is the right default in practice) is untested by anything but arithmetic.
  • The rendered panel. The descriptor is validated by node --check and by the page-level pins, but no browser has drawn it.

One judgement worth flagging for review: the metric list is the viewer's five, including logical_reads and logical_writes. Dropping them would have made the two surfaces disagree about what the heatmap can show, which is the thing this PR exists to prevent.

CI

All six checks green. One red run along the way was Lite.Tests.LiteServerTagsStoreTests timing out on the DuckDB write lock — the documented writer-starvation shape of that suite's one process-wide ReaderWriterLockSlim (SharedDuckDbFixture's own doc comment describes it; #2208 is the same shape). Unrelated to this change and green on re-run, but worth naming rather than quietly re-running.

🤖 Generated with Claude Code

The last of the ten viewer surfaces with no /api/read endpoint. The interactive
plot stays desktop-only by design; the read behind it is portable, and a bucketed
table is the same answer.

The bucketing is the viewer's, not a new one. Its bin width turned out to be a
CONSTANT — INTERVAL '5 minutes', hardcoded whatever range is on screen, not
derived from the window length — so there was nothing to reproduce, only a
default to honor. bucket_minutes exposes it and defaults to 5, because a browser,
an agent and a desktop pointed at the same server over the same window must not
draw different pictures. The seven log-magnitude bands are the viewer's too.

Making the width a parameter exposed a cross-SKU divergence the default hid.
Postgres date_bin takes an explicit origin and the viewer passes the Unix epoch;
DuckDB's time_bucket defaults to 2000-01-03 instead. The two origins are
15,780,960 minutes apart, which 5 divides exactly — so the default agrees by luck
— but 7 does not, and the two SKUs would have binned the same row a minute apart
the first time anyone passed an odd width. Lite's read now passes the epoch
explicitly. Verified identical across {1, 5, 7, 13, 60, 90, 360, 1440}-minute
strides on PostgreSQL 17 and DuckDB 1.5.5.

query_stats is a PERIODIC table, not an edge table: the collector writes rows
every cycle for whatever is in the plan cache, so an existence probe on the data
is the right denominator here — the opposite of what #2508 corrected for blocking
and deadlocks. Zero cells is three states: never collected (unavailable, nobody
looked), nothing in the window (empty, widen it), and one only this read has —
captures exist and every one recorded a zero execution delta, which is an idle
server rather than a broken one, where "widen the window" would be advice pointed
at the wrong problem.

The cap is on CELLS and it cuts the OLDEST bins, so a capped call keeps the recent
end of an incident window. That cut can land inside a bin, so the partial column
is dropped rather than returned with holes: a column missing its low buckets reads
as "nothing fast ran then" rather than "we stopped looking".

Also closes out uat-onboarding §3.4, whose "data with no read endpoint at all"
list named the heatmap and is now empty. Its counts were seven tools stale; they
are re-derived rather than incremented — 90 read endpoints of 109 tools, 69 of
them reached by the per-server page.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Comment thread Lite/Mcp/McpQueryTools.cs Outdated
cells = cells.OrderBy(c => c.TimeBucket).ThenBy(c => c.BucketIndex).ToList();

var labels = LocalDataService.HeatmapBucketLabelsFor(parsedMetric);
var end = DateTime.UtcNow;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

end (and therefore window_start/window_end in the returned JSON) is captured after dataService.GetQueryHeatmapCellsAsync has already run, but the query itself computed its own [start, end] independently via GetTimeRange(hoursBack, ...) (two separate DateTime.UtcNow calls) at the top of GetQueryHeatmapCellsAsync. The window reported to the caller is not the window that was actually queried — they can drift by however long the DuckDB call takes to acquire its read lock and run, which under active collection (the writer lock other collectors take) can be non-trivial, not just microseconds.

This is a real divergence from two things already established in this same PR:

  • Darling's twin (DarlingMcpQueryHeatmapTools.GetQueryHeatmap) computes start/end once, before calling GetQueryHeatmapAsync, and reuses that same pair for both the query parameters and the JSON output.
  • This very file's GetTopQueriesByCpu (line ~39) explicitly hoists nowUtc before the async call for exactly this reason, with a comment calling out the numerator/denominator window-skew risk of not doing so.

Suggest hoisting var end = DateTime.UtcNow; (and deriving start from it) to before the dataService.GetQueryHeatmapCellsAsync(...) call, and threading that same start/end through so the reported window matches the window that was actually queried — mirroring Darling's implementation.

@claude

claude Bot commented Aug 22, 2026

Copy link
Copy Markdown

Reviewed the query-heatmap read (Darling DarlingQueryHeatmapReader/DarlingMcpQueryHeatmapTools and Lite's LocalDataService.QueryHeatmap.cs/McpQueryTools.GetQueryHeatmap), the shared SQL between them, the three-state empty handling, the truncation/partial-column logic, and the web panel wiring in server-tabs.js.

Overall this is well-executed and the two SKUs are in tight parity — the bucketing constant, magnitude bands, metric expressions, validation messages, and empty-state sentences all match verbatim as the PR description claims, and I verified the truncation/no-partial-column logic by hand against the test cases (including the single-bin-fallback edge case). No SQL injection surface (metric goes through an enum switch, database_name/bucket_minutes/limit are all bound parameters), no missing-index folklore, no secrets/file/process concerns.

One real bug found — left as an inline comment on Lite/Mcp/McpQueryTools.cs: the new GetQueryHeatmap computes window_start/window_end for the JSON response after the DuckDB call returns, using a fresh DateTime.UtcNow, while the actual query window is computed independently (and earlier) inside GetQueryHeatmapCellsAsync. The reported window can therefore disagree with the window that was actually queried. Darling's twin gets this right (single start/end computed once and reused for both the query and the output), and this file's own GetTopQueriesByCpu explicitly hoists nowUtc before its async call for the same reason — so this looks like an oversight rather than a deliberate divergence.

Everything else I checked — parameter wiring in DarlingWebEndpoints.cs/DarlingMcpHostService.cs, the tool-catalog/instructions doc updates on both SKUs, the McpMissMessageParityPinTests additions, and the docs/uat-onboarding.md count updates — looked consistent and correct.

Review catch. Lite's GetQueryHeatmap took its window_start/window_end from a
FRESH DateTime.UtcNow after the DuckDB call returned, while the window the query
actually ran with is computed independently and earlier inside
GetQueryHeatmapCellsAsync. The reported window drifted by however long the read
took — on the one read whose entire output is a time axis, so a window that
disagrees with the bins underneath it is worse here than almost anywhere.

Hoisted before the call, the same move GetTopQueriesByCpu in this file already
makes and for the same reason. Darling's twin never had the bug: it computes
start/end once and passes them into the query.

Pinned on BOTH SKUs rather than only the one that was wrong — the invariant is
that the reported window spans exactly hours_back and brackets every bin returned,
which is now asserted in Darling's live test too so its correctness-by-
construction cannot quietly regress.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@erikdarlingdata

Copy link
Copy Markdown
Owner Author

Good catch on the window skew — fixed in bfef9cb.

window_start/window_end were taken from a fresh DateTime.UtcNow after the DuckDB call returned, so the reported window drifted by however long the read took. It is now hoisted before the call, the same move GetTopQueriesByCpu in that file already makes and for the same reason. Darling's twin never had it (it computes start/end once and passes them into the query).

Pinned on both SKUs rather than only the one that was wrong: the reported window must span exactly hours_back and bracket every bin returned. Darling was correct by construction and now has the assertion too, so that cannot quietly regress.

@claude

claude Bot commented Aug 22, 2026

Copy link
Copy Markdown

Reviewed the diff against ff4f91d (12 files, ~1780 lines). This is a tight, well-structured PR — no correctness bugs, no Lite/Darling parity drift, and no security concerns found.

Notes from the review:

  • Cross-SKU date_bin/time_bucket origin fix is correct and well-verified. Both readers now pass the Unix epoch explicitly, and the reasoning (15,780,960-minute offset, divisible by 5 but not 7) checks out against the documented DuckDB/Postgres defaults. Test coverage pins the SQL text for both dialects.
  • Parameter indexing is correct in both DarlingQueryHeatmapReader.BuildQueryHeatmapSql ($1–$6) and Lite's dynamic BuildDbInClause-based indexing (bucketIndex/limitIndex math checked against the command.Parameters.Add sequence) — no off-by-one.
  • No injection risk: metric is mapped through TryParseMetric to an enum before any SQL is built (never string-concatenated from caller input); bucket_minutes/limit/database_name are all bound parameters.
  • The residual DateTime.UtcNow skew in Lite's GetQueryHeatmap (tool-level windowEnd captured before the call, but GetQueryHeatmapCellsAsync computes its own window internally via a second UtcNow) is the same accepted pattern already used in GetTopQueriesByCpu in the same file, with the same rationale in the comment (skew shrunk to call-entry overhead, deemed not worth threading an explicit window through). Consistent with precedent, not a new bug.
  • Empty-grid three-state handling (unavailable / no-rows-in-window empty / idle-but-collected empty) is implemented identically on both SKUs and pinned in McpMissMessageParityPinTests — good parity discipline.
  • Truncation logic (drop partial oldest column unless it's the only column) is correct and matches on both SKUs; verified the wholeColumns fallback for the "cap below one bin" edge case.
  • Lite has no web//api/read surface by design (WPF-only), so the DarlingWebEndpoints.cs/server-tabs.js changes having no Lite counterpart is expected, not a parity gap.
  • Copyright headers, CRLF line endings, and T-SQL-adjacent C# conventions (comments-explain-why, OPTION(RECOMPILE) not applicable here since there's no T-SQL in this PR) all check out.

No inline comments filed — nothing rose to the level of a required change.

#2504 landed as_of across 57 reads while this branch was open. The heatmap is a
windowed read, so it takes the anchor too — on the same shared
McpHelpers.AsOfDescription constant as the rest, not a second convention.

It earns the parameter more than most reads do. A heatmap IS a time axis, so "the
four hours ending Tuesday 03:00" is the shape of every question anyone brings to
it, and widening hours_back until an old incident falls inside is not the same
question here even in the weak sense: the extra hours arrive as extra COLUMNS,
which push the incident's own columns past the cell cap.

Proved by CONTENT rather than by the parameter existing, on both SKUs. #2495's
own failure mode is a tool that advertises as_of, validates it, refuses a bad
anchor correctly and then queries now — eight of those shipped past a green suite
during #2504 — so both tests seed rows outside every default window and assert the
anchored call returns them while the same-LENGTH default-anchored call does not.
Neither tool names DateTime.UtcNow, and Lite threads the resolved instant into
GetQueryHeatmapCellsAsync and GetQueryHeatmapCoverageAsync rather than letting
either compute its own.

That also subsumes the window-skew fix from the previous commit: one resolved
instant now decides the query window, the coverage probe's window and the window
reported back.

Conflicts, and how they were taken:

- DarlingWebEndpoints catalog + dispatch: dev's as_of on the neighbouring reads,
  plus PAsOf() / as_of: AsOf(c) on get_query_heatmap.
- Both instructions tables: dev's rows win (they carry as_of); the heatmap row is
  re-inserted in place with as_of appended.
- The census sentence did not conflict, but it was RECOUNTED rather than trusted:
  scanning McpServerTool(Name = "...") gives 109 / 84 / 25, which is what it says.
  The uat-onboarding counts were re-derived the same way and are unchanged at
  90 read endpoints, 109 tools, 69 reached by the per-server page.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@claude

claude Bot commented Aug 22, 2026

Copy link
Copy Markdown

Review: get_query_heatmap (#2484)

Went through the new DarlingQueryHeatmapReader/DarlingMcpQueryHeatmapTools (Darling) and LocalDataService.QueryHeatmap.cs/McpQueryTools.GetQueryHeatmap (Lite), the web endpoint wiring, the server-tabs.js panel, and the parity pin tests.

No blocking issues found. Specifically checked and confirmed clean on:

  • SQL injection: both SKUs bind every caller-controlled value (database_name, bucket_minutes, limit) as a positional parameter ($1..$6 / DuckDB $N). The metric string never reaches SQL text directly — it's mapped through TryParseMetric/TryParseHeatmapMetric to an enum first, and unknown values are refused rather than silently defaulted.
  • Lite/Darling parity: the empty-state messages (unavailable/empty×2), the truncation/no-partial-column logic, the epoch-origin fix for date_bin/time_bucket, the bucket labels, and the parameter contract are all byte-identical between the two trees, and the new McpMissMessageParityPinTests entries lock the three empty-branch sentences so they can't drift apart silently.
  • The DuckDB/Postgres origin bug the PR calls out (time_bucket defaulting to 2000-01-03 vs date_bin needing an explicit epoch) is real and the fix (explicit epoch origin on both sides) is correct — confirmed against the stated 15,780,960-minute offset math.
  • XSS: the new QUERY_HEATMAP_COLUMNS cell renderer reuses codeDisclosure, which builds a <pre> through el()/text nodes, never innerHTML — consistent with the file's R4 rule.
  • Docs: uat-onboarding.md's "no read endpoint at all" list is correctly emptied out now that this is the last of the ten Ten viewer surfaces read data that has no /api/read endpoint, so neither the web dashboard nor the MCP can reach it #2484 items.

One non-blocking observation on the truncation heuristic (identical in both DarlingMcpQueryHeatmapTools.GetQueryHeatmap and Lite/Mcp/McpQueryTools.cs's GetQueryHeatmap): when truncated is true, the code unconditionally drops every cell belonging to the oldest time bin reached (cells.Where(c => c.TimeBucket > oldestReached)), even in the case where that oldest bin's cap-adjacent cut happens to land exactly on a bin boundary and the column is actually complete (all cells for that bin present). Since there's no per-column count available to distinguish "complete column that happens to be last" from "genuinely partial column," the code can't tell the difference and conservatively drops both. That's a reasonable, safe tradeoff (never show a misleadingly-partial column) and it's symmetric across both SKUs, so I'm not flagging it as a bug — just noting it as a known minor precision cost of the current heuristic, in case a future PR wants to tighten it (e.g., by over-fetching a full extra bin's worth of rows instead of just one row).

Nice work on the cross-SKU verification (real Postgres 17 + real DuckDB 1.5.5, 32 stride combinations) — that's exactly the kind of check this bucketing logic needed.

The merge that took as_of left the tool's own parameter-list pin describing the
pre-merge signature, so CI failed on exactly the thing that pin exists to catch —
which is the pin working, not a defect in it.

Expected list updated, and the anchor is now pinned by IDENTITY as well as by
name: its description must BE McpHelpers.AsOfDescription, not merely mention an
anchor. That is the constant's whole reason for existing — the same parameter
described two different ways on two SKUs is a divergence no other test would see.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@claude

claude Bot commented Aug 22, 2026

Copy link
Copy Markdown

Reviewed the diff (Darling PG reader/tools, Lite DuckDB reader/tools, web panel, docs) against CONTRIBUTING.md and the Lite/Darling parity requirement. Summary:

Correctness — Walked both BuildQueryHeatmapSql (Postgres) and the DuckDB equivalent end to end: parameter indices ($1–$6) line up with DarlingMcpReadParameters.AddWindow's (server_id, start, end) order and BuildDbInClause's index math in Lite; date_bin/time_bucket both bind an explicit Unix-epoch origin so the two SKUs can't drift apart at odd bucket_minutes strides (verified the reasoning, not just the claim); the truncation logic correctly identifies the oldest bin in the DESC-ordered, then-capped result via cells[^1].TimeBucket before dropping a partial column, and the three empty-state branches (unavailable / no-rows-in-window / genuinely-idle) are reachable and mutually exclusive as described. ValidateWindow/ValidateTop (confirmed shared between Darling and Lite via PerformanceMonitor.Common.McpHelpers) and the anchor (as_of) threading avoid the clock-drift bug class #2495 was written to prevent — the reported window_start/window_end are computed from the same instant used to run the query, not re-derived after the fact.

Parityget_query_heatmap is added symmetrically to both SKUs: same tool name, same 7 parameters in the same order, same defaults (5-minute bins, 500-cell cap, 1–1440 bounds), same metric names/units, same magnitude bucket labels, and the three empty-state messages are pinned word-for-word identical in McpMissMessageParityPinTests. Docs (DarlingMcpInstructions.cs, Lite/Mcp/McpInstructions.cs, uat-onboarding.md) and tool-count census were all updated together. No drift found.

Security — Both SQL builders are fully parameterized ($1–$6 / $1–$N); the only string-interpolated piece (metricExpr) comes from a fixed switch over an internal enum populated via TryParseMetric, never from raw caller text, so it's injection-safe as documented. database_name is bound as a text[] parameter, not concatenated.

Style — New Postgres/DuckDB SQL is consistent with the rest of the codebase's existing (non-T-SQL) query style in this project; OPTION(RECOMPILE)/T-SQL-specific rules don't apply since no install/*.sql collector was touched. Copyright headers present on new files.

One non-blocking observation, not specific to this PR: the "top query in a cell" tie-break (ROW_NUMBER() ... ORDER BY delta_execution_count DESC in Postgres, ARG_MAX(...) in DuckDB) has no secondary deterministic key, so a genuine tie between two queries' execution counts in the same cell could pick either one. This mirrors the pre-existing pattern in LocalDataService.QueryStats.cs:1373-1374 (the WPF chart's own copy, left untouched by design), so it's inherited behavior rather than something introduced here — flagging only because this surface is explicitly billed as reproducible across browser/agent/desktop callers.

No blocking issues found.

@erikdarlingdata
erikdarlingdata merged commit af19153 into dev Aug 22, 2026
9 of 10 checks passed
erikdarlingdata added a commit that referenced this pull request Aug 22, 2026
Both classes pin their tool set EXACTLY - seven blocking tools, three health
tools - so adding a tool to either is meant to fail here until the pin is told
about it. That is the pin working, not a defect in it, and it is the reason a
tool cannot be added to a class without also being given a parameter contract
and a Gemini-clean schema assertion.

Each new tool also gets its anchor pinned by IDENTITY rather than by name: the
as_of description must BE the shared constant, following the correction #2509
made for the heatmap. get_lock_wait_trend carries McpHelpers.AsOfDescription;
get_daily_summary_range carries AsOfDaysDescription, and that pin also asserts
the two constants differ, that the day-grained one does not name hours_back, and
that it does name days_back - because the whole reason for a second constant is
that a parameter description naming a parameter the tool does not have is worse
than a generic one.

LockWaitTrendSql gets its own pin on the three properties that are real defects
if they drift: the LCK filter, the LAG partitioned BY WAIT TYPE (without which
one wait type's cadence divides another's delta), and the CAST to double before
the division.

Also silences two xUnit2013 warnings the new range tests raised.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant