Repository navigation
The Darling viewer's procedure comparison, the two comparison twins and Query Store top text lookups get a time bound (#5420) - #5427
Merged
Conversation
…tring key apart (#5420) The outer FULL JOIN joined on COALESCE(key,'') alone, so a NULL-key group and an empty-key group that both made the top 100 merged into one row and fanned out. Each outer join now carries the IS NULL pair for every key, which stays hashable (a Hash Join on all three twins).
erikdarlingdata
marked this pull request as ready for review
October 6, 2026 21:50
…ies two stacked summaries (#5420)
This was referenced Oct 7, 2026
Closed
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.
Closes #5420.
Why
On a large store the Darling viewer's procedure comparison took about 25 s. Of that, 17.6 s went on finding each procedure's statement text. There were two causes, and both were reproduced on a rig.
sql_handle(thequery_statshandle index was dropped in Five tuning indexes on the raw hypertables write about a quarter of the store's WAL, and none has been read more than 13 times across the retained chunks #4247). A procedure with no text anywhere walked every retained chunk on the time index.IS NOT DISTINCT FROMon three columns. That cannot hash. Each period ran as a nested loop that discarded millions of rows.The Query Store top reads had the same unbounded shape in their inline-text fallback (
query_store_stats), in the viewer and in the MCP reader.What changes
ViewerDataService.ProcedureStatsComparisonSql):COALESCE(x, '') = COALESCE(y, '') AND (x IS NULL) = (y IS NULL)per key. That is exactly NULL-safe, because a NULL key never matches an empty string, and it has no join filter.textsCTE. It scansv_query_statsover the current range and the baseline range (two ranges, not the span between them, see review round 1). It is narrowed to the compared handles, andDISTINCT ON (sql_handle)takes the newest row with a text.v_query_statsis kept, so the payload dimension (query_text/query_plan_xml stored inline per row: 94% of a field store — normalize into hash-keyed dimension tables (~135x measured) #1767) still resolves.QueryStatsComparisonSqlandQueryStoreComparisonSql):COALESCE(key, '')alone. When a NULL-key group and an empty-key group both made the top 100, they merged into one row and fanned out. Each outer join now also carries(c.key IS NULL) = (b.key IS NULL)for every key column, so it stays a hash-joinable condition (EXPLAINshows a Hash Join on all three). The doc comments no longer claim a key is never the empty string. Same rows for every seed with no empty-string key. New live fact per twin: the NULL and empty groups both sit in the top 100 of both windows. The answer has two separate rows, each against its own baseline. RED: dropping oneIS NULLhalf in each outer join fails all three facts. It also fails theComparisonSql_UnionsTop100_FullOuterJoins_NullSafepin, which now counts both halves of every key in the outer join. Procedure keys can be NULL (all three columns are nullable incollect.procedure_stats), so it is covered the same way.s.collection_time >= $2and<= $3on the inline-text subquery. This is in the viewer's shared tail and the MCP reader's shared tail (raw, table and daily reads). The viewer keeps its open-end form($3::timestamp IS NULL OR ...).GetQueryStoreTopQueriesAsynchad the same unbounded LATERAL, and I bounded it here. Then Lite: a read that includes query text can run out of memory at the 1 GB DuckDB limit #5381 on dev replaced that lateral with a keyed text read over all history, on purpose, "as the old lateral picked". That conflicted with my change. I took dev's side and dropped the Lite test that pinned the bound. Lite's procedure comparison and Top Queries text picks were already bounded in Lite: a read that includes query text can run out of memory at the 1 GB DuckDB limit #5381, and DuckDB hashesIS NOT DISTINCT FROMitself, so they are untouched. Lite's procedure comparison now matches the viewer. The Query Store top text is the one place the two differ, and that was ruled: the viewer takes Query Store text fromquery_store_textfirst. That table has one row per query and no time bound. So the viewer shows the same text Lite reads from the query's latest row over all history. Only the rare inline fallback is bounded to the window. That is a query with noquery_store_textrow yet, and in PostgreSQL its lookup has no key index and walked every chunk. Lite is not bounded. A bound there drops text Lite shows today.DatabaseFilterTestsstill sees 6 database-filter placeholders in the comparison.QueryStoreTopDailyReadLiveTestsre-takes the two statement hashes on purpose, because the tail changed. The reason is in the test comment.Twin list: viewer
ProcedureStatsComparisonSql, viewerQueryStoreTopSqlandQueryStoreTopTableSql, MCPDarlingDataReader.QueryStoreTopSql,QueryStoreTopTableSqlandQueryStoreTopDailyTableSql(the daily route has the same fallback). Not changed, with reasons:ViewerDataService.QueryStatshourly-arm text lookup. It is keyed onquery_hashwith a kept index, and it reads past the window on purpose.DarlingReproScript.QueryStatsTextSql. It is one click-through lookup by hash with no window.DarlingDataReadertop-queries LATERALs. They are already bounded to the window.Test plan
Rig: PostgreSQL 18.6 with TimescaleDB, 1-day chunks, 15 days seeded. It holds 202 procedures, 864k
procedure_statsrows and 767kquery_statsrows, and about 20% of procedures have no text. The rig is not the large store. Its absolute numbers are smaller than the 25 s measured there, so compare the ratios. Five runs each, EXPLAIN (ANALYZE) execution time, custom plans:The first before-run at 24 h was cold and the rest were warm. The period joins now plan as Merge Join, not Hash Join, on the pre-aggregated inputs of about 200 rows. Both are plain-equality joins with no join filter, so the pin accepts either.
ViewerTextLookupWindowBoundLiveTests, on its own scratch store with hypertables and 13 chunks. The new comparison reads at most 3 of 13query_statschunks, and its joins have noDISTINCT FROMfilter. The pre-change statement on the same seed reads more than 3 chunks and has that join filter, which proves the shape test is not vacuous.Review round 1
The head moved from
5be1af60fto the merge of dev plus this round.origin/devconflicted onLite/Services/LocalDataService.QueryStore.cs(see the Lite bullet above). Everything else merged clean.textsCTE now reads(collection_time >= $2 AND <= $3) OR (>= $4 AND <= $5). The ends are inclusive, as in the rest of the read and as in Lite's pick. Pin:TheComparison_WithABaselineAWeekBack_ReadsTheTwoWindowsChunksAndNotTheGapBetweenThem. A baseline of End-8d to End-7d reads at most 4 of 13query_statschunks. RED: reverting toLEAST/GREATESTmakes it read 9 of 13, and the fact fails.qs.sql_handle, qs.collection_time DESC, qs.collection_id DESC, so the row collected last wins. Pin:WhenOneProceduresStatementsShareAnInstant_TheRowCollectedLastSuppliesTheText. It uses two procedures with two rows each at one instant, one inserted lower id first and one higher id first. RED: without the tie-break it returns "tie A: the lower collection id".QueryStatsComparisonSqlandQueryStoreComparisonSqlnow aggregate each period once in a derived tablew. The top list joins towon the NULL-safe equality pair, as the procedure comparison does. The Query Store text left join moved insidew, and the [BUG] 3.4.0 query_store collector runs 37–100 min on Azure SQL DB (3.3.0 median: 4.8 s) — starves all other collectors #2150 and Query Store aggregates double-count re-collected cumulative intervals - tier 2: interval identity + CAGG rebuild (tier 1 read-side dedup shipped in #1845) #1841 comments are kept.TheTwinComparison_JoinsItsPeriodsOnPlainEqualities_NotANullSafeJoinFilter. The plan has noDISTINCT FROMjoin filter and at least two hash or merge joins onquery_hash. The old statement on the same seed does have the filter.TheTwinComparison_AnswersTheOldStatementsAnswer_OnEveryColumn_NullKeysIncluded. The old statement is copied into the test as the oracle. The seed has NULL database, NULL hash, both-NULL, GONE and NEW keys. Every column is compared by exact double bits, filtered and unfiltered, for both twins.PgTableTuning.cscomment for the droppedidx_query_stats_server_handle_timenow says the LATERAL reader is gone. The text is found by one window scan.waggregates of the procedure comparison and the Top Queries comparison. The Query Store dedupe CTEs already had it. Results are identical:TheComparison_UnderADatabaseFilter_AnswersTheOldStatementsAnswer_ExceptTheDocumentedTextChangeand the filtered twin oracle facts pass. There is no RED, because it changes the work done and not the rows.DatabaseFilterTestsneeded no count change, because it checks that the$6predicate is present and not how often.ViewerQueriesSqlTests.ComparisonSql_UnionsTop100_FullOuterJoins_NullSafestill pinned the oldIS NOT DISTINCT FROMandGROUP BY th...text. It was already failing for the procedure comparison on the first round's head. It now pins the new NULL-safe pair.Twin timings come from my rig run on the review's seed: 505,500
query_statsrows over 14 days, 3,000 hashes and 673,000query_store_statsrows. Each is EXPLAIN (ANALYZE), 3 warm runs. The rows are identical both ways (EXCEPT 0 and 0) on all four reads: 195, 195, 196 and 182 rows.The review measured 2.4 to 3.4 s, 10 to 16 s, 3.8 to 3.9 s and 18 to 24 s before. It measured 0.11 to 0.13 s, 0.6 to 0.9 s, 0.49 to 0.60 s and 2.1 to 2.3 s after, on its own rig.
Tests after the merge (rig port 5714, UTC, TimescaleDB,
pg_stat_statementspreloaded):ViewerTextLookupWindowBoundLiveTests: 17 of 17 pass (8 old, 9 new).*ViewerTextLookupWindowBoundLiveTests*,*QueryStoreTopDailyReadLiveTests*,*DatabaseFilterTests*,*TuningReaderBudgetLiveTests*,*PayloadDimensionTests*,*StatementFilter*,*Viewer*,*Comparison*,*Statement*,*Census*and*Ratchet*: Total 4937, Failed 2.SensitiveStatementAutoParamTests.Measure_20MbPlan_WithACut_ThreeShapes_StayUnderTheCeilingsandStatementFilterAlertTests.Engine_AFourMegabyteReport_ReachesTheDelivererFilteredNotWithheldWhole. They are timing measures that failed under the 6 minute parallel load and pass alone (58 of 58).*QueryStoreTop*,*QueryStoreDedupRead*,*QueryStatsTextAfterRanking*,*ProcedureStats*,*Census*,*Ratchet*,*StatementFilter*): Total 123, Failed 0.Review round 2
IS NULLhalf left every fact green. Both twin seeds now hold the pairs the procedure seed already had.COALESCE, in the old statement as well as the new one. With both partners in the top lists, that join collides them. With one light, the final join stays apart. The period join then fans a heavy row out if it merges the pair.ComparisonSql_UnionsTop100_FullOuterJoins_NullSafecounts theCOALESCEhalf and theIS NULLhalf for every key. Each must appear exactly twice, once per period. That is two columns for the twins and three for the procedure comparison.query_hashIS NULLhalf from the Top Queries twin and thedatabase_namehalf from the Query Store twin. All four facts failed: the two pin cases and both oracle facts (duplicate keydb0|<null>for Top Queries and|h127for Query Store). Both halves are restored.*ViewerTextLookupWindowBoundLiveTests*,*ViewerQueriesSqlTests*,*DatabaseFilterTests*,*Census*,*Ratchet*and*DocCommentHygiene*: Total 673, Failed 0 (rig port 5720, UTC). Build 0 warnings.query_statsrows (Top Queries) and 673,000query_store_statsrows (Query Store). No product code changed in this round.