perf(dashboard): cap raw-SQL tile cost at the source with a server-side row/cardinality limit - #2856
perf(dashboard): cap raw-SQL tile cost at the source with a server-side row/cardinality limit#2856brandon-pereira wants to merge 5 commits into
Conversation
…-cardinality tables Raw SQL dashboard tiles rendered as a line, stacked-bar, pie, or bar chart had no bound on how many rows a query could return. A pathological high-cardinality group-by could stream hundreds of thousands of rows into the browser, and the client had to transform and materialize all of them before drawing — the dominant cost of loading a heavy dashboard. Builds on the per-tile series cap (#2802), which bounds what is *rendered*; this bounds what is *transferred and materialized*. Fewer rows returned = less client-side processing = faster tile load. Cap raw-SQL tile queries at 5,000 rows (+1 headroom) via two complementary ClickHouse settings: - max_result_rows + result_overflow_mode='break' bounds result rows - max_rows_to_group_by + group_by_overflow_mode='any' bounds aggregation cardinality (the setting that actually protects memory, since 'break' alone is defeated by a result that fits in one block) The +1 headroom means a complete result of exactly the cap comes back whole and is not flagged; only a larger result trips the cap. Detection uses the returned row count alone (never rows_before_limit_at_least), so a tile whose own SQL ends in a LIMIT is not falsely flagged. Builder tiles are unaffected (they already bound cardinality via the series limit; builder tables page rows separately). When a query hits the cap, an inline banner below the chart header notes the chart may be missing data and nudges the user to narrow the query. The banner clears while a narrowed query is in flight, and when a result is both row-capped and series-capped the hidden-series notice no longer claims all series were loaded.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
🦋 Changeset detectedLatest commit: 9c5f2a7 The changes in this PR will be included in the next version bump. This PR includes changesets to release 3 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
🟡 Tier 3 — StandardIntroduces new logic, modifies core functionality, or touches areas with non-trivial risk. Why this tier:
Review process: Full human review — logic, architecture, edge cases. Stats
|
Greptile SummaryThe PR caps raw-SQL dashboard chart results and surfaces possible truncation while preserving ordered top-N queries.
Confidence Score: 5/5The PR appears safe to merge. No blocking failure remains.
|
| Filename | Overview |
|---|---|
| packages/app/src/defaults.ts | Adds row-cap defaults, outer-LIMIT detection, ClickHouse setting resolution, and overflow-state helpers; the displayed prior LIMIT-detection cases are covered at HEAD. |
| packages/app/src/hooks/useChartConfig.tsx | Applies the resolved ClickHouse limits across serial, chunked, and parallel chart-query paths and propagates per-query overflow state. |
| packages/app/src/DBDashboardPage.tsx | Enables the default row cap only for raw-SQL dashboard chart tiles. |
| packages/app/src/components/DBTimeChart.tsx | Passes caps to current and comparison queries and displays a freshness-gated overflow warning. |
| packages/app/src/components/charts/CategoricalChart.tsx | Applies capped querying and exposes overflow metadata to bar and pie chart renderers. |
| packages/app/src/components/charts/ResultOverflowBanner.tsx | Adds the inline warning shown when a completed, non-placeholder result exceeds its configured cap. |
Flowchart
%%{init: {'theme': 'neutral'}}%%
flowchart LR
Tile[Raw-SQL dashboard tile] --> Detect{Outer LIMIT detected?}
Detect -->|Yes| ResultCap[max_result_rows cap]
Detect -->|No| BothCaps[Result-row and group-cardinality caps]
ResultCap --> ClickHouse[(ClickHouse)]
BothCaps --> ClickHouse
ClickHouse --> Overflow{Any query chunk exceeds cap?}
Overflow -->|Yes| Banner[Show possible missing-data warning]
Overflow -->|No| Chart[Render chart normally]
Reviews (5): Last reviewed commit: "fix(dashboard): consume multiline traili..." | Re-trigger Greptile
E2E Test Results✅ All tests passed • 275 passed • 1 skipped • 1125s
Tests ran across 4 shards in parallel. |
Deep ReviewScope: Raw-SQL dashboard tile row/cardinality cap — ✅ No critical (P0/P1) issues found. The core logic is sound and heavily tested; the 🟡 P2 -- recommended
🔵 P3 nitpicks (5)
Reviewers (6): correctness (dispatched, no return), testing, maintainability, project-standards, performance, kieran-typescript, julik-frontend-races, adversarial (dispatched, no return). Coverage: The correctness and adversarial reviewers were dispatched but did not return before synthesis; the top P2 (LIMIT-BY false-negative) was independently traced against the committed regexes and corroborated by two returning reviewers, so it stands regardless. The PR-description reference to Testing gaps:
Residual risks: Both caps are block-aligned/soft, so a single result can overshoot ~5,000 by up to one block (acknowledged in the PR copy); |
…xes) Addresses Greptile + deep-review feedback on the tile result-row cap: - P1 (silent top-N corruption): group_by_overflow_mode folds/drops GROUP BY keys DURING aggregation, before an outer `ORDER BY … LIMIT N` runs, so a raw-SQL tile of that shape computed its top-N over an arbitrary key subset and rendered silently wrong values with no banner (result stayed <= cap). Now the cardinality cap (max_rows_to_group_by) is applied ONLY when the query has no outer LIMIT (hasOuterLimit heuristic), and uses 'break' (deterministic stop) instead of 'any' (arbitrary fold). The order-preserving max_result_rows cap still always applies. Centralized in resolveResultRowLimitSettings. - P2: thread maxResultRows into the previous-period comparison query so the compareToPreviousPeriod overlay can't bypass the cap. - P2: add tests for the new UI wiring — resultWasCapped copy branch, raw-vs- builder cap selection (resolveTileMaxResultRows), and the settings chosen for raw SQL with/without an outer LIMIT. - P3: extract shared resolveDidOverflow helper (was duplicated across DBTimeChart/CategoricalChart), fix a stale comment, add a NaN row-count guard test. Also trims the verbose explanatory comments added in the first pass.
… TIES (greptile P1) hasOuterLimit's end-anchored regex missed an outer LIMIT followed by a valid trailing clause (`LIMIT 50 SETTINGS ...`, `LIMIT 50 FORMAT JSON`, `LIMIT 50 WITH TIES`). Those queries would fall through to the group-by cardinality cap, which truncates aggregation before the outer ORDER BY/LIMIT selects the top-N — the exact silent-corruption case the LIMIT check exists to prevent. Strip trailing SETTINGS/FORMAT/comment/semicolon clauses before the LIMIT test and accept an optional WITH TIES. Adds tests for each shape.
…ex tests Follow-up review fixes (greptile P1 + deep-review P2s): - hasOuterLimit now strips trailing block comments (/* ... */) before the LIMIT test and recognizes the `LIMIT n BY col` form. Both were false negatives that would wrongly enable the group-by cardinality cap and silently corrupt a raw-SQL top-N. The LIMIT BY matcher is end-anchored and excludes ')' so an inner-subquery LIMIT BY isn't matched. - Tests: block-comment / LIMIT BY / subquery-LIMIT-BY cases for hasOuterLimit, and a parallel-query (enableParallelQueries) assertion that each chunk query carries the row-cap clickhouse_settings. - P3: rename the singular helper resolveResultRowLimitSetting -> resolveMaxResultRowsValue to disambiguate from the plural settings builder. Deferred (deep-review P2): GROUP BY ... ORDER BY with no LIMIT still applies the cardinality cap by design (keeps the memory guard); the banner wording for that case is left as a follow-up.
…tection (greptile P1) TRAILING_CLAUSE_RE used `settings\s+.+`, and `.` doesn't match newlines, so a `LIMIT 50 SETTINGS\n max_threads=4` clause wrapped onto multiple lines wasn't fully stripped — hasOuterLimit returned false and the group-by cardinality cap was wrongly enabled, silently corrupting the top-N. Use `[\s\S]+` so a multiline SETTINGS clause is consumed. Adds a multiline-SETTINGS test.
Why
Closes HDX-4936.
High-cardinality tables make heavy dashboards slow to load. A raw SQL tile with a pathological group-by can return hundreds of thousands of rows, and the client has to transform and materialize every one of them before it can draw the chart — that transform is the dominant cost of loading such a dashboard. Fewer rows returned = less client-side processing = a faster, lighter load. Unbounded group-bys can also exhaust server memory.
This builds directly on #2802 (
perf(dashboard): cap high-cardinality time-chart series). That PR capped what gets rendered (top-N series by peak value). This PR caps what gets transferred and materialized in the first place — the earlier and cheaper place to stop the bleeding. Together: the row cap keeps the payload small, and the series cap keeps the draw small.Only raw SQL tiles are affected. Builder line/bar/pie tiles already bound cardinality via the per-tile series limit from #2802, and builder tables page rows through
useOffsetPaginatedQuery— so they have a guard already. Raw SQL tiles rendered as a line, stacked-bar, pie, or bar chart had none.Existing cap for frontend UI rendering (copy improved):

New cap for queries where the server cap is introduced:

What changed
Server-side row + cardinality cap
Raw SQL tile queries now run capped at 5,000 rows (
DEFAULT_MAX_TILE_RESULT_ROWS) with one row of headroom (cap + 1), via two complementary ClickHouse settings resolved inresolveResultRowLimitSettings:max_result_rows = cap + 1+result_overflow_mode = 'break'— bounds the result rows. Weak on its own:breakonly stops between result blocks, so a result that fits in one block (up to ~65k rows) can come back whole.max_rows_to_group_by = cap + 1+group_by_overflow_mode = 'break'— bounds the aggregation cardinality (unique GROUP BY keys). This is the setting that actually protects server memory for a high-cardinality group-by.The cardinality cap is deliberately skipped when the tile's SQL carries its own outer
LIMIT:max_rows_to_group_bystops accumulating keys during aggregation, before an outerORDER BY … LIMIT Nruns, so applying it there would compute the top-N over an arbitrary key subset and render silently wrong values. Outer-LIMITdetection accounts for trailingSETTINGS/FORMAT/WITH TIESclauses, comments, and multiline SQL.Both are soft, block-aligned caps (checked after each data part), so a real result can overshoot
cap + 1by up to one block. They are not exact truncations — hence the copy says the chart may be missing data.Why
cap + 1headroomAsking for exactly
capwould flag a complete result of exactlycaprows as overflowed. With one row of headroom, a result of ≤capcomes back whole and is not flagged; only a larger result trips the break. Detection is then simplyrows > cap.Overflow detection & banner
didResultOverflow), neverrows_before_limit_at_least. That field is only populated when the query has its own LIMIT stage, and it reports the count before the user's LIMIT — so a tile whose SQL ends in... LIMIT 50over a huge aggregation would be wrongly flagged.rows > capcan't false-positive that way, because the tile genuinely received ≤caprows.ResultOverflowBannerrenders just below the chart header (via a newbelowHeaderslot onChartContainer) when a tile hits the cap. It's a single tight line + tooltip that nudges the user to narrow the query (stricter GROUP BY / WHERE / shorter range).data.isComplete(no mid-stream flap as chunks accumulate) and!isPlaceholderData(a stale "capped" banner doesn't linger while a narrowed query is in flight — otherwise narrowing to escape the cap reads as "my fix didn't work" until the new result settles).HiddenSeriesIndicatordrops its "all series were loaded" claim so the two banners don't contradict each other.Chunked queries
A chunked time range applies the cap per chunk, so the accumulated total can exceed the cap without any single chunk overflowing. We track whether any individual chunk hit the cap (the definitive per-query signal) and OR it with the whole-result check.
Impact
max_rows_to_group_byTests
defaults.ts:resolveResultRowLimitSettings(cap + 1 headroom; non-positive → no settings; cardinality cap skipped when an outer LIMIT is present, including trailing SETTINGS/FORMAT/comments) anddidResultOverflow(rows > cap; never false-positives on a LIMIT'd query).useChartConfig: sendsmax_result_rows/result_overflow_mode/max_rows_to_group_by/group_by_overflow_modewith cap + 1 and flagsdidOverflow; sends nothing and flags nothing whenmaxResultRowsis unset.ResultOverflowBanner/HiddenSeriesIndicator: banner renders only whendidOverflowand shows row/series counts; the hidden-series notice drops its "all loaded" claim when also row-capped.Notes for reviewers
minorbump for@hyperdx/app).