feat(query): join operators (nested-loop, block-NL, sort-merge, grace) + sort, catalog, scratch workspaces#32
Merged
Merged
Conversation
Adds the `Workspace` trait and two backings for ephemeral execution scratch — join build sides, sort runs, materialized inners. The engine owns all tuple/join policy; this layer only provides bounded space. - `MemoryWorkspace` — pages are pool-backed `Slab`s, zero-copy reads. - `FileWorkspace` — journal-less `PageCache` over a temp file, sized by two independent budgets: an in-RAM working set (frames) and an on-disk ceiling. Completed pages flush to disk and their RAM is reused, so live memory stays at the working set however much spills. Both are always bounded (no unbounded mode) and report `capacity`/`used`; exceeding the bound fails with `Error::WorkspaceFull`, a useful OOM that counts as resource exhaustion. Reads are block-at-a-time — `blocks()` streams blocks (zero-copy, for block-nested-loop), each block's `tuples()` streams views; `tuples()` is a convenience that navigates blocks and copies out. Cleanup is drop-based (file: removes its temp dir, path retained for a future GC). No journaling on any read/write path (uses `PageCache::new`, never `with_journal`), verified by test. Thin wrapper: paging, eviction/flush, spill, pinning, and checksums are all delegated to `Heap`/`PageCache`; the only additions are insertion-order replay, a disk bound, and cleanup. Adds `PageTuples::iter()` (per-block tuple views) as the read primitive.
Adds JOIN support end to end: INNER, LEFT, RIGHT, FULL, CROSS, and
comma-FROM (cross), via the PostgreSQL dialect's syntax. Hash/sort-merge
and semi/anti (which need subqueries) are future work.
Plan model:
- `LogicalNode::Join { left, right, on: Option<Expr>, join_type }` and the
matching `PlanNode::Join` with a `JoinStrategy`. `on: None` is a cross
join; the output row is `left ++ right`, so the ON predicate and later
column refs index into the concatenated tuple. `JoinType` is the
semantics (fixed by the query); `JoinStrategy` is the algorithm chosen
in lowering — today always `NestedLoop` (`select_join_strategy`).
Binder:
- Replaces the bare-`Schema` scope stack with a `Scope` carrying per-column
provenance (source relation/alias + field). Qualified refs (`t.col`) now
resolve and disambiguate; unqualified refs error on ambiguity. A join's
scope is `left ++ right`; the ON predicate binds against it.
- FROM binding folds comma items and JOINs left-deep; table aliases are
honored as the relation name.
Executor:
- `NestedLoopJoin` (the general algorithm: any predicate, incl. cross).
Materializes both inputs; emits matched `left ++ right` pairs, then
NULL-pads unmatched left (LEFT/FULL) and unmatched right (RIGHT/FULL).
Output arity is read structurally so an empty side still pads correctly.
Covered by joins.slt (all types, both syntaxes, qualified/unqualified
refs, a 3-table chain, ambiguity error). Full spec suite green.
Adds the BlockNestedLoopJoin operator and makes it the default join strategy (`select_join_strategy`), since it subsumes tuple-nested-loop for any predicate. NestedLoop stays as the reference, selectable only via a hand-built physical plan. BNLJ buffers the outer in fixed-size blocks and scans the inner once per block (amortizing rescans) instead of once per outer tuple. The inner is materialized into a `MemoryWorkspace` — the engine's first use of the scratch-workspace layer, and the seam where a spilling backing (file workspace / grace) plugs in later. Tuples cross the byte-oriented workspace via their self-describing serde encoding (the plan carries no schema). RIGHT/FULL track matched inner rows across all blocks for the unmatched pass. strata-store now re-exports the workspace surface so the engine reaches it without depending on `filesystem` directly. joins.slt switches to `rowsort` (join output is unordered; BNLJ emits a different order than tuple-NLJ), so the specs are algorithm-agnostic. A new tests/joins.rs exercises each strategy directly: concrete inner/outer results, NLJ≡BNLJ agreement across all join types and cross join, and a 2500-row case that spans multiple blocks.
Volcano is now a module (`volcano/`): `scalar` (Filter/Project/Limit/
Offset), `sink` (writes), `join` (nested-loop family), `sort` (external
merge), with `mod.rs` holding the dispatch and shared `build`/`drain`.
Sort + ORDER BY:
- `Sort` plan node (a pipeline breaker) + `SortKey { expr, ascending,
nulls_first }`. The binder accepts ORDER BY (placed below the projection
so it can order by any input column/expression) with Postgres NULL
defaults (LAST for ASC, FIRST for DESC).
- External merge sort: run generation (sort memory-sized batches) into
per-run `FileWorkspace`s — sorted in RAM, spilled to disk — then a k-way
min-heap merge. Sort keys normalize to order-preserving bytes so plain
`Ord` yields the full ASC/DESC + NULLS ordering, key by key.
Workspace codec: join/sort now serialize scratch tuples with the existing
schema-driven `Schema::encode`/`decode` (no bespoke/serde codec). The
binder carries the materialized row shape on the nodes that need it —
`Sort.input_schema` and `Join.right_schema` — derived from the `Scope`.
Arithmetic: `BinaryOperator::{Add,Sub,Mul,Div,Mod}` with numeric coercion
(int→i64, float→f64, else exact decimal), overflow and divide-by-zero
errors. Closes the `unsupported: binary op: Plus` gap.
Covered by order_by.slt, tests/sort.rs (incl. multi-run merge), and the
updated limit.slt (ORDER BY now works). Full suite green.
`expression.rs` had grown to ~1500 lines. Split it into a module: - `expression/cast.rs` — `CAST`/`::` conversions. - `expression/function.rs` — built-in scalar functions and `LIKE`. - `expression/mod.rs` — the `Expr`/`BinaryOperator`/`ScalarFunc` types, the `eval` dispatch, binary/arithmetic ops + numeric coercion, and tests. The coercion helpers (`as_i64`, `cmp_values`, `values_eq`) and `concat_str` are now `pub(super)` so the submodules share them; no behavior change. Adds eval tests for the new arithmetic: integer widening to i64, float promotion, exact-decimal, NULL propagation, divide-by-zero, and overflow.
Adds a place to represent access paths in the catalog:
- `catalog::index::Index { name, columns }` — a secondary index keyed on
ordered column positions.
- `Table` now carries `clustered_key` (the row-key order a scan yields for
free — today the leftmost column) and `indexes`, with lookups
`clustered_on(cols)` / `index_on(cols)` (prefix match) the optimizer will
use to decide when a sort-merge join can skip a `Sort`.
- `TableMeta.indexes` persists secondary indexes (`#[serde(default)]` so old
rows load as none); `resolve_table` attaches them to the descriptor.
`pg_class.relhasindex` now reflects real metadata (`!indexes.is_empty()`)
instead of a hardcoded `false`; `reltuples = -1` remains the honest
"unknown until ANALYZE".
Reorganize the catalog around normalized, pg-style tables so a table's shape lives in granular rows the planner and catalog views read directly — the base for pg-compatible introspection and, ahead, cost-based operator selection over clustered/index orderings. Storage: - Reserve normalized catalog tables `_columns` / `_indexes` / `_constraints` (ids after `_stats`), with `ColumnMeta` / `IndexMeta` / `ConstraintMeta` row types, reusing the `(pk, meta-json)` system shape. - `create_table` / `replace_table` write one `_columns` row per field (clearing prior rows on replace); `TableMeta` no longer embeds the schema — `_columns` is the sole source of a table's shape. Read path (relcache): - `assemble_schema(table_id)` builds a `Schema` from `_columns`; `resolve_table` and the views (`pg_attribute`, `information_schema.columns`, `pg_class.relnatts`) all read it. Session: - Under-qualified names resolve against a fixed `strata.public` search path (`qualify_table_name`), so a pgwire client can address `public.foo` or a bare `foo` on connect (DDL, DML, queries). `SET search_path` is future. Covered by catalog_data.slt (create + insert + introspect) and an updated smoke.slt; `CREATE INDEX` / a `pg_index` view are intentionally deferred. Full suite + 18 specs green.
Inner equi-joins lower to a sort-merge join: each input is wrapped in a Sort enforcer on its join key, so the operator merges pre-sorted inputs in one pass, expanding equal-key groups to their cartesian product. NULL keys never match and are skipped. Non-equi, cross, and outer joins fall back to block nested loop.
Hash-partition both inputs to disk by their join key (FileWorkspace per partition, so a build larger than memory spills and still completes), then process one partition pair at a time: build an in-memory hash table from the right side and probe it with the left. Equal keys land in the same partition, so each pair is an independent join. Handles every join type (INNER/LEFT/RIGHT/FULL); NULL keys bypass partitioning and are emitted padded only by the outer sides. Selectable by hand-built plan.
Split the volcano join operator into a join/ module: nested_loop (tuple + block), merge (sort-merge), and grace (hash), with shared row plumbing in mod.rs. Grace hash join now keeps its build side in memory at any scale. It gates each partition on Workspace::used(): a partition over the build budget is recursively repartitioned with a fresh per-level xxh3 seed (so keys actually redistribute instead of re-colliding), and a partition that even repartitioning can't split (irreducible single-key skew) falls back to a streaming nested loop — sequential reads, never a random hash-table spill. Routes partition hashing through xxhash-rust (xxh3) with a seeded bucket function.
Grace hash join no longer materializes its whole result into a Vec before returning. Partitioning stays a pipeline breaker (both inputs consumed up front), but everything after streams: GraceHashStream is a real lazy iterator that pulls one partition at a time, builds an explicit hash table from the SMALLER side, and probes by streaming the larger side one tuple at a time (never held in memory). Adds FileWorkspace::into_tuples — an owned, block-buffered tuple cursor — so the probe cursor can live as operator state without borrowing the workspace. Bundles the join predicate/type/schemas into JoinPlan to shrink the operator signature, and renames the volcano input builder to build_input to disentangle it from the hash-table build phase.
Replace the up-front used()-size precheck with a build-and-measure decision: build the smaller side's hash table and bail the moment its materialized rows outgrow the budget, then repartition with a fresh seed and retry. Caps retries at 2 (down from 4) — once a single key dominates, more passes don't split it, so the last attempt builds in memory anyway. This measures the real build instead of guessing from spilled bytes.
Add JoinConfig (partition buffers, build-side budget, fan-out) sized from total RAM via sysinfo at Db open, with an 8 GiB fallback. Thread it through Db -> QueryContext so the grace hash join reads machine-derived limits instead of hardcoded constants. DbBuilder::join_config pins them for tests.
Reimplement grace as two clearly separated phases (build -> probe) reading sizing from JoinConfig. Flagged WIP: a FIXME at the top records why it is not up to par yet — the build phase materializes both inputs instead of streaming one chosen side, the hash-table structure needs redesign, and repartitioning duplicates the partition pass instead of splitting a bucket in place. Do not route the planner to GraceHash until this is reworked.
FIXME at top records the intended shape (author review notes): a plain single-build hash join — build ONE side's map in full, stream the left side against it, emit matches — not the current partition/build/probe machinery.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
This branch builds out the query execution layer for joins, plus the supporting infrastructure it needed: spillable scratch workspaces, an external-merge sort, an expanded catalog, and machine-derived join memory limits.
What's included
Join operators (
query/volcano/join/, all produceleft ++ rightand handle INNER/LEFT/RIGHT/FULL + cross, NULL-padding the absent side):MemoryWorkspace.Sortenforcers).Supporting infrastructure:
feat(filesystem): bounded scratch workspaces (in-memory + file spill).feat(query): external-mergeSort+ORDER BY, arithmetic expressions, thevolcanoexecution module, and theexpressionmodule split.feat(catalog): pg-compatible normalized catalog as the metadata base; index + clustered-key metadata.feat(query):JoinConfigderives join scratch limits (partition buffers, build budget, fan-out) from total system RAM viasysinfoatDbopen (8 GiB fallback), threaded throughQueryContext.The grace hash join carries a
FIXME(nlz)at the top of grace.rs: the author considers the current partition/build/probe structure not what's wanted and intends to rework it into a plain single-build hash join (build one side's map in full, stream the other side against it, emit matches). It is not reachable from the planner — the planner only routes inner-equi to sort-merge and everything else to block-nested-loop; grace is selectable only via a hand-built plan. So merging this WIP does not affect query results, but the rework is pending review.Testing
cargo test -p strata-dbgreen (132 tests incl. the join.sltspec + grace unit tests);cargo fmt --checkandcargo clippy -- -D warningsclean.🤖 Generated with Claude Code