Skip to content

Fetch Active Queries plan XML on demand instead of every snapshot row (#4239) - #4303

Merged
erikdarlingdata merged 5 commits into
devfrom
fix/4239-darling-active-queries-plan-on-demand
Sep 25, 2026
Merged

erikdarlingdata merged 5 commits into
devfrom
fix/4239-darling-active-queries-plan-on-demand

Conversation

@erikdarlingdata

@erikdarlingdata erikdarlingdata commented Sep 25, 2026 •

Copy link
Copy Markdown
Owner

Part of #4239. Lane V4b is doing Lite's half of this issue separately.

Why

The Active Queries grid and the wait drill-down read full plan XML for every query_snapshots row just to
gate the Estimated/Actual plan buttons — the issue's baseline measured 221-499 MB and 5-7.7 s for that read.
Only the row a user actually clicks needs its plan text.

What changes

  • ViewerDataService.QuerySnapshots.cs: the stored-row reads now select query_plan IS NOT NULL AS has_query_plan / live_query_plan IS NOT NULL AS has_live_query_plan instead of the plan XML itself.
    ViewerQuerySnapshotRow.HasQueryPlan / HasLiveQueryPlan become plain independent flags (previously
    computed from QueryPlan/LiveQueryPlan). A new GetQuerySnapshotPlanXmlAsync(serverId, collectionTimeUtc, sessionId, requestId, live) fetches one row's plan XML on demand by its natural key when a plan button is
    clicked. Both plan-fetch statements match COALESCE(request_id, 0) = $4 rather than a plain request_id = $4 — the row reader maps a NULL request_id to 0 for every row it reads, so a row with no captured
    request_id (a pre-column row or a collector gap) could never be fetched without it, mirroring Lite's Lite: fetch Active Queries plan XML on click, not on every grid read (#4239) #4297
    fetcher.
  • LatestQuerySnapshotsSql (the Active Queries grid's own read) is now capped to the newest 1,000 rows of the
    matched window, via a trailing COUNT(*) OVER () AS total_count column and session_id, request_id added
    to ORDER BY as deterministic tiebreakers. GetLatestQuerySnapshotsAsync returns (int TotalCount, List<ViewerQuerySnapshotRow> Rows). LatestQuerySnapshotBatchSql and QuerySnapshotsByWaitTypeSql are
    deliberately left uncapped (the plan's own scope call) — the batch read is already server-scoped to one
    collection, and the wait-type filter narrows the row count on its own.
  • ViewerDataService.ActiveQueriesLive.cs: the live DMV-backed "Current Active Queries" sub-tab now sets
    HasQueryPlan/HasLiveQueryPlan explicitly from its in-row plan (!string.IsNullOrEmpty(...)), since that
    row was never persisted at its exact collection_time and a keyed fetch would find nothing for it.
  • ViewerServerTab.ActiveQueries.cs: all four GetLatestQuerySnapshotsAsync call sites destructure the new
    tuple and surface "Showing the newest 1,000 of {N:N0}" when the cap trims the window.
    OpenSnapshotEstimatedPlan/OpenSnapshotActualPlan are now async and in-row-first: use the row's own
    QueryPlan/LiveQueryPlan when present (the live grid), else fetch via GetQuerySnapshotPlanXmlAsync
    behind the existing ShowPlanLoading/HidePlanLoading/_planLoadCts overlay (mirrors ViewPlan_Click's
    pattern, including cancellation).
  • WaitDrillDownWindow.xaml.cs: LoadCorrelatedDataAsync and LoadChainDataAsync (two more callers of
    GetLatestQuerySnapshotsAsync beyond the four in the Active Queries tab) destructure the tuple too.
    Behavior change 1: the 1,000-row cap now also bounds the correlated-wait view and the blocking-chain
    walk — LoadChainDataAsync walks chains only over the newest 1,000 snapshots of the window, so a head
    blocker whose own snapshot falls outside that cap is missed. A "showing newest 1,000 of N snapshots" note
    surfaces this instead of silently absorbing it. ViewPlan_Click now fetches the plan via a new
    FetchSnapshotPlanAsync helper (this window's rows are always store-read, never live, so there is no
    in-row plan to check first) instead of reading row.QueryPlan/row.LiveQueryPlan directly. Both
    ViewPlan_Click and GetActualPlan_Click now wrap that fetch in try/catch (an earlier pass in this PR left
    it unguarded inside their async void handlers, so a store error would have reached the dispatcher as an
    unhandled exception): a failed fetch shows a "Failed to retrieve plan: {message}" dialog and returns,
    matching the fetch-failure idiom ViewerServerTab.Plans.cs already uses at its own plan-fetch call sites.
    A new source pin, WaitDrillDownPlanFetchGuardTests, proves both handlers guard the fetch (an async void
    WPF handler has no seam for a behavioral test, so this walks the shipped source the way the repository's
    other guard pins do); proven red by reverting ViewPlan_Click to the unguarded shape and confirming the
    expected [FAIL], then restored.
    Behavior change 2: GetActualPlan_Click now calls FetchSnapshotPlanAsync before building
    estimatedPlanXml for the data-modification-detection gate (the row no longer carries the plan in-row to
    read synchronously). A snapshot with no captured plan (the fetch succeeds and returns null) still fails safe
    to "may modify data", as before this PR: QueryModificationDetector.Detect treats a missing plan as
    uncertain, and uncertain counts as modifying. A fetch that throws (a store
    error) is different: it aborts the whole re-execute flow instead, since the modification check itself is
    unreliable when the fetch failed.
  • ViewerServerTab.CopyExport.cs: CopyReproScript_Click gets a ViewerQuerySnapshotRow arm, in-row-first
    (uses snapshot.QueryPlan when non-empty, else fetches by natural key). BuildReproScriptForRow's snapshot
    case now takes the plan from the caller's enrichedPlanXml parameter instead of snapshot.QueryPlan
    directly, matching the ViewerQueryStatsRow/ViewerQueryStoreRow arms. Stale doc comments claiming
    "Active Queries carries its plan in-row" (unconditionally) are corrected in all three files that had them.

Measurement (3,500 plan-bearing rows, 50-200 KB plans each)

Seeded one throwaway server_id in the rig's probe database with 3,500 query_snapshots rows in one
INSERT .. generate_series, each carrying both an estimated and a live plan built with repeat() (min 56 KB,
max 244 KB, average 149 KB per plan text — realistic execution-plan XML size). Old SQL is LatestQuerySnapshotsSql
as of origin/dev (git show origin/dev:.../ViewerDataService.QuerySnapshots.cs) with literal values in
place of $1-$3; new SQL is this PR's version (flags + cap + total_count). Each ran 3 times via
psql -A -t (unaligned, tuples-only — psql's default aligned-table rendering added ~40 s of pure
client-side formatting overhead on the first run, which is not representative of the app's binary-protocol
read, so it's excluded); wall time via the shell's own time, bytes via the redirected output file's size.

Bytes returned Time (3 runs) Time (median)
Issue's own field baseline 221-499 MB 5-7.7 s -
Old SQL (uncapped, full plan XML) 998 MB (1,046,155,212 bytes) 8.24 s / 6.87 s / 7.82 s 7.82 s
New SQL (#4239: capped at 1,000, flags + total_count only) 128 KB (131,000 bytes) 0.099 s / 0.107 s / 0.091 s 0.099 s

At this seed (3,500 rows, both plan columns populated), the fix cuts the read from ~998 MB / 7.8 s to ~128 KB
/ 0.1 s — a larger drop than the issue's own field numbers because this seed deliberately maxes out both plan
columns at the top of the requested 50-200 KB range, rather than reflecting one server's actual capture mix.

Test plan

  • dotnet build Darling/Darling.Tests/Darling.Tests.csproj -c Debug: 0 Warning(s), 0 Error(s) after every
    item, including after merging origin/dev.
  • Guarded fetch: WaitDrillDownWindow.xaml.cs's ViewPlan_Click and GetActualPlan_Click both wrap
    FetchSnapshotPlanAsync in try/catch. New source pin WaitDrillDownPlanFetchGuardTests (2 cases), proven
    red by reverting ViewPlan_Click to the unguarded shape (confirmed [FAIL] at the expected assertion),
    then restored and green.
  • NULL request_id fix: both plan-fetch SQL statements now match COALESCE(request_id, 0) = $4.
  • Live fact: QuerySnapshotPlan_FlagsMatchSeeded_AndFetchRoundTripsByteIdentical_IncludingNullRequestId
    seeds an estimated-only row, a no-plan row, and a both-plans row with a NULL request_id; asserts the
    has-plan flags match what was seeded and GetQuerySnapshotPlanXmlAsync returns byte-identical XML for both
    estimated and live. The NULL-request_id row doubles as a live regression pin for the COALESCE fix: proven
    red by reverting it to a plain request_id = $4 and confirming the expected [FAIL] (Assert.Equal
    expected the plan XML, got null), then restored.
  • Live fact: QuerySnapshots_SeedingPastTheCap_ReturnsFullTotalCount_AndTrimsToNewestThousand seeds 1,001
    rows in one round trip and asserts TotalCount is 1,001 while Rows.Count stays capped at 1,000, newest
    first.
  • Proved the rewritten LatestQuerySnapshotsSql pin actually catches the regression it's meant to catch
    (reverted the column list, got the expected [FAIL], restored and reran green) — carried over from the
    earlier commit in this PR.
  • Postgres rig before/after MB / seconds measurement — see the table above.
  • Full Darling test suite, once, after merging origin/dev and against a freshly dropped/recreated
    darlingtest: Total: 14054, Errors: 0, Failed: 0, Skipped: 49, Not Run: 1 (the skips are the usual
    live/platform-gated classes; 0 [FAIL] lines in the run log).
  • Every class in both touched test files (ViewerQueriesRestTests.cs's 5 classes,
    WaitDrillDownPlanFetchGuardTests's 1 class) run individually against the rig before the full-suite run:
    36/36 passed, 0 skipped.

CHANGELOG entry

SECTION: Fixed
ENTRY:

🤖 Generated with Claude Code

https://claude.ai/code/session_01TszxYhJJbTEh4LrZ56NYo3

erikdarlingdata and others added 5 commits September 25, 2026 11:15
…#4239)

Part of #4239. The Active Queries grid and wait drill-down read full plan
XML for every query_snapshots row just to gate the Estimated/Actual plan
buttons. Read only the has-plan flags, and fetch one row's plan XML by its
natural key (server_id, collection_time, session_id, request_id) when a
plan button is clicked. The live "Current Active Queries" grid still
carries its plan in-row (never persisted at that exact collection_time),
so every plan consumer checks the in-row plan first and only fetches when
it is null.

Also caps the Active Queries grid's stored-row read (LatestQuerySnapshotsSql)
to the newest 1,000 rows of the matched window, via a trailing
COUNT(*) OVER () total, surfaced as a "showing newest 1,000 of N" note.
WaitDrillDownWindow's two extra callers of the same read (the correlated
view and the blocking-chain walk) inherit the same cap.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TszxYhJJbTEh4LrZ56NYo3
Part of #4239. Keeps Darling.Tests compiling and honest against the
service/UI changes in the prior commit:
- the LatestQuerySnapshotsSql pin now checks has_query_plan/has_live_query_plan
  by exact line match (not a Contains substring, which "has_query_plan" would
  defeat) and pins the new total_count column, ORDER BY tiebreakers, and
  LIMIT 1000; proved once against the reverted column list (FAILs), restored
  (passes).
- QuerySnapshotRow_HasPlanFlags rewritten: the flags are independently
  settable and no longer implied by QueryPlan/LiveQueryPlan.
- ViewerPlanHostGatingTests gets a ViewerQuerySnapshotRow case (it had none).
- BuildReproScriptForRow's snapshot unit test now passes enrichedPlanXml
  instead of relying on the row's own QueryPlan.
- The live snapshots test's GetLatestQuerySnapshotsAsync call destructures
  the new (TotalCount, Rows) tuple.

DarlingActiveQueriesLiveTests.cs needs no edit: it already asserts
HasQueryPlan/HasLiveQueryPlan independently of the live round-trip, so it
was already a ready-made pin for the flags becoming independent properties.

Deferred to the next lane (not in this commit): a live fact proving the
flag + GetQuerySnapshotPlanXmlAsync round-trip returns byte-identical XML,
and a live fact seeding 1,001+ rows to prove the 1,000-row cap.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TszxYhJJbTEh4LrZ56NYo3
Two coordinator-found defects in the on-demand plan fetch this PR adds:

- WaitDrillDownWindow.xaml.cs's ViewPlan_Click and GetActualPlan_Click called
  FetchSnapshotPlanAsync (a Postgres round-trip) unguarded inside async void
  handlers, so a store error reached the dispatcher as an unhandled exception.
  Both now wrap the fetch in try/catch and show the same "Failed to retrieve
  plan" error dialog ViewerServerTab.Plans.cs's fetch call sites already use.
  A new source pin (WaitDrillDownPlanFetchGuardTests) proves both handlers
  guard the fetch; proven red by reverting ViewPlan_Click to the unguarded
  shape and confirming the expected [FAIL], then restored.
- ViewerDataService.QuerySnapshots.cs's two plan fetch statements matched
  request_id = $4, but the row reader maps a NULL request_id to 0 for every
  row read. A row collected with no request_id (pre-column data, or a
  collector gap) could never be fetched. Both statements now match
  COALESCE(request_id, 0) = $4, mirroring Lite's #4297 fetcher.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TszxYhJJbTEh4LrZ56NYo3
InsertQuerySnapshotAsync now takes optional queryPlan/liveQueryPlan/requestId
(NULL allowed) so the plan's "Tests" section's two deferred live facts can be
written against real Postgres:

- QuerySnapshotPlan_FlagsMatchSeeded_AndFetchRoundTripsByteIdentical_
  IncludingNullRequestId: seeds an estimated-only row, a no-plan row, and a
  both-plans row with a NULL request_id; asserts the has-plan flags match what
  was seeded and GetQuerySnapshotPlanXmlAsync returns byte-identical XML back
  for both estimated and live. The NULL-request_id row doubles as a second
  live regression pin for the COALESCE fix: proven red by reverting it back to
  a plain request_id = $4 and confirming the expected [FAIL], then restored.
- QuerySnapshots_SeedingPastTheCap_ReturnsFullTotalCount_AndTrimsToNewestThousand:
  seeds 1,001 rows in one INSERT .. generate_series round trip and asserts
  TotalCount is 1,001 while Rows stays capped at 1,000, newest first.

36/36 in the two touched test files pass against the rig (0 skipped).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TszxYhJJbTEh4LrZ56NYo3
@erikdarlingdata
erikdarlingdata marked this pull request as ready for review September 25, 2026 16:21
@erikdarlingdata
erikdarlingdata enabled auto-merge (squash) September 25, 2026 16:21
@erikdarlingdata
erikdarlingdata merged commit fc4f537 into dev Sep 25, 2026
17 of 18 checks passed
@erikdarlingdata
erikdarlingdata deleted the fix/4239-darling-active-queries-plan-on-demand branch September 25, 2026 16:42
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