Skip to content

feat(query): join operators (nested-loop, block-NL, sort-merge, grace) + sort, catalog, scratch workspaces#32

Merged
nevzheng merged 17 commits into
mainfrom
feat/joins
Jun 14, 2026
Merged

feat(query): join operators (nested-loop, block-NL, sort-merge, grace) + sort, catalog, scratch workspaces#32
nevzheng merged 17 commits into
mainfrom
feat/joins

Conversation

@nevzheng

Copy link
Copy Markdown
Owner

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 produce left ++ right and handle INNER/LEFT/RIGHT/FULL + cross, NULL-padding the absent side):

  • Nested-loop — tuple-at-a-time; the general fallback for any predicate / cross / non-equi.
  • Block nested-loop — buffers the outer in blocks, scans the inner once per block via a MemoryWorkspace.
  • Sort-merge — inner equi-joins over pre-sorted inputs (optimizer inserts Sort enforcers).
  • Grace hash — equi-joins, hash-partition to disk + recursive repartition. ⚠️ see Known issues.

Supporting infrastructure:

  • feat(filesystem): bounded scratch workspaces (in-memory + file spill).
  • feat(query): external-merge Sort + ORDER BY, arithmetic expressions, the volcano execution module, and the expression module split.
  • feat(catalog): pg-compatible normalized catalog as the metadata base; index + clustered-key metadata.
  • feat(query): JoinConfig derives join scratch limits (partition buffers, build budget, fan-out) from total system RAM via sysinfo at Db open (8 GiB fallback), threaded through QueryContext.

⚠️ Known issues — grace hash join is flagged WRONG

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-db green (132 tests incl. the join .slt spec + grace unit tests); cargo fmt --check and cargo clippy -- -D warnings clean.

🤖 Generated with Claude Code

nevzheng added 17 commits June 13, 2026 22:31
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.
@nevzheng
nevzheng merged commit f3e6c3b into main Jun 14, 2026
5 checks passed
@nevzheng
nevzheng deleted the feat/joins branch June 14, 2026 10:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant