Dedup-first eager aggregation for GROUP BY over string functions of one column - #61
Draft
bgmcmullen wants to merge 1 commit into
Draft
Dedup-first eager aggregation for GROUP BY over string functions of one column#61bgmcmullen wants to merge 1 commit into
bgmcmullen wants to merge 1 commit into
Conversation
…ne column GROUP BY f(col) evaluates f per input row, so a string-producing function over a dictionary-encoded column manufactures a full-length copy per row even when the column holds few distinct values. Chunked key evaluation (#60) cannot bound the wrapped form: substr(f(col), 1, 150) makes the observed key bytes tiny, the chunk grows back to its cap, and the full-length intermediates blow the heap between size observations. Rewrite the plan into two stages: group by the raw column first (reference comparisons only), then apply f and merge partial aggregates per final key. Fires only when provably answer-preserving: single key over one unqualified column through deterministic scalar constructs including at least one string-producing function or string cast; splittable aggregates only (COUNT/COUNTIF/SUM/MIN/MAX, no DISTINCT/FILTER); no HAVING; aggregate-level ORDER BY redirected to output aliases or the rewrite declines. Incident-shape bench (94,392 rows sharing 3,445 distinct ~90KB strings, 8.2GB logical, 4GB heap): OOM before, 3,445 rows at 761MB peak / 14.8s after. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L7LMSa2odTyibuvg4AzdGg
bgmcmullen
marked this pull request as draft
August 31, 2026 23:17
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.
The query that was breaking
hypaware-server's 2026-08-26 outage query (hyparam/hypaware-server#394 and #395 hold the containment side) still OOMs a 4GB heap on v0.16.3:
Data: 94,392 rows sharing 3,445 distinct ~90KB
system_textvalues by reference (8.2GB logical, parquet dictionary decode).The deciding characteristic is a fat intermediate hidden behind a small final key. #60 sizes chunks from the evaluated key bytes, and
substr(..., 1, 150)makes every key look 150 bytes wide, so the chunk grows back to its 4000-row cap while each row still materializes a full ~90KBregexp_replacecopy inside the expression, where the sizing never sees it. AnyGROUP BY substr(lower(col), ...)orGROUP BY substr(CAST(col AS VARCHAR), ...)fails the same way. Unwrapped (GROUP BY regexp_replace(...), #60's own bench shape) stays bounded, so the wrapper is what breaks it.The fix: dedup first, transform after
Grouping on the raw column only compares references, so the inner stage collapses 94k rows to 3,445 before
fruns once per survivor. Answer-preserving becausefis deterministic, and the executor is untouched: both stages are ordinaryHashAggregatenodes, still covered by #60's chunking. This is the relational form of the "dictionary-aware batch evaluation" #60's notes name as a follow-up, and it covers the repeated-input case #57/#58 targeted without a result cache. New filesrc/plan/eagerAggregate.js, applied whereplanSelectbuilds the aggregate node.When it fires
All of: one GROUP BY key over exactly one unqualified column through deterministic scalar constructs; the key holds at least one string-producing function or string cast; every output is the key or a splittable aggregate (
COUNT/COUNTIF/SUM/MIN/MAX, noDISTINCT/FILTER); noHAVING; and every aggregate-level ORDER BY term matches an output column, so it can be redirected to that alias (re-evaluatingCOUNT(*)on the merge stage would count partial rows). Otherwise the node is returned unchanged.Results
One process per shape,
executeSqldirectly, 4GB cap, peakheapUsedsampled at 25ms (peaks are lower bounds; pass/fail is the reliable signal). Top two rows run 3x.GROUP BY substr(regexp_replace(text,…),1,150)GROUP BY regexp_replace(text,…)GROUP BY text,GROUP BY id % 10,JSON_EXTRACT(...),LIKE/REGEXP_LIKESELECT regexp_replace(text,…) FROM tSELECT upper(text) AS k FROM t ORDER BY k(no LIMIT)Rows 4-5 need bounded sort-key evaluation and a caller-side refusal, not a plan rewrite (
ORDER BY ... LIMIT nis already bounded by the existing top-k path). Row 6 is not a regression - v0.16.3 fails it too, since the sizing blindness is independent of cardinality - but the mechanism does shift from evaluation churn to dedup-map retention.I built and measured a byte-capped flushing dedup map for row 6 and rejected it: on dictionary data it ran 136s against 14.8s, because 3,445 x 90KB is ~620MB of key bytes, far above any sane bound, so the map flushes nearly every chunk and the merge stage re-runs
fon ~82k rows instead of 3,445. It still OOM'd on all-distinct data. A byte tally cannot tell a shared dictionary reference (~8 bytes of real cost) from a uniquely-held one (~180KB), so row 6 wants a cardinality signal from the data source rather than an executor heuristic.Tests
16 in
test/plan/plan.eagerAggregate.test.js: rewrite shape (incident query, string casts, ORDER BY redirection), all nine decline conditions, and execution equivalence over repeated values, distinct values normalizing to one key, NULL keys, and NULL-skipping COUNT (COUNT/SUM/MIN/MAX, CTE, alias ORDER BY). Suite 2,009 green;tscandeslintclean.🤖 Generated with Claude Code
https://claude.ai/code/session_01L7LMSa2odTyibuvg4AzdGg