Repository navigation
Fetch Active Queries plan XML on demand instead of every snapshot row (#4239) - #4303
Merged
erikdarlingdata merged 5 commits intoSep 25, 2026
Merged
Conversation
…#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
…e-queries-plan-on-demand
erikdarlingdata
marked this pull request as ready for review
September 25, 2026 16:21
erikdarlingdata
enabled auto-merge (squash)
September 25, 2026 16:21
erikdarlingdata
deleted the
fix/4239-darling-active-queries-plan-on-demand
branch
September 25, 2026 16:42
This was referenced Sep 25, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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_snapshotsrow just togate 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 selectquery_plan IS NOT NULL AS has_query_plan/live_query_plan IS NOT NULL AS has_live_query_planinstead of the plan XML itself.ViewerQuerySnapshotRow.HasQueryPlan/HasLiveQueryPlanbecome plain independent flags (previouslycomputed from
QueryPlan/LiveQueryPlan). A newGetQuerySnapshotPlanXmlAsync(serverId, collectionTimeUtc, sessionId, requestId, live)fetches one row's plan XML on demand by its natural key when a plan button isclicked. Both plan-fetch statements match
COALESCE(request_id, 0) = $4rather than a plainrequest_id = $4— the row reader maps a NULLrequest_idto 0 for every row it reads, so a row with no capturedrequest_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 thematched window, via a trailing
COUNT(*) OVER () AS total_countcolumn andsession_id, request_idaddedto
ORDER BYas deterministic tiebreakers.GetLatestQuerySnapshotsAsyncreturns(int TotalCount, List<ViewerQuerySnapshotRow> Rows).LatestQuerySnapshotBatchSqlandQuerySnapshotsByWaitTypeSqlaredeliberately 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 setsHasQueryPlan/HasLiveQueryPlanexplicitly from its in-row plan (!string.IsNullOrEmpty(...)), since thatrow was never persisted at its exact
collection_timeand a keyed fetch would find nothing for it.ViewerServerTab.ActiveQueries.cs: all fourGetLatestQuerySnapshotsAsynccall sites destructure the newtuple and surface
"Showing the newest 1,000 of {N:N0}"when the cap trims the window.OpenSnapshotEstimatedPlan/OpenSnapshotActualPlanare now async and in-row-first: use the row's ownQueryPlan/LiveQueryPlanwhen present (the live grid), else fetch viaGetQuerySnapshotPlanXmlAsyncbehind the existing
ShowPlanLoading/HidePlanLoading/_planLoadCtsoverlay (mirrorsViewPlan_Click'spattern, including cancellation).
WaitDrillDownWindow.xaml.cs:LoadCorrelatedDataAsyncandLoadChainDataAsync(two more callers ofGetLatestQuerySnapshotsAsyncbeyond 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 —
LoadChainDataAsyncwalks chains only over the newest 1,000 snapshots of the window, so a headblocker whose own snapshot falls outside that cap is missed. A
"showing newest 1,000 of N snapshots"notesurfaces this instead of silently absorbing it.
ViewPlan_Clicknow fetches the plan via a newFetchSnapshotPlanAsynchelper (this window's rows are always store-read, never live, so there is noin-row plan to check first) instead of reading
row.QueryPlan/row.LiveQueryPlandirectly. BothViewPlan_ClickandGetActualPlan_Clicknow wrap that fetch in try/catch (an earlier pass in this PR leftit unguarded inside their
async voidhandlers, so a store error would have reached the dispatcher as anunhandled exception): a failed fetch shows a
"Failed to retrieve plan: {message}"dialog and returns,matching the fetch-failure idiom
ViewerServerTab.Plans.csalready uses at its own plan-fetch call sites.A new source pin,
WaitDrillDownPlanFetchGuardTests, proves both handlers guard the fetch (anasync voidWPF 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_Clickto the unguarded shape and confirming theexpected
[FAIL], then restored.Behavior change 2:
GetActualPlan_Clicknow callsFetchSnapshotPlanAsyncbefore buildingestimatedPlanXmlfor the data-modification-detection gate (the row no longer carries the plan in-row toread 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.Detecttreats a missing plan asuncertain, 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_Clickgets aViewerQuerySnapshotRowarm, in-row-first(uses
snapshot.QueryPlanwhen non-empty, else fetches by natural key).BuildReproScriptForRow's snapshotcase now takes the plan from the caller's
enrichedPlanXmlparameter instead ofsnapshot.QueryPlandirectly, matching the
ViewerQueryStatsRow/ViewerQueryStoreRowarms. 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_idin the rig'sprobedatabase with 3,500query_snapshotsrows in oneINSERT .. generate_series, each carrying both an estimated and a live plan built withrepeat()(min 56 KB,max 244 KB, average 149 KB per plan text — realistic execution-plan XML size). Old SQL is
LatestQuerySnapshotsSqlas of
origin/dev(git show origin/dev:.../ViewerDataService.QuerySnapshots.cs) with literal values inplace of
$1-$3; new SQL is this PR's version (flags + cap +total_count). Each ran 3 times viapsql -A -t(unaligned, tuples-only — psql's default aligned-table rendering added ~40 s of pureclient-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.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 everyitem, including after merging
origin/dev.WaitDrillDownWindow.xaml.cs'sViewPlan_ClickandGetActualPlan_Clickboth wrapFetchSnapshotPlanAsyncin try/catch. New source pinWaitDrillDownPlanFetchGuardTests(2 cases), provenred by reverting
ViewPlan_Clickto the unguarded shape (confirmed[FAIL]at the expected assertion),then restored and green.
request_idfix: both plan-fetch SQL statements now matchCOALESCE(request_id, 0) = $4.QuerySnapshotPlan_FlagsMatchSeeded_AndFetchRoundTripsByteIdentical_IncludingNullRequestIdseeds an estimated-only row, a no-plan row, and a both-plans row with a NULL
request_id; asserts thehas-plan flags match what was seeded and
GetQuerySnapshotPlanXmlAsyncreturns byte-identical XML for bothestimated and live. The NULL-
request_idrow doubles as a live regression pin for the COALESCE fix: provenred by reverting it to a plain
request_id = $4and confirming the expected[FAIL](Assert.Equalexpected the plan XML, got
null), then restored.QuerySnapshots_SeedingPastTheCap_ReturnsFullTotalCount_AndTrimsToNewestThousandseeds 1,001rows in one round trip and asserts
TotalCountis 1,001 whileRows.Countstays capped at 1,000, newestfirst.
LatestQuerySnapshotsSqlpin actually catches the regression it's meant to catch(reverted the column list, got the expected
[FAIL], restored and reran green) — carried over from theearlier commit in this PR.
origin/devand against a freshly dropped/recreateddarlingtest: Total: 14054, Errors: 0, Failed: 0, Skipped: 49, Not Run: 1 (the skips are the usuallive/platform-gated classes; 0
[FAIL]lines in the run log).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:
The Active Queries grid and the Wait Stats drill-down window used to pull full estimated and live execution
plan XML for every stored query snapshot in the window, even when nobody opened a plan, which could pull
hundreds of megabytes over several seconds on a busy server. Plan XML is now fetched only when a plan
button is actually clicked, including for rows stored without a request_id. The Active Queries grid (and
the drill-down's correlated view and blocking-chain walk, which share the same read) also now cap at the
newest 1,000 snapshot rows of the selected window, with a note when the window holds more than that.
REF:
[Fetch Active Queries plan XML on demand instead of every snapshot row (#4239) #4303]: Fetch Active Queries plan XML on demand instead of every snapshot row (#4239) #4303
🤖 Generated with Claude Code
https://claude.ai/code/session_01TszxYhJJbTEh4LrZ56NYo3