Repository navigation
fix(query): align membership, concat, and grouped fallback semantics - #744
Conversation
singaraiona
left a comment
There was a problem hiding this comment.
Reviewed 2ab29f1 against the four follow-up issues from #742. Constant collection concatenation and numeric whole-column fallback are routed separately from row-wise text concatenation, evaluator membership applies the query null-row rule, and the index diagnostic names the supported types. Under ASan/UBSan, all 3 membership-filtered tests, all 28 concat-filtered tests, and the zone_sym_diagnostic test pass. Additional probes covered filtered/computed numeric concatenation, scalar aggregation, mixed-type constant collections, and fallback membership projections. No new actionable findings. Grouped aggregation over numeric concat remains unsupported, but it also failed before this PR.
|
Reviewed. The four findings from #742 are fixed. For SYM, integer, F64 and temporal columns with nulls, the planned and evaluator
Outside queries, Before merging:
Nits:
Tests: disabling each new guard makes a new test fail. There are no evaluator-path cases for GUID, STR, F64, temporal or MAPCOMMON columns, for A run of the 43-query workload on 100M rows with this PR together with #741 and #743 on top of dev matches dev's timings and outputs: those queries all take the planned path. |
|
Fixed in 74fca72. Thanks for the concrete repros; these exposed gaps in my earlier review:
The existing query-specific concatenation of two scalar symbols remains string concatenation; it is distinct from eager collection concat. STR membership retains its existing collection behavior and typed-plan limitation. The compile-error ownership guard is also fixed. Validation: full Added
The evaluator now runs close to the planned path on this benchmark. These are local measurements, not the same machine/results as the review's baseline. The optional diagnostic type-name polish remains unchanged. |
|
Rechecked 74fca72. Fixed:
The typed-kernel-first path matches the masked path in 49 column × probe combinations, byte-identical to 2ab29f1:
Each of the five new guards fails a test when disabled. About 140 projection shapes behave as before except the ones listed below. New regression: grouped arithmetic over two or more aggregates, one of them over a text concat, now errors.
Risks:
Nits:
Unrelated to this PR, but worth an issue:
|
|
Fixed the grouped-arithmetic regression and scalar broadcasting in ad2cb03.
The broader GUID producer/null-detection inconsistency, repeated inline-probe evaluation in projections, empty projection typing, and mixed scalar/nested LIST membership remain outside this fix. The planned Validation: |
|
Rechecked ad2cb03 against 74fca72 and 2ab29f1. Fixed:
Tests. I reverted each hunk in turn and ran
Behaviour changes from the exec.c hunk (T has 3 rows, O has 1 row):
Performance. Release build, 20M rows,
Unrelated to this PR (also on dev 57f5cfe and v2.11.0):
|
|
Fixed in 896af4d, including the related older failures reported in this review.
Validation: 4,082/4,082 passed, 0 skipped, 0 failed (ASan/UBSan, eight workers), release build passed. The sparse top-N and aggregate-broadcast regressions were also checked by temporarily disabling their fixes, then restoring them before the final run. |
Rayforce targeted audit passedThe required Rayforce audit gate passed on the latest run. Workflow run: https://github.com/RayforceDB/rayforce/actions/runs/38075476747 |
|
Fixed the audit's blocking The whole-column special case is now limited to a single known scalar. Column references (including quoted column names), vector constants/bindings, and multi-argument The alias scratch capacity is Validation: 4,114/4,114 passed, 0 skipped, 0 failed (ASan/UBSan, two workers, after syncing current The earlier CI pool failure did not reproduce locally: its focused case passed 20/20 repeated runs and the full merged-base suite. The new required audit run has passed. |
|
Rechecked 896af4d and 37a6816. Fixed:
BUG, still on 37a6816: inside any query, The planner already does this on dev for literal vectors: Tests. I reverted each hunk of 896af4d in turn. Every one makes a test fail except two:
Behaviour changes, intended as far as I can tell:
Performance. Release build, 20M rows,
|
|
Fixed in a1d3c2a.
Your repros now return Added forced per-group fallback cases for Validation: |
|
Follow-up on CI: synced #754 from current
Validation on the integrated branch: 4,119/4,119 ASan/UBSan tests passed, zero skips/failures; release build passed; all three read-ahead cases passed 30 repeated runs; captured seed passed 100 four-worker replays. Fresh 60-second differential fuzz campaigns passed in serial (1,830 inputs) and four-worker mode (801 inputs). |
|
Rechecked a1d3c2a. In Setup for the repros below: 1. Lambda helpers that contain
The decline fires on any 2.
3. A column concatenated with a bound vector takes the text kernel in the per-group fallback. The eval.c condition takes it when either side reads rows.
4. Abort. dev and 37a6816 give 5. Perf.
Elsewhere there is no change: Tests. I reverted each hunk in turn. These revert without any in-tree test failing:
The variadic-third and lexical-shadowing tests are projections, so the planner handles them, not the evaluator code they are meant to cover.
It would be safer to keep inlining lambdas and decide the kernel after inlining, from whether each |
|
Follow-up on d6970a7. Its #744 together with #754. I compared 113 nested queries merged against
The fuzz oracle relaxation in d6970a7:
The underlying engine bug: a parallel F64 sum is nondeterministic. This is not from this PR; it reproduces on dev 57f5cfe. Two keyless paths keep one accumulator per worker while
So each partial depends on which worker claimed which task. This is fixed in #758: partials are kept per chunk, with chunk boundaries derived from the row count, and folded in chunk order, which gives the same bits at every core count. With it, the select-merge oracle can stay strict instead of tolerating parallel F64 differences. Smaller items:
|
Scalar reductions and the keyless select scan kept one accumulator per worker while the pool hands out tasks dynamically, so F64 sum, avg, var and prod changed from run to run. Partials are now kept per chunk, with chunk boundaries derived from the row count only, and folded in chunk order, which gives the same bits at every core count.
|
One more The two problems, both on acaa50a and on dev 57f5cfe:
Could you cover this in #744? I'd expect the grouped update and the grouped select to both give |
…or concat A grouped (concat (first s) 'zz) gave null for an empty symbol in a grouped update and failed to produce one value per group in a grouped select, while the ungrouped projection read the empty cell as empty text. The planner's concat kernel takes a cell mask: an operand that reduces a text column (first, last, min, max) is a cell, so a null there is empty text and a STR cell keeps STR output. The grouped select's arithmetic-of-aggregates wrappers run with the group result as the active table and the hidden aggregate columns flagged as row-derived.
|
Covered in ae5d195. Both forms give The rule that was missing: a text column reduced to one value (
Tests. Validation: |
|
Rechecked 01870ab through ae5d195 (CI green). Release builds, compared with 8821daa, d698cbd and dev 57f5cfe. Fixed
New in 01870ab. In each case 8821daa, d698cbd and dev give the right value.
Purity check: an effect through a parameter runs twice. Tests. These hunks revert without any test failing:
Regression runs (2×24-thread box). 8821daa and acaa50a each did the load plus 3 rounds of 43 queries. Both are within noise of dev, and the only output difference from v2.11.0 is the requested column order of the 4-key group. |
…al capture, parameters in the purity walk A capture whose let had not run read as 0: capture slots are no longer fixed, so an empty one falls back to the global or a name error like any let slot. eval and resolve in a compiled body run inside a scope window over its locals, so a closure's eval sees its captures. Template compilation stops nesting at 32 levels and leaves deeper literals to the dynamic path, which compiles one level per call. A tree-walked lambda call marks its frames as the lexical boundary for capture, so a caller's locals are not captured whether the maker compiles or not. The by: key purity walk binds a callee's parameters to the call's argument text, so an effect reached through a parameter counts and a pure helper passed as one keeps the whole column.
… by interpreted code A closure made by the tree walker (the top level, a lambda that could not compile, a literal inside a special form the compiler hands to the tree walker) compiled its own copy of the body on its first call. ray_fn now keeps a per-thread table of templates keyed by the literal's parameter and body nodes (retained, so an address stays the entry's own) and its capture names; a hit shares the bytecode, a miss compiles once. The table is dropped with the runtime. Tests pin the sharing, two capture sets from one literal, the block-boundary case of the key null scan and the interpreted lazy capture.
Hunks. The null scan's last-element case is pinned by a key column whose single surviving null lands on slot 4095. The interpreted lazy capture is pinned by your Tests. Validation: |
Two literals built for eval can share their parameter, first and last nodes and differ in between; the template table compared only those, so the second literal ran the first one's bytecode. An entry keeps every body node (literals over eight forms are not cached) and a hit requires all of them. A thread whose table outlived its runtime forgets its entries instead of releasing objects of a destroyed heap, and ray_fn uses the compiler's own closure shape gate.
|
Audit finding addressed in 840261e.
Validation: |
A template entry remembers the symbols its literal mentions, so a hit costs a pass over the dict keys instead of a walk of the body and allocates nothing; the capture walk gathers row flags in a local buffer and builds the flag vector only when one is set. Closures made and never called by an interpreted maker: 249 ms per 200k before, 202 after (dev 121); the fn literal inside or: 136 to 98 ms per 200k.
|
Follow-up in 1f2a0e2, on the audit's note that compilation moved to creation: a closure made by an interpreted maker and never called cost 249 ms per 200k on 840261e against 193 on ae5d195 (dev: 121). A template entry now remembers the symbols its literal mentions, so a hit costs one pass over the dict keys instead of a walk of the body and allocates nothing, and the capture walk builds its row-flag vector only when a binding is flagged. The same shape takes 197 ms now, and the |
|
Rechecked efcc705 through 1f2a0e2 (CI green). Release builds, compared with ae5d195, d698cbd and dev 24c14e8. Fixed
New. In each case ae5d195 and dev give the right value unless noted.
Tests. 1f2a0e2 adds no tests, and these revert without a failure:
Regression runs (2×24-thread box). ae5d195 and 1f2a0e2 each did the load plus 3 rounds of 43 queries. Both are within noise of dev, and the only output difference from v2.11.0 is the requested column order of the 4-key group. |
…ndow wraps only the call The eval/resolve window opened before the arguments were compiled, so a return, a let or a call inside an argument ran in the window and a return leaked the frame; it now opens right before the call and closes right after it. A closure body resolves the builtins it names when it runs instead of baking the one the template saw, and a top-level literal's baked template is recompiled once a function-valued global was rebound (ray_env_fn_generation). The VM's general call applies the same elementwise map, concat rule and provenance join as the dedicated opcodes. The symbol walk and the compiler stop at 1024 and 2048 levels; a literal holding more than 65536 vector elements is not kept in the template table; a body that did not compile is marked and never retried. The by: key purity walk binds a passed lambda's parameters as unknown, lets a nested literal see the enclosing body's parameters, and resolves an argument's name where the call was written. A grouped update expands its helper expression once instead of per group.
…not a remembered expression A remembered expression pointer could match a fresh expression at a reused address and skip the planner for it, turning a schema error into a name error. The flag now belongs to one grouped update. The OP_RET window unwind was unreachable once eval's window wraps only the call and is gone; the deep-literal test runs at the depth the sanitizer build's recursive release survives.
Hunks. Template matching by capture names is pinned with the smaller capture set created first; a row-flagged column captured after 1 and after 21 unflagged bindings keeps its flag; the purity walk's parameter binding for called lambda values and unknown parameters for uncalled ones are each pinned by the shapes above. Tests. Validation: |
…atform's stack The regression test for the depth guard built a 12000-level literal and relied on the sanitizer build surviving its recursive release; the macOS one does not. The body is now a chain of nested ifs: compiled it runs as jumps and returns the parameter, on the tree walker it recurses and stops at the eval depth limit. 600 and 1500 levels return 1, 2500 (past the 2048 guard, within the bytecode jump range) is a limit error; with the guard removed it compiles and returns 1. The syntax page states the limit; the control-flow page's anchor to the lambdas section now resolves.
|
Follow-up in 5bf922d: the macOS debug job overflowed its stack releasing the 12000-level literal my regression test built (the Linux sanitizer build survives that depth, the macOS one does not). The compiler's depth guard is unchanged; the test now pins it by result instead of by a crash. The body is a chain of nested Aside, pre-existing on |
…kes 255 The text kernel read its per-operand cell flags from a 64-bit mask that was written for the first 64 operands only, so operand 64 shifted by 64. The flags are one byte per operand for all 255 positions, in the planned node and the two-operand path alike. The planner folded a query concat into one node up to 16 operands and left longer forms to the binary builtin, which rejected them; it now takes the executor's 255, so one limit governs the form. Tests run 65-operand concats: a cell at position 0 with a null literal at 64 nulls the row, a cell at 64 is a cell (empty text, STR output), a column joins every row.
|
Audit finding addressed in 7c1d2d2. Undefined shift in the concat kernel. Confirmed: the cell flags were a 64-bit mask, read for every operand while written for the first 64, so operand 64 shifted by 64. The flags are now one byte per operand for all 255 positions, in the planner's node and in the two-operand text path alike. Reverting the kernel hunk makes the new test abort under UBSan with The audit's example was not reachable as written: the planner folded a query On the non-blocking observation about the template cache not keying on where a literal was compiled: the compile mode follows the closure's presence (a body with captures resolves its free names at run time, one without keeps the builtins baked), and the capture names are part of the key, so a literal cannot match an entry compiled in the other mode. |
|
Rechecked b32d913 through 7c1d2d2 (CI green). Release builds, compared with 1f2a0e2 and dev 24c14e8; Fixed
New. dev gives the expected value unless noted.
Tests. These revert without any test failing:
Regression run (2×24-thread box). 7c1d2d2 did the load plus 3 rounds of 43 queries. It is within noise of dev, and the only output difference from v2.11.0 is the requested column order of the 4-key group. |
|
Names in queries: a column vs a local or a global. This is not a defect in this PR, but #744 sets part of the rule, so it is better decided before it merges. With A parameter vs a column of the same name. Most paths let the parameter win; several let the column win: The column also wins in Helper bodies (this PR). A free name in a helper now resolves to the calling query's source column before the global, on the inlined and the bytecode paths alike. Around 25 assertions pin this, e.g. the This is dynamic scope for columns: adding a column to a table changes what an existing helper computes, and an opaque helper may read any column, which blocks projection pushdown. In q, a function never sees the calling select's columns or the caller's locals. A proposal for one rule:
How would you like to settle the helper rule for this PR? Keeping it means users start relying on column visibility in helpers. Dropping it now would leave the rest for a follow-up after 2.11.1, and we can take that implementation. |
|
Decided, after checking every case above on this branch, on Consequences for the list above. The paths where the local wins today, the materialised |
|
Agreed that the binder goes after 2.11.1. One correction before it is planned. Today the local wins on most paths, not only the two above. A plain projection already reads the parameter: The same holds for
The column wins only on the count fast path, For #772, the no-output |
|
Correction taken, and confirmed on this branch and on |
…744 orders grouped outputs
…sion aggregate (#760) * fix(query): a multi-key select with a distinct count beside an expression aggregate (#756) A select grouped by two or more keys failed with `name` when it held a `(count (distinct x))` beside an aggregate over an expression, such as `(sum (if (== st 'bad) 1 0))`. The distinct count is not a DAG aggregate, so with more than one key the select fell to the eval-level grouping, which evaluated an aggregate's argument with no column in scope — and which is serial, 8x slower than the parallel path for the shapes that did not fail. The eval-level grouping now evaluates an aggregate's argument over the selected rows with the columns bound, as a row-wise projection (group_key_eval), since the eval-level `if` is scalar. A plain multi-key by: stays on the DAG path when the outputs the DAG cannot serve are distinct counts and literal broadcasts: the post-group scatter maps rows to groups through the composite key (rgid_build_multikey, a parallel tuple probe that applies the selection), and the existing per-group kernels serve the distinct counts. Two shapes the issue's workarounds exposed go the same way. An aggregate over an `if` dropped the group onto the legacy engine, 6x slower under a where: selection: the v2 expression bridge now materializes an input the element-wise compiler declines through the DAG executor. A distinct count alone took the two-pass rewrite — group by (keys, value), then count — which is a radix grouping of every tuple when that composite is not dense (1.8 GB and 3x the time at 10 M rows with 45 groups of 3 M values); the rewrite is now gated on a sampled estimate of the group and value counts (agg_group_card_estimate) and the select otherwise groups by the keys and counts over the slices. Closes #756. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(query): guard the distinct-count gate's division for static analysis * fix(query): -0.0 keys in the distinct-count probe, a sampled gate, by-dict aliases Review round on #760. - The row→group probe (key_read_i64, KEY_READ) reads an F32/F64 key's -0.0 as +0.0, as the grouping does (group_key_f64_bits, #407). A -0.0 row missed the merged zero group and was dropped from every per-group kernel — n fell to 0 beside a right count — on the single-key path before, and on the composite path since 7c17cf7. - agg_group_card_estimate samples 65,536 rows at a fixed stride (the description's claim; the code read 64 blocks of 1,024) and raises the sample's distinct count by the Chao1 estimator from the values seen once and twice, capped at the sampled span for an integer key and the domain count for a SYM key. Skewed and clustered keys estimated low before, so the gate took the slice dedup where the rewrite is 2–4x faster: a single-key where: count-distinct over a skewed SYM key ran 405 ms against 99; it is 45 ms now. The set is sized to the sample, so a 10-row select plans in 4 µs, not 80. - The gate no longer prefers the rewrite under fewer groups than workers. At 2 to 9 groups of 3 M to 6 M values on 28 workers the slice dedup takes the same time or less at 40% of the memory, and the issue's 45 groups at -c 46 take 42 ms / 512 MB against the rewrite's 89 ms / 1375 MB. - The two-pass rewrite keeps a by-dict's aliases, so `{a: k1 b: k2}` names its columns a, b on every route; it declines an alias naming another input column, as the by-dict materialization does. - ray_execute resets the pool's cancel flag at the outermost call only: the grouped expression bridge's nested execution would have dropped a cancel that arrived before it, and the workers would have run the remaining tickets. Tests: -0.0 keys through the probe (F64, F32, one key and a composite, where:, the distinct count alone); a 60 K-group composite under where:, where the probe's selection feeds the global-hash kernel; aliases on both routes, with desc:/take: and where:. Each fails with its fix reverted. Full ASan/UBSan suite 4,111 of 4,111. * fix(query): where: stays alias-blind to a by-dict's keys `(select {from: T by: {a: k1} where: (!= k1 'a) n: (count v)})` returned no rows. The eager by-dict path adds each alias as a column of the table the rest of the select is compiled over, and a literal symbol naming a from-table column resolves to that column (B3 Part 2), so `'a` read the new alias column and the filter compared k1 with itself. The two-pass distinct-count rewrite and the deferred parted route add no column and kept the symbol, so the same where: meant different things on different routes. where: names source columns only, like by: and the sort keys. Before the aliases are added, every literal in it naming an alias the table does not already hold is wrapped in `(quote …)` — the compiler folds a quote call to the symbol; only the tick atom takes the column rule — and the select runs again over the rewritten dict, as the materialized fallback does. Outputs are untouched: a literal naming an earlier projection resolves to it by design. Tests in literal_col_ref.rfl: the alias as a quoted literal in where: with count, sum, asc:/desc:/take:, two aliases, the rewrite route for comparison, a vector literal and a source-column literal (both unchanged), and a trivial self-alias. They fail with the wrap disabled. Full ASan/UBSan suite 4,111 of 4,111. * fix(query): guard the cardinality estimate's division for static analysis * test(query): the composite select's columns in declaration order, as #744 orders grouped outputs * fix(group): F32 keys through the shared column reader read_col_i64 had no F32 arm, so an F32 group key fell to its one-byte default: the parallel group hash table's builders hashed and stored a byte of each key, and a multi-core group by an F32 column over more than a morsel of rows emitted garbage keys and split or merged groups — on a plain count, on a distinct count beside the key, and for a composite key with an F32 member once #760's probe admitted F32. The reader now decodes the 4-byte lane, with -0.0 canonicalised to +0.0 as the F64 key readers do, and write_col_i64 stores it back. F32 stays off the packed-key whitelist: a packed region is raw bit identity, as for F64. test/rfl/query/group_f32_key.rfl runs 100 K rows through every shape at the suite's two workers; its first assertion fails without the arm. * fix(query): the fused kernel's alias, from: evaluated once, a dead decline The fused single-key distinct-count kernel named its key by the source column (K_syms[0]) where every other route names a by-dict's key by its alias, so `{g: k1}` came out as `k1` from 262,144 rows with workers and as `g` below. The where: shield's re-run took the from: expression, so `(do (set C (+ C 1)) T)` ran twice; it now takes the table from: evaluated to. The rewrite's decline of an alias equal to the count's name changed nothing on any route — the key comes out under the name first and the count second everywhere — and is removed; the output is pinned instead, with the duplicate-alias error on the rewrite's route. * perf(query): the distinct-count gate reads the key and (key, value) tuples agg_group_card_sample replaces the estimator's body: one strided pass over the columns, prefetched, 32,768 rows, one row per window at a hashed offset (a fixed position read a key whose period divides the stride as one value), that also hashes the key tuple and the (key, value) tuple into two more sets — the groups a composite forms, which the product of its columns overstates, and the tuples the rewrite's (K, X) pass would keep. A sorted prefix defeats a stride, so when the sampled tuples never decrease, blocks of adjacent rows are read as well and the lower reading stands. Planning costs 0.6 ms of 10.5 on the dense 2-key shape at 10 M rows, from 1.8. The gate keeps its rules — dense (K, X) → rewrite; up to 256 K groups otherwise → slice dedup; past 1 M groups → rewrite — and between 256 K and 1 M groups asks the tuple estimate: the rewrite when its (K, X) pass dedups, the slice dedup when the tuples are nearly the rows. A sorted 750 K × 20 composite now takes the slice dedup at 138 ms / 849 MB against the rewrite's 175 / 1596 (15 M rows, 28 workers); the other measured shapes route as before. test_agg_contract.c's card_estimate pins the Chao1 correction, the span and domain caps, the tuple against the product, and the blocks on a sorted prefix; each fails with its hunk reverted. * fix(agg): the estimator samples each window at a hashed offset, caps SYM at its codes' span The sampled row of window i was i * stride plus the high bits of i times the golden ratio modulo the stride, an offset nearly linear in i: the sampled rows stayed an arithmetic progression, and a key whose period is near the stride was read at a nearly fixed phase. At 10 M rows the (k1, k2, x) tuples of (% i 1000), (% i 1009) and (% i 50) estimated at ten million (1,009,000 true; no tuple seen twice) and the groups of (% i 1000), (% i 1013) at 106,015 (1,013,000 true; too many seen twice), so the gate sent the usual periodic test data to the slice dedup at twice the rewrite's time. The offset is now the splitmix64 finaliser of i: the collisions are a random sample's, and both land within an eighth of the count. A SYM key's cap was the process-wide domain's count, which grows with every symbol interned and moved the estimate with it. It is now the span of the sampled codes, as for an integer key: fixed once the column's symbols are interned, and never above the domain's count. card_estimate pins the tuple cap (2,002 tuples against a Chao1 of 500,502), a key whose period equals the stride (32; 1 at a fixed offset), the two periodic composites at 10 M rows, the permutation (i x 7919) mod N, and the SYM estimate before and after another symbol is interned. Each fails with its hunk reverted. Refs #756, #760. * test(query): the distinct-count route gate under test cd_two_pass_preferred moves from query.c into cdfuse.c as ray_cd_two_pass_preferred, declared in cdfuse.h beside the fused kernel it routes around, so the contract tests can call it. agg_contract/ cd_route turns each rule on by itself: the dense check (45 x 100 takes the rewrite, 45 x a million the slices), the 256 K floor (100 K groups whose tuples would dedup to a fifth take the slices), the band (524,288 groups of 19 rows: tuples a twentieth of the rows take the rewrite, every row distinct the slices; both again over a sorted key read through the blocks; the periodic 1000 x 1009 composite takes the rewrite), the million-group rule, and the two declines (an F64 key takes the rewrite, an F64 value the slices). Removing the dense check, the floor or the million-group rule, or pinning the band either way, fails the test. Refs #756, #760. * fix(query): the distinct-count gate sees the rows a where: keeps and the largest group On the published 100 M-row dataset, a distinct count of users by phone model behind `(!= MobilePhoneModel "")` took the slices at 2x the rewrite's time, and the two-key form the same. The gate estimated over the whole table, where the empty model is the dominant value; the filter keeps 5.56 M rows, and of those one model holds 5.1 M. The slice dedup (cdpg_buf_par_fn) runs one group per task, so that group ran on one worker while the rewrite's (K, X) pass spread every row. The gate now samples the predicate: 262,144 table rows at the estimator's hashed stride, the columns the predicate names gathered from them (typed loads with reads in flight; a byte-wise copy took 25 ms) into a table of that size, the predicate run over it as the select runs it, and the survivors handed to the estimator with the population they stand for (query_cd_gate_where_rows; 2.4-2.9 ms at 100 M rows). Running the predicate in full cost 18 ms of a 90 ms select whenever the slices were then chosen and evaluated it again. A predicate naming a text, nested or parted column, or keeping every sampled row, leaves the gate on the whole table. The estimator takes the row list and its population, keeps a count per sampled value and reports the largest group's rows; a group holding more than CD_SCATTER_SKEW (8) workers' shares of the rows takes the rewrite. Synthetic 100 M rows with 12 M surviving a filter, 28 workers: a value on half the rows took 180 ms on the slices against 136 on the rewrite, one on 8% of them 107 against 138, equal groups 89 against 140. The real queries: 51-52 ms from 99-108 (dev 48) and 58 from 101-103 (dev 55). card_estimate pins the largest group's rows, a selection whose dominant value is filtered, and a list standing for a population; cd_route the dominant group on 28 and on 2 workers, the filtered key over the table and over the survivors, and the sampled predicate itself. Removing the skew rule, ignoring the rows, the count or the population, or disabling the predicate sample fails them. Refs #756, #760. * perf(query): confirm a sampled group count in full before the slices A distinct count of users by search phrase behind `(!= SearchPhrase "")` took the slices at 2x the rewrite's time: 6.02 M phrases over the 13.2 M surviving rows, most on one row and a few on thousands. The Chao1 takes the phrases seen twice for the whole tail's repeats and lands in the low hundreds of thousands, under the 256 K floor. Before the slices are chosen on a count above the sample's size — any larger count is the Chao1's correction, not the sample's evidence — the key columns are counted approximately in full (ray_count_distinct_approx, parallel, 12 ms at 100 M rows): a column past a million groups takes the rewrite. Over the whole table, an upper bound that errs towards the rewrite. The real query: 150-152 ms from 314-318 (dev 143-148). cd_route pins a heavy-tailed key the Chao1 reads as ~100 K of 5 M values; the test fails with the confirmation removed. Refs #756, #760. * fix(query): release the gate's sampled table, gather SYM at its width The filter over the gate's sampled table returns that table retained, as the rewrite's own where: pre-pass returns the source table, and neither reference was released: every gated where: select kept its 262,144-row sample, a few MB per query on a long-running server. Both are released now. A SYM predicate column was gathered into a 64-bit vector by its narrower source width (a mapped table's columns are 8, 16 or 32-bit; an in-memory one 64), so the predicate read packed codes and garbage, kept every sampled row, and the gate fell back to the whole-table estimate. The gather allocates at the source's width. cd_route runs eight gate samples under the memory trace and holds nothing afterwards (33.5 MB with the release reverted), and writes the key at 32 bits over the same domain and checks the survivors against the key's integer twin (no survivors list with the width reverted). Refs #756, #760. * perf(store): request many short page runs over the pool ray_col_want_rows lists the runs of pages its rows touch and, from 256 of them, asks for them over the pool's threads instead of one request at a time on the calling thread: a sparse sample of a large mapped column marks one page per row, and each request costs a few microseconds of the kernel's — 14,000 to 34,000 of them on the distinct-count queries of the published 100 M-row dataset. Few or long runs still go through ray_pool_want, which splits a long range itself. * perf(query): the distinct-count gate asks for its pages before it reads them Cold, the gate cost more than the select: the predicate sample read 262,144 rows a stride apart — every page of a 400 MB mapped column, one page fault at a time on the main thread, 0.8–3.4 s — and the estimator then read the key and value columns at the survivors, a fault each. The select's own scan asks for its pages ahead through the pool, and the other samplers do the same before their scattered reads; the round 3–4 estimator, the predicate sample and the full count did not. - The predicate sample lists its rows and asks for their pages first (ray_col_want_rows: at this stride the column whole, in parallel), and gathers the rows over the pool's threads, one task per range. - The estimator lists the rows it reads — the strided sample and the sorted-prefix blocks — and asks for them: the sampled rows' pages, or the column whole when the rows the estimate describes fall on every page of it (a row per page or more: the select reads it whole then, and the stream runs while the pass waits on its first pages). - The full count asks for the key column whole before the parallel pass. - ray_vm_willneed_bytes counts the bytes asked for at the one place requests are made, for the pool's trace summary and the test. On the published 100 M-row dataset at 28 workers, first run after the page cache is evicted: the phone-model query 1240 → 297 ms (dev 272), the two-key one 1496 → 352 (317), the search phrase 1338 → 452 (378). Warm the gate is 2 ms on the first two and 10 on the third, 6.7 of them the full count. Test cd_gate_read_ahead evicts a splayed table it writes and checks, for the predicate sample, the estimate and the gate over a sparse selection, that each asks for the bytes it reads and takes at most a handful of major faults; each of four reverts fails on the bytes asked for. * fix(query): release the filter result in the count-distinct rewrite A lone (count (distinct x)) under a where: and a by: takes try_count_distinct_v2_rewrite. Its where: branch ran the filter node and kept the result, the input table with a reference of its own, releasing it only on error. Each such query left one extra reference on its table, so the table was never freed and the next insert into it copied every column. Release it once the filter has run. The constant node keeps the graph's reference until ray_graph_free, and the group reads the rows from g_in->selection. Fixes #757 * perf(query): the gate asks for the span its sample covers, from the chunks the zone gate keeps The estimator asked for the key and value columns whole whenever the selection averaged a row per page, without looking at where the sampled rows were: a 1% window of a time-ordered table pulled 92 MB where 2 was needed. It now takes the first and last sampled row and asks for that span as one range when the selection keeps a row per page of it or more, and the sampled rows' pages otherwise. The predicate sample read every page of the filter column. It is drawn now from the chunks the select's own zone gate lets through (scan_gate_where), so a slice of a sorted column samples its one or two chunks — every row of them, the survivors and the population exact — and the key and value columns follow through the span. A predicate the gate says nothing about samples the whole table as before. ray_col_want_rows splits its page runs over ray_pool_dispatch_n, a task per 256 runs: the plain dispatch cut at 8,192 elements, so thousands of runs stayed on one or a few tasks. agg_est_sample_row took a whole-row stride, nrows / sample, and so read sample * stride rows of a selection, the last fifth of 41,943 rows under a sample of 32,768 unread; its windows are fractional now. Test cd_gate_read_ahead gains the sorted-column window and the contiguous-slice estimate; the sparse step moves to a 272 MB column. Seven reverts each fail on the bytes asked for. --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> Co-authored-by: Ihor Filimonov <254675014+ihrfv@users.noreply.github.com>
* fix(store): a replaced splayed table keeps one copy of itself (#713)
A table replaced twice or more held about three copies on disk: the first
write's root column files, never read again after the first publish, and
the previous generation, kept for a reader that resolved .current before
the swap.
- The first publish removes the columns the retired root schema names,
with their .link sidecars, then .legacy.d. Only those names go: the
root also holds the symfile, the lock, and for a table written into a
database root, the partitions. A cleanup cut short is finished by the
next publish, which also cleans stores written by earlier versions.
- Publication keeps only the selected generation. Instead of the writer
keeping the previous one, a reader whose generation is removed before it
opened every file resolves again and reads the selected one
(splay_load_impl, ray_read_splayed_dom, and ray_splay_has_schema for the
partition checks, where a false "no table" would let .db.parted.fill
write an empty table over a real one). Mapped files stay readable.
No setting: the previous generation protected nothing the retry does not.
* fix(query): exclude null symbols from not-in (#739)
* perf(index): store hash slot table and group offsets as 32-bit
A hash index over a column below 2^32 rows keeps its slot table (group
id + 1) and its CSR group offsets as unsigned 32-bit values instead of
64-bit. Both are bounded by the row count, so nothing is lost; the two
arrays take half the memory and half the bytes on disk. Readers go
through one width-aware accessor; the append carry widens the source
arrays and re-narrows the result.
An index with a narrow array is persisted as layout generation 2. The
mapper accepts generation 1 (all 64-bit, as written so far) and
generation 2, and rejects a generation-1 region that carries a narrow
array, so the column loads unindexed rather than misreading it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(heap): count off-heap allocations toward the spill watermark (#712)
The anonymous watermark that routes new memory to spill files saw only
the heap's pools and direct blocks. Everything allocated with
ray_sys_alloc stayed outside it: the string arenas of the symbol table
and of the column domains, their hash tables, and index-build scratch.
During a load of a wide table with many distinct strings that is most of
the footprint. Sampled in a container limited to 15 GB with -m 9G, the
watermark counter stayed at 1.9 GB while sys allocations grew to
15 GB, and the process was OOM-killed.
ray_sys_alloc now goes through ray_heap_sys_map: every block counts
toward the watermark, and a block of 1 MiB or more that would cross it
is mapped over an unlinked spill file instead, like a direct block. The
file's descriptor is closed at once, so spilled blocks hold no fds.
Past the watermark, a pool or direct block that could not get a spill
file (no writable spill directory, disk full) failed the allocation.
With more counted and a lower default that point came much earlier; such
a block, like a sys block, now comes from RAM instead.
The default watermark was the whole RAM the process may use (physical
RAM, or a cgroup limit when that is smaller), so spilling began only once
nothing was left. The page cache needs room from the same memory: the
mapped inputs, the dirty pages of the files being written and those of
the spill files, all charged to the limit under a cgroup. The default now
stops a quarter below it, at least 3 GiB and at most half. The budgets a
query sizes its strategies by (the direct-block cache, the dense and mode
grouping gates, the fused cumulative-distribution gate) keep the whole
limit: ray_heap_mem_budget is the -m value when one is set, else the RAM
the process may use. Sized by the lowered watermark they picked slower
plans on a 32 GB machine.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* perf(index): store hash index row ids as 32-bit
The CSR row-id array of a hash index (one entry per indexed row, the
largest of its arrays) is stored as unsigned 32-bit values below 2^32
rows, like the slot table and offsets. Over a column of unique keys an
index now takes 16 bytes per row (row id, offset, key) plus 4 per slot,
instead of 24 plus 8.
Row slices leave the index as ray_idx_rows_t, a pointer with its width,
read through ray_idx_rows_at:
- ray_index_hash_group and ray_idx_slice_t carry it; the rowsel builder
is specialised per width, and ray_index_rowsel_from_rows builds a
selection straight from an index slice.
- The filter-plus-group kernel widens a narrow task chunk once into a
per-worker buffer, so its accumulation loops are unchanged.
- The as-of join verify takes the slices directly instead of packing
pointers into an int64 array.
Indexes mapped from generation-1 files keep their 64-bit rows and are
read through the same accessors.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(arena): grow chunks so a large arena can spill (#712)
An arena allocated every chunk at its initial size: 64 KiB for a column
domain's string atoms. A domain with millions of distinct strings was
then hundreds of thousands of small ray_sys_alloc blocks, and blocks
below 1 MiB are never mapped over a spill file, so the atoms of a large
load stayed in RAM past the watermark. In a 15 GB container with -m 9G
the counted footprint reached 16.5 GB and the load stalled at the
cgroup limit.
Chunks now double from the initial size up to 16 MiB. A large arena is
a few big chunks that spill past the watermark; a small one keeps its
small first chunk.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(index): keep the wide arrays when narrowing is interrupted
Narrowing a just-built hash index's arrays runs as a pool dispatch, and
an interrupt (Ctrl-C, a client's cancel) makes a dispatch skip its
tasks. Landing after the build's last interrupt check, it left the
32-bit copies unfilled and installed them; a load could then save that
index, and lookups returned wrong rows. The narrowing now keeps the
64-bit array it was copying when the dispatch was cancelled.
The append carry built 64-bit arrays and narrowed them again on every
append; it now builds them in the width they end in and copies a narrow
source as it is.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(heap): hand memory back between statements when it is short
A free block keeps its pages and a standard pool stays mapped after a
statement, so a repeated query reuses them without faulting; a server
gave them back only after ten idle seconds. Where memory is short this
turned against the next query. What the previous one left resident
starved the page cache, and a kept pool still counts toward the spill
watermark, so the next query's new memory went to spill files. In a
container limited to 4 GB a heavy grouping ran in 22 s on a fresh server
and in 430 to 870 s on the second and third runs.
At a statement boundary of a server request or the REPL, once the
anonymous footprint is past the watermark (the next query's memory
would spill, and the room the watermark leaves the page cache is being
eaten), empty pools of every heap, standard ones included, are now
unmapped (their bytes leave the watermark count) and the pages of the
remaining free blocks released. The repeated runs above take as long as
the first. Below the watermark nothing changes: the kept memory is what
a repeated query reuses.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* perf(index): build hash indexes partition by partition within the memory budget
The hash index build kept every row's group id, key word, partition slot
and the output arrays live at once, about 58 bytes per row at random, so a
100M-row column needed ~6 GB of private memory and on a small machine paged
it through the spill file at random.
Numeric and SYM columns are now indexed by a partitioned builder. Key
words and row ids are bucketed by the top bits of the key's mixed hash;
partitions are deduped in batches whose working set fits a quarter of the
heap watermark; their groups, row offsets and rows are written in order.
Groups are numbered in ascending order of the mixed hash and a key's home
slot is the top bits of that hash, so the slot table is written in one
forward sweep and every array follows from the data alone, whatever the
partition count, budget or core count. Only a batch is accessed at
random; the bucketed input and the outputs are streamed, and past the
watermark their heap blocks are file-backed. An interrupt anywhere in
the build leaves nothing allocated and the column unindexed.
The new tables are marked RAY_MARK_HASH_HIGH and persisted as layout
generation 3. Readers take the home slot from the table's own rule, so
generation 1 and 2 tables keep loading; older binaries load a generation-3
column unindexed. STR columns keep the serial walk.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(heap): relieve only when it brings the footprint back under the watermark
The statement-boundary relief scanned every freelist once per pool (its
cost grew with the square of the heap: hundreds of milliseconds per
statement on a fragmented heap of a few hundred MB), ran at every boundary
while live data held the footprint past the watermark (unmapping pools,
draining the direct cache and releasing pages that the next statement
mapped and faulted straight back), walked the heaps of threads that may
still be allocating, and ignored empty spill pools.
Empty pools are now found per heap with a cheap filter (a free block of
pool order or above must exist; then one header read per cascade offset)
and confirmed by one walk of the freelists that sums free bytes per
candidate. Relief acts only when the next pool would not fit under the
watermark and handing back the empty pools and the direct cache brings it
under again; otherwise the memory stays for reuse. When it acts it also
unmaps empty spill pools, whose page cache counts against a container's
limit. Relief, the idle decay and gc's pool pass visit only the caller's
heap and the parked pool workers' heaps. Without a file spill (Windows)
relief does nothing and memory is left to the idle decay. The REPL's ^C
path, a script run with a file argument, timer callbacks and .sys.gc now
end at the same boundary.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* perf(scan): read ahead the columns a pass reads on cold storage (#747)
* fix(store): a reader keeps up with a table that is being rewritten (#748)
* fix(store): a reader keeps up with a table that is being rewritten
Only the selected generation of a splayed table is kept, so a reader can
see the generation it resolved removed before it has opened all its
files. It then resolves again and retries, but gave up after eight
attempts. A reader slower to open its files than the writer is to
publish (many columns, slow storage, a busy CPU) failed with an I/O
error after eight saves, although every failure was only the table
moving on. splay/generation_reader_races hit it: under a loaded CPU it
failed 29 times in 30.
Each retry follows a completed publish, so the reader is no longer
counted out: it retries while the table keeps resolving to a different
generation, at least eight times and for up to 60 seconds, and then
reports that the table kept changing rather than the error of its last
attempt. An interrupt stops the retries with a cancel error. The
symbol domain of the previous attempt stays open across a retry, so a
large symfile is extended rather than read again on every attempt; read
again each time, it could take longer than the writer's interval and
lock the reader out. Under the CPU load above the race test passes 30
times in 30.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(store): a retry that finds no symfile lets go of the domain it held
A retry that found the symbol file missing or unreadable left the
domain an earlier attempt held in place and then overwrote the only
reference to it, so the domain and its mapping stayed cached for the
life of the process (a later save to that symfile path then failed).
Each attempt now releases the domain held from the one before it,
after reopening the symfile when there is one.
New test splay/generation_retry_releases_domain drives a read through a
FIFO .current served by a child process: the first attempt fails after
opening the domain, the symfile is removed before the retry, and the
domain must not stay cached.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* perf(parquet): import column by column when memory is short, through an import symbol dictionary (#753)
* perf(parquet): import a splayed table column by column when memory is short
The direct import ran one task per column chunk with row groups outermost,
so every worker wrote all of the table's column files at once and kept
every column's state live together, and the hash indexes waited for the
end and re-read their columns. On a machine whose memory cannot hold the
output that is a scattered write pattern, and the re-read goes to disk.
The import now runs in passes. When the process may use less than twice
the output's bytes it goes column by column (a pass is one column, or a
few when the file has fewer row groups than the pool has workers):
numeric and temporal columns first, each closed as soon as its pass ends
and its hash index built right away while the file is still in the page
cache; symbol columns after, smallest first, one by one below half the
output's bytes of memory and together above. With more memory one pass
takes every column, as before. RAY_PQ_PASS_COLS forces the columns per
pass.
Read-ahead follows the passes: a task requests its own chunk and the same
column's chunks of the next row groups (RAY_PQ_PREFETCH), up to a share of
the memory budget. In one pass each written chunk's writeback starts as
it is written (ray_file_writeback_start), so the final sync finds little.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* perf(sym): build the import's symbol file as the strings arrive
The import interned its strings into a file domain that keeps every one as
an arena atom (about a hundred bytes each with its index), behind one lock,
and wrote the symbol file only at the end: a rewrite of the whole
vocabulary through a temporary file, then a sync. On large
vocabularies that is several gigabytes of anonymous memory, which a small
machine pages in and out, and tens of seconds of writing after the last
column.
A converter that builds its symbol file alone now uses an import domain
(ray_sym_domain_create_import, table/symimp.c): interned strings are
appended straight into the mapped symbol file as its records; in memory
there are only the records' offsets and a hash index in 1024 shards. A
hit is found without a lock, and a batch's new strings are added together
under one, so they get neighbouring positions. Position 0 stays "".
While the passes run, a background thread appends what is new to the
symbol file every quarter second and starts its writeback
(ray_sym_domain_flush_append writes the tail in place, and is what the
import domain's flush does), so the final flush only syncs. The direct
Parquet import uses the import domain; the spooled path keeps the
ordinary one.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(parquet): keep the one-pass import as it was; harden the import dictionary
One pass over every column (the layout with memory to spare) closed and
hash-indexed each numeric column on the main thread as the pass ended,
one after another, instead of finishing them on the pool and building
their indexes in one parallel wave after the symbol domain is released:
a table of wide numeric columns took twice as long. That pass now leaves
the columns to the common finish, as the row-group import does; only the
column-by-column passes close and index each column as they go. A file
with fewer row groups than the pool has workers, imported column by
column, put its symbol columns in the numeric pass; they get passes of
their own. RAY_PQ_PASS_COLS sets the symbol passes too. Output sizes
from the file's metadata are summed without overflow.
The symbol file appender slept a quarter second between flushes and the
import joined it at the end, so every import with a symbol column took a
quarter second longer: it now sleeps in slices that see the stop. A
failed flush stops the import at the next pass instead of after the
last.
When the import dictionary could not be set up after creating its file,
the empty or zero-filled file stayed and the fallback to the ordinary
domain rejected it ("oom"): the file is removed first. The file grows by
fallocate where the filesystem supports it, so a full disk fails the
intern instead of faulting a write through the mapping, and from 1 MiB
instead of 64; growth stops at the mapping's reserve rather than failing
short of it. The file is created 0666 under the umask, as the ordinary
domain's is.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* perf(sym): free the import dictionary's replaced tables; look up a batch's repeats once
A shard table that grew was kept until the end of the import, since a
lookup without the lock might still be reading it, so the dictionary held
its tables about twice over. On a machine with little memory that left
the page cache too small for the symbol file's records, which every
lookup reads to confirm a match, and the import of a large vocabulary
became a stream of random reads.
Lookups now register in the parity of an epoch for the span of their
lock-free probing; a replaced table is retired in the current epoch and
freed, under the lock, once no lookup of an older epoch is left. Equal
strings of a batch are looked up once, compared with the batch's own
bytes, and the symbol file's mapping is marked random so a lookup that
misses the page cache reads one page rather than a read-ahead window.
Peak anonymous memory of a 2.5M-string import: 200 MB to 120 MB.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* test(sym): the import dictionary gives back the tables it replaced
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* perf(parquet): finish the one-pass import's columns while the symbol file is still written
With memory to spare the import runs one pass over every column, then
stopped the symbol file appender and synced the file before closing and
indexing the columns. On a disk busy writing back the column data the
appender's last writeback request waits behind it, and the import sat
idle for that wait, then for the symbol file's sync, before any column
work began.
The one-pass import now finishes its columns (on the pool) and builds
their hash indexes in waves before it stops the appender, so that wait
overlaps the work; a symbol column's header takes the domain's final
count, which no longer changes once the pass is done. A column whose
hash index ray_col_stream_hash_all built no longer asks for one, so the
common finish after it has nothing left to do.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(sym): random advice only on the written part of the import's symbol file
Marking the whole mapping random made every new page of the file being
appended a separate major fault, taken under the lock that serialises
the appends (a few percent slower with memory to spare). The written
part is marked random at each background sync instead, and the tail
keeps the default. Replaced tables are freed after the lock is dropped
rather than under it. A batch's dedupe table is cleared for the batch's
size, not for the largest batch. The reclaim test counts table sizes in
the 4 KiB units ray_sys_get_stat uses whatever the page size, so it holds
on 16 KiB pages.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(parquet): a chunk whose final flush fails fails the import
In the one-pass layout a chunk's stream is flushed before its writeback
is started, and a failed flush only skipped the writeback: the stream
had dropped the bytes it still buffered, the close after it succeeded,
and the import published a table with those rows zeroed. A failed flush
is now the task's error, so nothing is published, and the writeback
starts only after a flush that succeeded.
New test parquet/chunk_flush_error (Linux): a file size limit inside the
buffered tail of the first chunk makes that flush fail; the import must
report it and publish nothing.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* perf(query): isolate sparse group-count worker slices (#741)
* perf(query): isolate sparse group counters by cache line
* test(query): cover sparse count stride through reachable queries
* fix(query): compare ordered symbols by lexical value (#743)
* fix(query): compare ordered symbols by lexical value (#738)
* perf(query): bound symbol ordering setup to scanned ids
* perf(query): compose safe nested selects before planning (#754)
* perf(query): compose safe nested selects before planning
* fix(query): bound nested select planning and preserve overflow semantics
* test(query): cover empty grouped nested selects and large script rewrites
* test(query): verify scoped sources shadow global tables
* fix(query): preserve materialized product and filtered reduction semantics
* fix(ci): start select merge smoke fuzzing from seeds
* fix(query): align membership, concat, and grouped fallback semantics (#744)
* fix(query): align collection membership across evaluation paths
* fix(query): address membership and concat review regressions
* fix(query): preserve concat aggregate slots and scalar broadcasting
* fix(query): close filtered count and concat fallback gaps
* fix(query): preserve per-row enlist constructors
* fix(query): preserve collection concat across query scopes
* test(pool): drain pending reads before cache eviction
* test(query): account for unmerged parallel aggregate variability
* fix(group): fold keyless reduction partials in chunk order
Scalar reductions and the keyless select scan kept one accumulator per
worker while the pool hands out tasks dynamically, so F64 sum, avg,
var and prod changed from run to run. Partials are now kept per chunk,
with chunk boundaries derived from the row count only, and folded in
chunk order, which gives the same bits at every core count.
* fix(query): preserve concat operands through helper fallbacks
* fix(query): preserve materialized columns for nested sort
* test(pool): batch page reads while settling mappings
* test(query): restore bounded aggregate comparison after reduction fix
* fix(query): preserve helper globals during fallback expansion
* fix(query): preserve helper scope across fallback aliases
* fix(query): preserve fallback text and grouped output layout
* fix(query): retain column provenance in opaque helpers
* test(pool): require worker participation before asserting reclaim
* fix(query): align helper literals, concat operands and grouped layout across paths
The collection-concat detector recursed without end on an opaque helper
around row text; it now inspects the unexpanded call's arguments. A
grouped output named like its key kept only the key; the layout pass
treats the last column of a repeated name as the output. Grouped
fallback outputs follow the requested order, as planned outputs do.
A concat operand the planner has no node for (a reordered, indexed or
cast column) is a whole column in projections, so select agrees with
update, where and by; a STR column's cell keeps its type row by row.
Helper purity covers every non-restricted, non-special-form builtin
outside system namespaces. A literal symbol is the symbol inside a named
helper's body on every path, while inline lambdas keep the query's
column rule; a named helper's free names never see its caller's formals
or a derived alias. Aggregate classification skips the trial compile for
bare columns and concat-free column expressions.
* fix(eval): run query helpers as bytecode with row provenance on the VM stack
A helper called from a query fallback ran on the tree walker so concat
could tell column parameters from bound collections. The tree walker
ignores return, binds lazy arguments it cannot read twice, stops near 62
scopes and is about three times slower on recursion.
Helpers run as bytecode again. Each VM stack slot carries a row flag:
parameter loads take it from the call, let stores and loads copy it,
builtins the planner lowers row by row keep it, collection verbs clear
it, nested compiled calls pass it on and return it with their result,
and concat consults it. A call frame marks the lexical boundary: the
query's aliases and locals are hidden, a free name that is a source
column binds on first use to the row's cell or the whole column, and
anything else is a global. The tree walker stays for closures and for
lambdas written inline in a query, whose literals follow the column
rule; it now forces lazy parameters at binding. An opaque helper under
a grouped aggregate evaluates per group. call_lambda is the direct
bytecode path again outside queries.
* fix(eval): key provenance caches on the symbol epoch and carry opaque helper results
The row-wise builtin table, the helper purity tables and the per-object
verdict cache are rebuilt when the symbol-table epoch changes, so a
runtime re-init cannot leave a stale id or address behind. The compiled
call path computes a result flag only for builtins, a scoped window
clears the flag of a local it rebound, and the call-frame binding comment
states that only an allocation failure leaves a name unbound.
At the query level an opaque helper given row data yields row data, so a
concat over its result keeps text semantics. A source column shadowing a
global helper of the same name is documented and tested.
* refactor(eval): drop the dead inline-lambda bytecode gate in query calls
A lambda written inline in a query captures the query's bindings and is
therefore a closure, which is never compiled; the tree walker handles it
with the query's literal rule regardless. Pin that rule with a lambda the
planner does not substitute.
* fix(eval): keep a helper's free source column on the query's rows
A source column bound on demand into a helper's call frame carried its
row flag only on the first read; the frame now records where those
bindings start and every lookup of one sets the flag. The binding itself
is the query's own: the row's cell, the group's slice or the whole
column, taken from the frames below the call frame, or sliced by the
group's rows when the per-group fallback did not bind that column for its
own expression. While a projection binds output aliases, the source table
decides, as before.
* feat(query): group on a helper the planner cannot lower
A by: key that is an opaque helper is evaluated once over the table,
where its column arguments keep their rows, and grouped as a hidden
column named after the expression, as other computed keys are.
* fix(eval): carry row provenance through the tree walker
Every evaluation publishes whether its value derives from the active
query's rows: a query frame's column binding, a helper's source column,
a row-wise builtin over such a value, or a compiled helper's result. The
concat hook and the helper call site read that flag instead of
re-deriving it from the syntax, which also removes a DAG compile per
row from the call site; the syntactic analysis remains for the result
of an interpreted closure.
Inside a function, concat joins text only when an operand carries row
provenance; otherwise it keeps collection semantics, whatever called
it. Helper purity is an allowlist again, and a name standing for a user
function is never pure. A helper's free source column is the query's
binding for it, never a caller's local, and a nested query inside a
helper restores the enclosing group's rows. Reordering and indexing
keep row provenance in the VM; a bound vector returned by a helper does
not. Every grouped result keeps the requested output order, and a
purity lookup no longer binds a column into a helper's call frame as a
side effect, which the planner then mistook for a lexical local.
* perf(string): build a two-cell text concat as an atom
Row-by-row text concat of two cells produced a one-row vector and
unpacked it; it now builds the SYM or STR atom directly.
* fix(eval): keep row provenance on every binding, through forms, and in by keys
Scope frames carry a row-provenance flag per binding, so a let local in
an interpreted lambda, a parameter, a scoped window's locals and a
helper's source column all report their own provenance; the separate
call-scope bookkeeping is gone, and a call frame records whether it was
made by name for the literal rule. Flags are tri-state in the VM and the
tree walker: a try handler's value or an interpreted closure's result is
unknown and falls back to the syntactic analysis, so a bound constant is
not row data and a row value through a handler still is.
Only control forms pass their operand's provenance through; a nested
select, update or join builds a new value. A null text atom follows the
kernel's null rule row by row. An opaque helper used as the by: key is
evaluated over the rows where: keeps, row by row when its body holds a
condition over one value, grouped under the planned key's name, and
with no outputs the key replaces the source column it is named after.
* fix(group): pad keyless chunk accumulators to 128-byte line pairs
Blocks of chunks scanned at the same time sat on neighbouring 64-byte
lines, which the adjacent-line prefetcher moves as a pair, so they still
bounced between cores on every row of the generic kernel. Five
aggregates over 70K rows on a two-socket box took 5.7 ms against 0.58 ms
before the chunked fold.
* fix(eval): scope provenance to the innermost query and carry it through closures
A lookup that passes a query frame reports the binding below as plain
data, so a nested query inside a helper no longer takes the caller's
row value as its own row data. A closure's captures keep their flags,
and a try handler's own evaluation reports its value's provenance, so a
row value through a handler used inside the helper stays row data. set
passes its value's provenance through; a lambda called inside a named
helper's body is that body's text, so its quoted symbols stay symbols.
A null cell is the empty text the vector kernel reads, while a null
literal still nulls the row. An opaque by: key runs row by row only for
a condition over one value, looking helpers up lexically before
globally; a let, a sequence or a loop keeps the key whole.
* fix(env): size a closure's capture for its provenance entry
The key vector of a captured scope was sized for the bindings alone,
and the provenance entry appended after them wrote one slot past it.
Regressions create closures inside a query with several live row
bindings, directly in a per-row expression and inside an inline lambda.
* fix(env): resolve a closure's capture provenance at call time
A closure stores no row provenance: it is relative to a query. When the
closure is called while the frames it captured from are live, each
capture takes the provenance its binding has at that moment, relative
to the innermost query, and a compiled frame publishes its slots for a
closure whose scoped window has already closed. An escaped closure's
captures are plain data. The stored entry, its spellable key, its
serialization and the intern per closure call are gone.
The opaque by: key gate leaves and/or to elementwise evaluation, treats
an if over an aggregate condition as one value for the column, and
resolves names inside a global helper's body globally. A null STR
literal follows the kernel's null rule on both paths.
* fix(env): keep a closure's capture flags in the query frame it was made under
The flags are recorded in the innermost query frame when the closure is
made, keyed by the capture object, and consulted only while that frame
is the innermost query; they are freed with the frame. Nothing about
provenance is stored in the closure, matched by object identity, or
limited in count, and the compiled frame publishes nothing.
An opaque by: key is evaluated over the whole column and again row by
row only if a conditional tested a vector derived from the rows during
that run. Two STR literals join as strings on both paths. A try fallback
value keeps the provenance its expression had, in both interpreters. A
closure is never compiled when called through another function, so it
keeps its environment.
* feat(eval): compile closures, loading captures as flagged constants
A closure compiles like any lambda; its captures are loaded by OP_LOADCAP
as fixed values. The capture row flags live on the lambda in two new
slots that are never serialized, together with the serial of the query
frame the closure was made under, and apply only while a frame with that
serial is the innermost query. No record is keyed by address, nothing is
kept per closure in a frame, lookups are constant time, and a recursive
closure called from a compiled helper keeps bytecode's depth. A lambda
made inside a query is query text and stays on the tree walker, which
keeps the query's literal rule; the tree walker reports its result's
provenance like the VM does, so a try handler of either kind agrees.
An opaque by: key is evaluated again row by row when any conditional
tested a vector during the whole-column run, or when that run failed.
* perf(eval): clear only the VM header per compiled call
Every stack, return and trap slot is written before it is read and the
locals are set explicitly, so zeroing the whole exec block per call was
wasted work that outweighed a small body.
* fix(eval): compile a closure only when its captures have the expected shape
A closure compiles only with SYM keys and a LIST of values of equal
length; anything else stays on the tree walker, whose capture binding
reports the type error. The deserializer rejects a lambda whose closure
is not such a dict, and the compiled capture loads check the list type.
* test(serde): pin the closure shape check and size lambdas by their slot layout
The deserializer's rejection of a closure whose values are not a LIST now
has a test that fails when the check is reverted. Three tests built lambdas
with seven pointer slots while the heap walks every slot of the current
layout on retain and free; they and the shallow object-size report use
LAMBDA_SLOTS.
* fix(mem): count a closure's captures in both object-graph walkers
.mem.objsize stopped at a lambda's debug slot and the heap's retained
count skipped the capture-flag vector; both now walk the closure and
capture-flag slots. objsize.rfl pins that a closure outweighs the same
lambda without one.
* fix(eval): bind a closure's captures as locals of its compiled body
A compiled closure resolved names differently from the tree walker: a
let of a captured name allocated a new slot, a capture named like a
builtin resolved to the builtin, a captured self was the closure's
maker, a caller's local of the same name stood in for a capture inside
a window, and a closure with more than 255 captures read globals. The
compiler now binds every capture to a local slot in a prologue, a local
shadows special forms and builtins, self is never captured and loads
through OP_LOADSELF (also for a bare self in any compiled body), and a
closure past the slot limit stays on the tree walker.
A closure compiles on its second call, so one made and called once keeps
no bytecode; capture row flags are kept only for lambdas made inside a
query. A by: key body with an effect is evaluated row by row from the
start instead of whole first, so the effect runs once per row; a failed
whole-column run clears the raise payload and trace it leaves behind.
* test(eval): pin the capture-flag release, the self exclusion and the query-text lambda rule
The flag-release probe makes a lambda per row; the compiler no longer
skips a self capture itself, so the env exclusion is the one mechanism;
a lambda with nothing to capture written in query text keeps the column
rule for its quoted symbols.
* perf(eval): bind a compiled closure's captures with one opcode
OP_BINDCAPS stores every capture into its slot from one constant map
instead of a load and a store per capture, so a closure call pays one
dispatch for its prologue. The flag-release probe's lambda now captures
a row-flagged local, so a leaked flag vector shows.
* fix(eval): count a closure call once when deciding to compile
The VM's call opcode decided not to compile a closure on its first call,
then fell back to the tree-walk entry, which decided again and compiled.
The entry now takes the decision from its caller.
* fix(eval): return ends a lambda on the tree walker too
A closure compiles on its second call, so a return in its body gave the
identity on the first (interpreted) call and the early exit on the
compiled one. Inside a lambda the tree walker's return now parks the
value on the VM and raises a return marker that every form propagates;
the lambda's call boundary turns it back into the value, try lets it
through, and the VM's error path treats a marker from a tree-walked
special form as the frame's return. Outside any lambda return is still
its value.
* fix(query): never inline a closure into a query
The fallback inliner skipped a closure only once it had compiled, and a
closure now compiles on its second call, so a query inlined an uncompiled
closure's body and a captured name read a source column or a global of
that name; the planner's own inlining had the same hole. Neither inlines
a lambda with a closure. The window sync covers every slot of a frame,
and a swallowed whole-column key failure drops a pending return value.
* test(query): a closure as the by: key, pure and with an effect
* fix(agg): flag a grouped key column as nullable only when a null key was emitted
A fused where: can remove every null row of the key column, yet the
grouped result inherited the source column's may-have-nulls flag, while
the same query over the filtered table materialized first carried none,
so one result had two spellings on the wire (the nested select
differential fuzzer caught it). The key gather and the dense key emit
keep the flag only when a gathered key is null; symbol keys keep it as
before.
* feat(eval): compile a fn literal once as a template and share it across its closures
A closure's first call ran on the tree walker and later calls compiled,
so every engine difference showed up as call-count dependence, and each
closure compiled its own copy of the body. A fn literal inside a compiled
body is now compiled once, as a template, when the enclosing function
compiles; OP_MKCLOSURE makes each closure share that bytecode and capture
only the enclosing slots the body mentions, with their row flags.
OP_BINDCAPS binds captures by name, so a let that had not run leaves its
slot empty, and a read of an empty let slot (OP_LOADENVG) falls back to
the global, as the tree walker does. Closures no longer compile on their
second call; a lazy value is materialized when captured.
The by: key purity check follows a callee wherever it is bound (a global,
a parameter or let of an enclosing frame, a capture, a lambda literal
bound earlier in the body); a computed or unknown callee is an effect,
and builtins are pure unless they are system, I/O or a few that read
outside their arguments.
* perf(agg): settle a grouped key's null flag with a vectorised, pool-split scan
The per-key scan behind the flag fix was serial and showed on large
groupings. The scan is now a block-wise sentinel compare the compiler
vectorises, split across the pool for a large column, and shared by the
three emitters and by the legacy wide-key path, which seeded the flag
from the source column and could only ever set it; an F64 key, alone or
beside another key, now agrees with the materialized reference too.
* fix(eval): materialize a lazy slot when a compiled closure captures it
A lazy handle is single-use: materializing frees its graph in place, so a
closure dict holding the handle would fail the closure's second call.
Rayfall call sites materialize lazy arguments, but a caller of the C API
reaches OP_MKCLOSURE with one; lang/closure_lazy_capture_vm passes a real
lazy handle through call_lambda and calls the closure three times. The
Rayfall shape that claimed to cover this never captured a lazy and is
gone. A zero-parameter literal ([] parses as an empty I64 vector) now gets
a template too instead of a private compile per closure.
* fix(query): a text cell reduced from a column is a cell of the rows for concat
A grouped (concat (first s) 'zz) gave null for an empty symbol in a
grouped update and failed to produce one value per group in a grouped
select, while the ungrouped projection read the empty cell as empty text.
The planner's concat kernel takes a cell mask: an operand that reduces a
text column (first, last, min, max) is a cell, so a null there is empty
text and a STR cell keeps STR output. The grouped select's
arithmetic-of-aggregates wrappers run with the group result as the active
table and the hidden aggregate columns flagged as row-derived.
* fix(eval): an unbound capture, eval in closures, deep literals, lexical capture, parameters in the purity walk
A capture whose let had not run read as 0: capture slots are no longer
fixed, so an empty one falls back to the global or a name error like any
let slot. eval and resolve in a compiled body run inside a scope window
over its locals, so a closure's eval sees its captures. Template
compilation stops nesting at 32 levels and leaves deeper literals to the
dynamic path, which compiles one level per call. A tree-walked lambda
call marks its frames as the lexical boundary for capture, so a caller's
locals are not captured whether the maker compiles or not. The by: key
purity walk binds a callee's parameters to the call's argument text, so an
effect reached through a parameter counts and a pure helper passed as one
keeps the whole column.
* perf(eval): share one compiled template per literal for closures made by interpreted code
A closure made by the tree walker (the top level, a lambda that could not
compile, a literal inside a special form the compiler hands to the tree
walker) compiled its own copy of the body on its first call. ray_fn now
keeps a per-thread table of templates keyed by the literal's parameter
and body nodes (retained, so an address stays the entry's own) and its
capture names; a hit shares the bytecode, a miss compiles once. The table
is dropped with the runtime. Tests pin the sharing, two capture sets from
one literal, the block-boundary case of the key null scan and the
interpreted lazy capture.
* fix(eval): match a closure template on every body node
Two literals built for eval can share their parameter, first and last
nodes and differ in between; the template table compared only those, so
the second literal ran the first one's bytecode. An entry keeps every
body node (literals over eight forms are not cached) and a hit requires
all of them. A thread whose table outlived its runtime forgets its
entries instead of releasing objects of a destroyed heap, and ray_fn uses
the compiler's own closure shape gate.
* perf(eval): cheaper closure creation on the interpreted path
A template entry remembers the symbols its literal mentions, so a hit
costs a pass over the dict keys instead of a walk of the body and
allocates nothing; the capture walk gathers row flags in a local buffer
and builds the flag vector only when one is set. Closures made and never
called by an interpreted maker: 249 ms per 200k before, 202 after (dev
121); the fn literal inside or: 136 to 98 ms per 200k.
* fix(eval): closure bodies resolve free names when they run; eval's window wraps only the call
The eval/resolve window opened before the arguments were compiled, so a
return, a let or a call inside an argument ran in the window and a
return leaked the frame; it now opens right before the call and closes
right after it. A closure body resolves the builtins it names when it
runs instead of baking the one the template saw, and a top-level
literal's baked template is recompiled once a function-valued global was
rebound (ray_env_fn_generation). The VM's general call applies the same
elementwise map, concat rule and provenance join as the dedicated
opcodes. The symbol walk and the compiler stop at 1024 and 2048 levels; a
literal holding more than 65536 vector elements is not kept in the
template table; a body that did not compile is marked and never retried.
The by: key purity walk binds a passed lambda's parameters as unknown,
lets a nested literal see the enclosing body's parameters, and resolves
an argument's name where the call was written. A grouped update expands
its helper expression once instead of per group.
* fix(query): the grouped update's planner skip is a flag of the call, not a remembered expression
A remembered expression pointer could match a fresh expression at a
reused address and skip the planner for it, turning a schema error into
a name error. The flag now belongs to one grouped update. The OP_RET
window unwind was unreachable once eval's window wraps only the call and
is gone; the deep-literal test runs at the depth the sanitizer build's
recursive release survives.
* test(lang): pin the compiler's depth guard by result, within every platform's stack
The regression test for the depth guard built a 12000-level literal and
relied on the sanitizer build surviving its recursive release; the macOS
one does not. The body is now a chain of nested ifs: compiled it runs as
jumps and returns the parameter, on the tree walker it recurses and stops
at the eval depth limit. 600 and 1500 levels return 1, 2500 (past the 2048
guard, within the bytecode jump range) is a limit error; with the guard
removed it compiles and returns 1. The syntax page states the limit; the
control-flow page's anchor to the lambdas section now resolves.
* fix(query): concat cell flags cover every operand, and the planner takes 255
The text kernel read its per-operand cell flags from a 64-bit mask that was
written for the first 64 operands only, so operand 64 shifted by 64. The
flags are one byte per operand for all 255 positions, in the planned node
and the two-operand path alike. The planner folded a query concat into one
node up to 16 operands and left longer forms to the binary builtin, which
rejected them; it now takes the executor's 255, so one limit governs the
form. Tests run 65-operand concats: a cell at position 0 with a null
literal at 64 nulls the row, a cell at 64 is a cell (empty text, STR
output), a column joins every row.
---------
Co-authored-by: Serhii Savchuk <ser.vasilich@hotmail.com>
* fix(query): sort and take a no-output grouping on a computed key (#772)
* fix(query): sort and take a no-output grouping on a computed key
A grouped select with a computed by: key and no outputs falls back to
eval-level grouping and returned its first-of-group rows directly, so
asc:, desc: and take: were dropped and groups came back in first-seen
order. It now goes through apply_sort_take, as the plain-key branch
does.
* fix(query): gather a no-output computed-key grouping's columns by type
The no-output computed-key branch built its result columns cell by cell
through store_typed_elem, which has no STR or LIST case: STR cells were
left unset (empty, or strings left from an earlier result, and sorting
them aborted on an unset string pool) and a LIST column failed. The key
and source columns are now gathered at each group's first row with
ray_group_gather, which builds every column type, and a failed gather
returns its error.
* fix(query): keep a LIST-valued computed key and reject a misaligned one
A computed `by:` key with no outputs gathered its key column only when
the key was a typed vector, so a LIST-valued key lost both the key and
its source column, in the grouped and in the empty-result path.
A key whose length is neither the row count nor 1 was grouped as is,
and the gathered columns were then read at group rows past their end.
It now fails with `length`.
* fix(group): release the partial list when a LIST gather append fails
ray_group_gather's LIST arm assigned the result of ray_list_append back
to its output, so a failed append dropped the list it had built so far.
* fix(query): group a no-output select by a global's value, not as a column
A bare `by:` name that is not a column of the table, such as a global
vector, was still taken for a column on the no-output path, and the
missing column was dereferenced. Such a name now takes the computed-key
path, as the grouping before it already evaluated it.
* fix(query): release the where selection on the no-output grouping path
With `where:` and `by:`, the filter keeps an extra reference to its row
selection that is released only at the end of the select. The
no-output grouping returns earlier, so each call leaked the selection.
* fix(query): group a no-output select by a local that shadows a column
A `by:` name bound both as a local and as a column was grouped by the
local's value, but the no-output path then read the column's key slot,
which the grouping never filled, and returned garbage or crashed on a
LIST column. Such a key now takes the computed-key path, and a missing
key column is an error instead of a read of unset memory.
* fix(query): release a by-dict's symbol vector on the no-output paths
The vector of by-dict or quoted key names was released only at the end
of the select, so the no-output grouping and the eval-level grouping,
which return earlier, leaked it on every call.
* fix(query): release the where selection on every exit of a select
The filter's retained row selection was released only on the success
path at the end of the select, so every error return with `where:` and
`by:` leaked it. The select body now hands the selection to a thin
wrapper that releases it whichever way the body returns.
* fix(eval): free the error a `try` catches
Neither `try` implementation freed the caught error, and a rethrow left
its trace frames behind. Inside a function, the compiled path also
handed a runtime error object to the handler; it now passes 0, as the
top level does and the error guide documents.
* fix(query): group a no-output select by an atom as one group
An atom `by:` key, such as a global scalar, failed on the no-output
path with a type error. It is now broadcast to one group, as a
length-1 key is. A function used as a key now fails with a domain
error that says so, instead of a vector-type error.
* fix(query): keep every column of a no-output by-dict select with where
With no outputs, the where prefilter narrowed the table to the columns
the clauses read, so the result lost the other columns and their order.
The prefilter now projects only when there are outputs.
* fix(query): run a no-output grouped select over a parted table
Every no-output grouped select without `where:` failed on a parted
table with a vector-type error. It now flattens the table before the
paths split.
* fix(query): release the by-dict symbol vector on every exit of a select
The by-dict and quoted key name vector joins the where selection in the
select wrapper, so the error returns that skipped its release no longer
leak it, and the per-return releases go away.
* fix(query): free compile errors and failed fast-path results
A key the compiler rejected left its error on the graph, and freeing
the graph released it with ray_release, which skips errors, so it
leaked. Failed top-k, fused take, vector allocation and asof operand
results were dropped the same way; all now go to ray_error_free.
* perf(query): gather only the kept rows of a parted no-output select
Flattening the whole parted table to return one row per group cost the
full table in memory. The select now runs on a narrow table that
flattens only the columns its clauses read and stands in a row number
for every other column, then gathers just the kept rows from the
partitions. Tables wider than the gather's column limit still flatten.
* fix(query): count quoted key and sort names as columns on parted tables
The parted no-output select gathered only the columns its clauses
referenced, and skipped quoted symbols, so `by: 's` or `desc: 'h` was
grouped or sorted by a row-number stand-in.
* perf(query): carry one row number through a parted no-output select
Each unreferenced column was a row-number stand-in of its own, so a key
with many groups gathered one row vector per column and could use more
memory than flattening the table. The narrow table now holds the
referenced columns and one hidden row number, and the result is rebuilt
in the flattened layout from the kept rows.
* fix(query): drop the filter's reference when where: keeps every row
When every row passed, the filter returned the input table itself with
an extra reference, which the no-output and the eval-level grouping
never released, so the whole input leaked on every call.
* test(query): cover the try trace clear and two error and parted cases
A caught error raised through a nested call, a sorted parted select
whose every row is its own group, and a failing asof operand on a
parted table now each have a check.
* fix(group): tell a null key from a function key
The function-key check also caught the null and lazy types. A null key
now has its own message, a lazy key is materialised first, and only
the function types report a function.
* fix(query): materialise a lazy computed key and keep where on a parted key
A lazy computed key, such as a moving max, failed the no-output path's
grouping. On a parted table, a where: on the key column was deferred
past the grouping, which a no-output select never reaches, so its rows
went unfiltered into the row path.
* fix(query): sort a parted table without by: by gathering the kept rows
A sort without `by:` that neither streaming path took fell through to
the flat sort, which read parted columns as plain ones and failed with a
type error, and its take form leaked. The sort now runs over the sort
keys and a row number, then gathers the kept rows from the partitions
with the row gatherer the no-output grouping uses. Shapes it declines
flatten the table first. The tests that pinned the type error compare
with the flattened table instead.
* refactor(query): drop the redundant key-name entry of the parted grouping
The derived key name already covers `key`, so the separate entry did
nothing. A test now pins the key-name guard itself.
* fix(query): decline the parted row gather when __row is a user name
The parted no-output grouping and sort carry a hidden `__row` column and
declined only when the table had one. A by-dict alias or a bound local
or global of that name shadowed it, giving an error or silently grouping
by row number. Any such use now falls back to the flattened table.
* refactor(group): drop the unreachable lazy-key materialise
Every expression key reaches this check already materialised; the
computed-key paths in query.c do the work and stay.
* fix(query): keep parted sorts bounded and decline output alias chains
A parted sort without by: flattened the table whenever a sort key was
not output under its own name, though the narrow sort reads keys as
columns either way. It now declines only when an output names another
output's alias, which the flat path reads as the alias and the gather
read as the source column, a silent wrong result.
* fix(query): stream a parted top-k only when the sort key is its own column
The parted top-k accepted a sort key that is an output alias of another
column: each partition sorted by the column, the merge by the output,
so the take kept the wrong rows.
* fix(query): flatten a parted no-output select whose clauses run code
The parted row gather builds a narrow table from the columns its clauses
name, so a column read inside a helper, or a name built at run time,
was missing from it or resolved to the hidden row column. Clauses that
call a lambda, call a computed head, read a container that may hold
lambdas, or evaluate text now take the flattened table.
* refactor(query): drop the bound-name check of the parted row column
With code-running clauses declined, a bound `__row` can only be shadowed
through a name the select writes, which the clause check covers. A
test pins a helper that reads a bound `__row`.
* fix(exec): keep no rows for a sort with take: 0
The sort-and-head fusion passed the take to the sort, which reads a
limit of 0 as no limit, so a sorted select with `take: 0` returned every
row. The fusion now applies only to a positive take.
* fix(query): check each by-dict value on its own for code in a parted select
The run-code check passed a by-dict's value list through the call-head
rule, so a dict whose first value was a builtin call looked like a
computed call and flattened the whole parted table.
* fix(query): follow a symbol binding to a lambda when checking for code
A dict literal stores its values unevaluated, so `{f: hy}` holds the
symbol `hy`, and calling `(D.f 0)` reaches the lambda through one more
lookup. The parted run-code check stopped at the symbol and took the
bounded path, which then missed the helper's columns. A binding that is
a symbol naming a lambda now counts as code, for plain and dotted names.
* fix(query): evaluate a collection concat by: key over the whole column
The planner keeps concat for text, so a bare by: key that concatenates
collections no longer lowers. Only helper keys fell back to the
whole-column evaluation; a bare concat key failed with "failed to compile
group key expression", even when aligned with the rows. Route keys that
call concat to the same fallback: an aligned one groups, a misaligned one
is refused with length.
* test(mem): measure top-level try per iteration, not per statement
A handler written as a lambda literal adds an entry to the closure
template cache once per statement (bounded at 256 entries). Run each
top-level try statement 1000 and 4000 times and require the same cost,
so the probe still catches a per-iteration leak of the caught error.
* fix(query): evaluate any by: key the planner cannot lower over the whole column
A bare computed by: key with no helper, such as (reverse x), (asc x) or
(til (count x)), failed with "failed to compile group key expression".
Only helper and concat keys took the whole-column fallback. Try to lower
every computed key and send any key that does not lower to the same
fallback: it groups as its lambda form does, honours where:, desc: and
take:, and a key of another length is refused with length. A text
concat still lowers and stays on the planned path.
* fix(query): evaluate an unlowered by: key over parted columns flattened
The whole-column key fallback evaluated the key over a parted table's
partition columns and failed with "key expression must yield one value
per row". With outputs, flatten the columns the select reads (the whole
table when a clause runs code whose reads are unseen) before evaluating
the key. With no output, leave the select to the first-rows path, and
when that path declines, run the select again over the flattened table
so the key reaches the fallback there.
* fix(query): report an unknown column in an unlowered by: key as schema
A key that lowers to the planner reports an unknown column as schema
("column 'c' not found"); one evaluated over the whole column reported
the evaluator's name error. Before evaluating, look for a free name in
the key that is no column and no bound name, outside any lambda written
in it, and report it as schema. An unknown function stays a name error.
* perf(group): check for a function or null key inside the scalar-key branch
The check that refuses a by: key bound to a function or to null sat as
its own early return in exec_group_run, with a release loop of its own.
It slowed computed keys on that path by 5-12% at 1M rows (bare bool,
mod, like and if keys), through the code built for the function rather
than the check itself. Make it part of the existing scalar-key branch,
which already returns its errors, and build the error in a cold
out-of-line helper. The keys time as on dev again; the errors are
unchanged.
* fix(query): let and set in a do bind their name for an unlowered key
The unknown-column check before the whole-column key evaluation reported
a name the key binds itself, `(do (let q x) (reverse q))` or
`(do (set q x) (reverse q))`, as an unknown column. A two-argument let
or set now binds its name for the forms after it, and only its value is
checked. The four-form let the check assumed is no let form.
* fix(query): evaluate an unlowered by-dict value over parted columns flattened
A by-dict value the planner cannot lower, a helper `{k: (fk)}` or a
builtin with no kernel `{k: (reverse i)}`, failed over a parted table:
the parted by-dict deferral compiled it ("failed to compile group key
expression"), and the eager path evaluated it over the partition
columns. The deferral now takes only values that lower; the eager path
first flattens the columns the select reads (the whole table with no
output, or when a clause runs code whose reads are unseen).
The where: prefilter before a computed key no longer narrows the table
to the columns the clauses name when a clause calls a helper: the
helper's own column reads failed with a name error, flat or parted.
* fix(query): keep projecting a where: prefilter that reads a container
The where: prefilter before a computed key stopped narrowing the table
whenever a clause named a variable bound to a list, dict or table, since
it used the parted gather paths' test, where such a container may hold
lambdas. A where: reading a config dict then filtered every column of a
wide table. The prefilter now declines only for code that reads columns
the clauses do not name: a lambda, a name bound to one or to its name,
a computed callee, eval, parse, resolve, load or value. The parted
gather paths keep the container rule.
* fix(query): know code and binders by the builtin, not by the call's spelling
Two checks in the by: key paths matched names as written.
The unknown-column check before the whole-column key evaluation followed
let and set only as siblings in one form, so a name bound in a nested
do or an if, `(f (do (do (set q x)) q))`, was refused as an unknown
column. The check exists only to pick the error code: it now runs only
on keys that bind no name and run no code, and leaves every other key to
the evaluator.
The test for code whose column reads a clause does not show matched
fn/eval/parse/resolve/load/value by the spelling of the call head, so a
builtin called by another name, `(set getv resolve)` then
`{k: (getv rname)}`, took the parted narrow-copy path and read its
private row-number column. Names are now resolved through locals,
globals, dotted paths and one name hop, and a builtin is known by the
name it was registered under. A name of a column of the table is the
column.
* fix(query): resolve clause names as the evaluator does, objects included
The checks that classify a clause's names and nodes read a name as a
column before looking for a lexical local, so a parameter or let that
hides a column was taken for the column:
- a local lambda named after a column, `(run P (fn [] y))` with
`{k: (x)}`, was not seen as code, and the parted narrow copy left out
the column it reads;
- a binder passed as a parameter, `(run set)` with `(x q y)`, was not
seen as one, and the unknown-column check refused the key;
- the parted sort path gathered the column for an output `x: x` that
reads a parameter x.
A lexical local now comes first, then the query column, then a global.
A builtin placed in a built query as the object itself, `(list resolve
rname)`, was not recognised as code (and `set`, `let` or `quote` as a
binder); the checks now look at the object as well as at names.
* fix(query): sort a parted table by gathering only stored output columns
The parted sort path took any output name that reads a column, a dotted
field of one included (`month: date.mm`), and then looked for a stored
column of that exact name to gather, failing with "lost a column of a
parted table". An output it gathers must be a stored column, and one no
lexical local hides; any other leaves the select to the flat sort.
* perf(import): bound parquet symbol import and splayed hash-index I/O (#773)
* perf(parquet): import dictionary counters under RAY_CSV_TRACE
Each symbol pass reports the strings it interned, batch-local repeats,
global probes, hits, inserts, slots read, false tag matches, table
growth and the dictionary sizes, so the import's access pattern can be
measured on real data.
* perf(store): allocate SYM stream lookup tables on first remap
A SYM column writer allocated and cleared its runtime-to-file lookup
tables at open, though a writer fed only chunks already in its target
domain never reads them. They are now made by the first chunk that needs
a remap; the parquet imp…
What & why
Fix query membership and concat results that differed between planned execution and evaluator fallback. Inline typed membership probes use the null-aware kernel for numeric, temporal and SYM columns. Fallback covers empty inputs, partition keys, GUID nulls and nested
not-in; mixed LIST probes retain general membership.Collection concat keeps ordinary collection semantics for literal and bound vectors, in either operand order. Column-derived text operands retain text concat through casts, uncompiled operations and helper calls; a reordered, indexed or cast column is a whole column in projections, so select agrees with update, where and by. A STR column's cell keeps its type row by row, and a null cell is the empty text the vector kernel reads.
Helpers called from a query fallback run as bytecode. Row provenance is a flag beside every VM stack slot and on every scope-frame binding: parameters,
letlocals, a scoped window's locals, a closure's captures and a helper's on-demand source columns carry it; builtins the planner lowers row by row keep it, collection verbs clear it, compiled calls pass it to their callee and return it with the result, andconcatconsults it. The flag is tri-state and relative to the innermost query. A call frame marks the lexical boundary: the query's aliases and locals are hidden, a free source-column name is the query's binding for it (the row's cell, the group's rows or the column), a literal symbol is the symbol in a helper called by name, and other free names are globals. Lambdas written inline in the query are query text, stay on the tree walker and keep the query's column rule.Closures: a
fnliteral inside a compiled body is compiled once, as a template, when its enclosing function compiles, and every closure made from it shares that bytecode (OP_MKCLOSURE) and captures only the enclosing locals its body mentions, bound by name into its own slots (OP_BINDCAPS). Aletof a captured name rebinds the slot for that call, a parameter shadows its capture, a local named like a builtin is the callee,selfis the closure itself, an emptyletslot or an unbound capture reads as the global or a name error (OP_LOADENVG),evalandresolverun inside a scope window over the locals that wraps only the call, a closure body resolves the names it does not capture when it runs (a top-level definition keeps its builtins baked, and its shared template is recompiled once a function-valued global is rebound), a lazy value is materialized when captured, capture is lexical (a caller's locals are never captured, a change from dev's dynamic capture for interpreted makers), andreturnends a lambda on the tree walker as it does compiled. Closures made by interpreted code share a template per literal and capture set through a per-thread table keyed on every node of the literal. Closures are never inlined into a query.Helpers built from effect-free builtins are inlined by the planner and the fallback. An opaque helper can be the
by:key: a pure body (callees followed through globals, parameters, lets and captures; a computed or unknown callee,set,evaland system or I/O builtins are effects) is evaluated whole over the rowswhere:keeps and again row by row when a conditional tested a vector or the whole run failed; a body with an effect runs row by row from the start. A grouped key column keeps its "may have nulls" flag only when an emitted key is null, so fused and materialized results agree on the wire.Grouped fallback reserves all aggregate slots, keeps the requested column order (an output named like its key keeps both columns) and uses the same kept-group prefix for keys and outputs. Constant collection COUNT counts the collection. Filtered COUNT handles computed inputs. Grouped updates prepare pure helper expressions and reuse dependency analysis across groups. Nested-select composition preserves materialization when an outer sort names an inner computed or renamed column. Index-attachment diagnostics and query-scope documentation are corrected.
Includes #758's head (3a8ff52): fixed row chunks are folded in chunk order, independent of worker scheduling, with accumulator blocks padded to 128-byte line pairs. The fuzz oracle retains exact null-flag comparison and its original bounded aggregate tolerance. Pool tests drain pending read-ahead and guarantee worker participation.
Behaviour changes from dev, each documented: grouped fallback outputs follow the requested order; reordered, indexed or cast concat operands are whole columns in projections; quoted symbols are literals inside named helper bodies; inside any function
concatkeeps collection semantics unless an operand derives from the query's rows.Validation
b21830b6, including the targeted audit, the fuzz smoke and cppcheck; TSan clean.test/rfl/query/concat_*.rfl,membership_*.rfl,helper_bytecode_provenance.rfl,concat_helper_consistency.rfl,group_key_null_flag.rfl,lang/closure_locals.rfl,mem/closure_flags_release.rfl,nested_sort_computed.rfl,keyless_pool_accum.rfl,lang/query_helper_reinit(runtime re-init),lang/closure_malformed_capture,lang/closure_lazy_capture_vmandstore/serde_lambda_malformed_closure. Reverting each hunk of the helper rounds in turn fails at least one test.Release medians against dev
57f5cfe3, eight workers, 2M rows unless noted:(sum (hn (+ x 1)))ifand a helper(reverse x)(dev computes a collection)(times 1000000 (cadd 2)), closure call, one coreScripts:
bench/concat_fallback.rfl,bench/concat_update_fallback.rfl,bench/membership_fallback.rfl.Checklist
dev