Skip to content

WorkTable 1.9.0-alpha1: v3 persistence, integrated Vec tables and runtime selection - #105

Merged
pathscale merged 149 commits into
masterfrom
feat/runtime-backends
Sep 13, 2026
Merged

pathscale merged 149 commits into
masterfrom
feat/runtime-backends

Conversation

@pathscale

@pathscale pathscale commented Sep 9, 2026

Copy link
Copy Markdown
Owner

WorkTable 1.9.0-alpha1 consolidates runtime selection, columnar layouts and Vec tables with the coordinated DataBucket v3 format cutover. This is the single release PR for WorkTable.

Resulting behavior

Generated tables default to Nagoya Locality with four empty search rounds of 128 spin hints before host parking. Existing flavor numbers and grammar stay unchanged. LowLatency remains an explicit longer-spin option; Tokio is an optional backend. Nagoya uses the broad ^0.1 requirement; the resolved 0.1.2 release carries the reviewed runtime fixes.

WorkTablesIndex now uses standard slice search by default. At the default 1,024-key node width, the isolated matrix measured randomized lookup at 45.4 ns versus 101.9 ns for predictable search. The alternating table A/B retained predictable search as an explicit write-heavy option: it reduced persisted insert-and-drain medians by about 12-14%, while standard search was faster for stable four-client in-memory insertion. The feature name describes the algorithm and does not require Rust std.

Query profiles execute through owned, cancellable tasks. Select builders materialize borrowed iteration and predicates on the caller, then send owned filtering, sorting, offset and limit work to the selected pool. An explicit profile requires execute_async; synchronous execute reports RuntimeRequiresAsync. Runtime-annotated mutations require an Arc table receiver and owned Send/static arguments. Unannotated calls retain borrowed signatures. Profiles may select another flavor within the same backend family. Dropping a waiter cancels pending work; synchronous work already running can finish, and cancellation is not rollback.

Vec storage and its indexes are integrated into WorkTable. The review fixes secondary-key collisions and panic-safe candidate edits, snapshot page identity/append validation, stale unique-index locations during row relocation, generated index capacity and columnar/layout emission. Columnar fields, clustered side indexes, partitioning and Rust callsite tuning are covered in the canonical guide.

Ordinary persisted pages carry a live-row directory and integrity checksum. Independent scans find live rows without an index, including after deletion and variable-length relocation. Vacuum maintains the directory, clears reclaimed pages before advertising reuse and restores free-range ownership on reopen. vacuum_with_pacing extends the Rust callsite API without new grammar.

Final correctness review

A released collision could let two readers of the same archived row use separate lock entries, allowing a writer to miss one reader. The first mutex-based repair was sound but serialized random reads. The final implementation maps offsets through a full-bit mix onto 256 stable reader/writer stripes per data page. Colliding readers may proceed together; a colliding write waits conservatively. A generated in-place callback may synchronously read a different row on its owned stripe; same-row reads and nested writes return CellLockReentry. Native regressions and two bounded Loom models cover the reproduced interleaving, callback collision and read/write exclusion. Stripe state and writer ownership use 3 KiB per 16 KiB page on 64-bit hosts. This storage is runtime-only and does not change the archived image or grammar.

The vacuum wake regression now exercises the complete wake-to-sweep path with the same operation-wide bulk guard used by ranged deletes. The prior test stopped at a wall-clock sampling helper and could fail when a loaded runner starved the delete task for one settle interval, even though the production sweep still waited at its activity gate. The corrected end-to-end case passed 20 consecutive local repetitions and the exact-head versioned-publication lane. Runtime code is unchanged by this test correction.

Distinct live lock dependencies could also collapse when diagnostic u16 labels repeated. Equality and hashing now use the existing shared flag allocation, while labels remain diagnostic. Explicit unlock performs cleanup once. Regressions reproduce both defects against the prior implementation.

The allocation regression test had a separate accounting race: parallel tests reset and charged one process-global counter. Thread-local measurement regions now isolate each test and restore disabled accounting after panic. Existing memory limits remain unchanged. This changes test accounting, not table storage.

Breaking data cutover

WorkTable and DataBucket move from page format v2 to v3 together. Readers reject incompatible stores without rewriting or deleting them. Explicitly recreate regenerable data; retained data requires application-specific conversion with the old reader. Page-size changes also change layout. Vec snapshots use a separate container and are not interchangeable with ordinary space files.

Validation

The full local CI sequence passed on the page-extent implementation: default and versioned workspace tests, all-feature builds/tests, formatting, default/all-feature workspace Clippy with warnings denied, the two bounded cell-lock models, generated no_std consumer tests, Windows GNU no_std, and three portable WTI search graphs. After the final buffered-scanner refactor, exact-head S3 Clippy, all six manifest/extent unit tests and the stateful transfer/restore integration test passed again at 4e01926. All nine exact-head GitHub CI lanes pass.

A concurrent persisted insert/delete/reuse stress test exposed an order inversion: primary-index events are assigned during the mutation, while UUID operation ids are created later. Durable row coalescing now follows primary-event order, matching index replay. The prior head reproduced a key/row mismatch on iteration 15; the fix passes 50 consecutive fresh reopen cycles and the focused batch regression.

The final local checks use Rust 1.97.1, RUST_TEST_THREADS=2 and WT_RUNTIME_WORKERS=2, retaining each test's internal concurrency. GitHub uses the newest stable toolchain. The exact-head remote run passes the same formatting, duplicate-graph, default/all-feature Clippy, Loom, no_std, default, versioned-publication and all-feature gates against the published dependency graph. The earlier five-second shutdown failure under heavy host load is preserved in the release audit; the final all-feature run passes without increasing that timeout.

The database-wide storage addition passes DataBucket's 79 library tests, workspace all-feature tests, all-target all-feature Clippy, and all three no_std gates. WorkTable passes all-target all-feature Clippy, both no_std consumer checks, and 732 non-ignored all-feature integration tests. The local trybuild subprocess must resolve the published DataBucket 0.7.1 feature graph, so that final lane is intentionally gated on DataBucket #77 merging and publishing before this PR.

The complete no_std target graph builds with Rust std removed from its sysroot, retaining libc/native OS services and host proc macros. Checks pass on macOS ARM64, Windows GNU x64 and Linux musl ARM64. An isolated generated-table consumer runs CRUD, concurrent page growth and ordered UUID v7 generation. Three portable WTI search combinations pass; std-only optional features declare that requirement explicitly. Published psc-nanoid 3.2.0 replaces the temporary NanoID patch. Additional cross-target/feature gates are part of both CI and the local script.

The archived-row lock was remeasured with a controlled same-source A/B: identical f5eb6f0 source and dependency graph, changing only the lock implementation. The exact-row registry repaired correctness but retained only 85.3%, 51.6% and 35.4% of the stripe baseline at 1, 4 and 12 clients; its twelve-client median was 48.50 million reads/s. The final owner-aware stripes measure 99.2%, 99.5% and 100.5% of the plain-stripe baseline at 1, 4 and 12 clients, with the twelve-client median moving from 154.27 to 155.99 million reads/s. At twelve clients, update50 changes by +4.4%, +7.5% and +2.4%, while read-modify-write changes by -0.8%, +6.6% and +3.5% across balanced, almost_tokio and Tokio. The final source adds only a zero-sized !Send guard marker and lint cleanup after the measured implementation, so it has the same runtime code.

Historical results retain their original source revisions. The clean final evidence in https://github.com/pathscale/perf-benchmarks/pull/3 is one consolidated 889-metric report containing the repeated supplement, isolated search matrix, structural/logical persistence matrix and alternating default-search A/B. It passes self-validation with unique metric identities. No measurements ran alongside compiler activity or native UI QA.

The recommended S3 path now uses one database-wide DataBucket storage domain and one real generated WorkTable system catalog. database_s3_persistence!(TableName) is a Rust callsite extension, with no table grammar change. One cloneable S3Database is shared across table engines. DataBucket stages content-addressed data pages and stable 16 KiB index chunks, WorkTable prepares the generated catalog checkpoint, and a conditional 160-byte head publishes the generation only after both are durable. Applications receive read-only catalog views for tables, pages, indexes, and replication state.

The table mutation path only enqueues work. Local persistence, catalog accounting, hashing, blocking HTTP, conditional publication, and restore run on the private one-worker persistence runtime. The stateful adapter fixture covers a generated-table insert, complete background commit, local deletion, catalog restart, bounded page reads, atomic file rebuild, and row lookup. It measures 33,016 uploaded bytes for one page with a small catalog and 49,544 bytes after the catalog grows beyond one page. The 4 MiB target remains a coalescing ceiling rather than a mutation floor. The existing per-table s3_sync_persistence! engine remains available for old manifests during transition.

The canonical manuals are docs/wt-user-guide.typ and docs/why-worktables.typ, with rendered PDFs. The user guide covers declaration features and Rust callsites, including vacuum pacing and the search-policy tradeoff. Markdown entry points link to canonical sources. The offline gate validates the S3 protocol and transfer shape against a stateful local object service; a configured support.cafe/Tigris smoke remains part of the later application cutover rather than a correctness substitute.

Release order

WorkTablesIndex 0.0.15, parking_lot_lite_hack 0.12.8, Congee 0.4.6, data_bucket_derive 0.3.18, ps-st3 0.6.2, Nagoya 0.1.2, psc-nanoid 3.2.0 and Arctic 0.1.12 are available. DataBucket #77 must merge and publish 0.7.1 first because it supplies the storage-domain and s3-support APIs. WorkTable #105 follows; its CI publishes worktable_dsl, codegen and WorkTable in dependency order. Production cutover still needs the application's explicit data policy.

Storage architecture and remaining spill work

The DataBucket storage-domain contract, S3 adapter and generated system catalog are implemented here and in DataBucket #77. The Upstash and hybrid dual-write protocols plus the bounded WorkTable partial-hydration model remain specified in docs/remote-page-stores-and-partial-hydration.md. The catalog and bounded read_page path are the foundation for partial hydration; generated queries do not yet evict or fault row pages, so the document does not claim spill mode is complete.

The deployed provider gate selects Tigris for production page-segment storage and keeps 4 MiB as the coalescing target rather than a minimum. Wasabi passed twice with 29-33 ms median page PUT, 11-14 ms median 256 KiB range GET, 275-296 concurrent page writes/s and safe conditional heads, but its 90-day overwrite/deletion charge is a poor fit for database churn. A pure-Singapore Bunny S3 zone significantly outperformed Tigris for point and range reads while Tigris retained stronger sustained writes, so Bunny remains measured evidence rather than the selected backend. Cloudflare R2 preserved exact reads and safe conditional heads, but its 49.73 ms range median, 369.15 ms mean 4 MiB PUT and 90.90 Mbit/s write rate miss the performance gate. Upstash's roughly 216 ms request floor disqualifies synchronous per-page persistence. The reusable Rust drivers are in DataBucket #77, and the consolidated evidence is in perf-benchmarks #3.

meh and others added 30 commits September 7, 2026 16:49
ps-reclaim 0.1.4 closes a use-after-free. A retirement published between the
participant scan and the extraction of garbage was judged against a decision
taken before it existed, so a live reader's object could be reclaimed under it.
The fix is a sequence cutoff captured under the garbage mutex before the scan.
It also stops bounded drains being quadratic: `extract_if(..).take(k)` compacts
the unchecked tail on drop, so a 128,000 backlog at `advance_up_to(256)` moved
23.29 ms of records under that mutex and now moves 1.28 ms.

Both were already being picked up by resolution, because `^0.1` allows them.
Raising the floors says they are required rather than merely permitted, which
is what a correctness fix means.

arctic 0.1.11 is the release where `smr-ps-reclaim` stopped implying `std`.
It changes nothing here - WorkTable links `std` and takes ps-reclaim directly
with default features, so feature unification gives it `std` either way - but
the floor is what lets a no_std consumer downstream rely on it.

    cargo test    927 passed, 0 failed
    cargo clippy --all-targets    clean
beta.19 changed the on-disk format and every `.wt.data` on this machine had to
be thrown away and rebuilt on 6 September 2026, because nothing could read the
old shape. That is a regeneration event, and the reason it happened is that a
data page cannot be read without the index that points into it.

This test states the requirement for beta.20 as an executable assertion rather
than a paragraph in a release note: a page that describes itself can be read by
a reader that has never seen the writer's index.
The CIDR submission deferred the lock-discipline scaling comparison, crash
consistency, protocol checking, the cost of monomorphization, and baselines
beyond redb and LMDB. This plans the paper those deferrals point at, and pins
each candidate contribution to the code and the measurements that back it,
so the writing starts from what has landed rather than from an outline.
`chunks_exact` with a constant chunk size is a lint on a newer clippy than the
one installed here, so the local run was clean and CI was not. `as_chunks`
also gives fixed-size arrays rather than slices, which is what the loop wanted.
`scripts/ci-local.sh` runs `cargo fmt --all --check` as its first step and CI
did not, which made the local script stricter than CI rather than equal to it,
so formatting drift could reach master unnoticed. Same command, same arguments.

Measured before adding it: the tree already passes, exit 0 with no diffs, so
this job is green as written rather than a red first run to clean up.
The log carried beta.18 and beta.19 and nothing before them, so the history a
consumer needs in order to judge an upgrade was only in the commits. Entries
are derived from this repository's own history.
`BatchOperation::validate` refuses an event stream with a hole in it. A hole is
normally transient, and one that survives the whole deferral budget is not, but
the stall message could not tell the two apart: it reported the range and
nothing else. Two very different bugs produce that line. Either an id was
assigned by the index and its event was dropped instead of queued, in which
case nothing will ever deliver it, or the operation was queued and batch
collection keeps assembling batches that exclude it.

Distinguishing them needs a record of what was queued. Assignment happens
inside the index and cannot be hooked from here, but every event reaching
persistence passes through `Queue`, so an id present in the stream and absent
from the ledger was assigned and never queued. That is the leak signature.
Producers are named by `std::panic::Location`, which is why `push_at` takes the
caller location and why the old `push` and `push_many` wrappers are deleted
rather than kept: calling them loses exactly the location the ledger exists to
record.

This observes and does not fix. `docs/TODO.md` records the three leak sites the
instrument points at, all in generated code: the `NotFound` arm of the update
query returns without building an acknowledge while its `AlreadyExists` sibling
builds one, and two `res?;` sites in update and delete propagate before
acknowledging.

The TODO's congee section is corrected while here. The crossbeam-epoch port was
already done in congee-wt 0.4.4, and it was a design change rather than a
rename: per-tree `Domain`, batched retires, guard provenance checked, and a new
`Guard` that is `!Send` and tree-scoped, which constrains future callers.
The ledger from the previous commit points at three sites in generated
persisted query code where an event the index had already assigned an id to
was dropped instead of queued. A dropped event is a hole
`BatchOperation::validate` refuses forever, so the table stalls and the
message names a range rather than a cause.

All three now do what the rollback arms already did: build an `Acknowledge`
carrying the orphaned events and apply it before propagating. The
`IndexError::NotFound` arm of the update insert path acknowledges the events
its sibling `AlreadyExists` arm was already acknowledging, and the two bare
`res?` sites, in `gen_process_diffs_remove_on_index` and after
`delete_row_cdc`, became `if let Err(e) = res` blocks that acknowledge and
then return.

The events are moved into the acknowledge rather than cloned, and that is
load-bearing rather than a preference. Cloning does not compile: the events
type is still an inference variable at that point, pinned only by the
`op.extend_secondary_key_events` call further down, and method resolution for
`.clone()` needs the type known where the call is written. It fails as an
`E0282` reported against the `worktable!` invocation with no inner span, which
is expensive to diagnose. The comment in `docs/TODO.md` records that so nobody
pays for it twice.

The write failures are not forcible through the public API, so the wiring is
pinned on the emitted tokens the way the surrounding tests already do it. Both
assertions check the acknowledge is emitted *before* the return or the extend,
not merely that one appears somewhere in the output.

The in-memory generator has the same two `NotFound` arms and is deliberately
untouched: there is no persistence stream behind them to gap.
`DataPages::pages` was a `Vec` behind an `ArcSwap`, and both append paths
cloned the whole thing to add one page. Every page in it is an `Arc`, so the
clone was one atomic increment per existing page, each touching a separately
allocated page header: a cache miss apiece. Appending page N cost O(N), and
filling a table cost O(N^2).

It hid behind row size, because row size is what decides how many pages a
table has. At 256 bytes a page holds sixty-odd rows and twenty thousand rows
is three hundred pages, where the copy is invisible and per-row cost is flat.
At 4 KiB a page holds three, the same rows are six thousand pages, and per-row
cost climbed from 2.95 to 30.08 microseconds across the load.

The pages now live in fixed-size chunks, so an append copies one chunk and the
spine of chunk pointers rather than every page. Readers still take an `ArcSwap`
snapshot and never block, and page access still goes through the directory
first, so the read path is unchanged.

Measured over twenty thousand 4 KiB rows, per-row cost goes flat - 1.53 to 1.95
microseconds against 2.95 to 30.08 - and inserting into an unpersisted table
goes from 254 MB/s to 3062. A persisted table gains far less, 208 to 279 MB/s,
because with the copy gone persistence is what the load now waits on.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The directory holds stable page pointers so a point access does not take an
`ArcSwap` snapshot, and it reached 64 * 64 = 4,096 pages, which is 64 MiB at
the default page size. Past that `publish` returned early and every access
fell back to the snapshot. A table of 4 KiB rows crosses that after twelve
thousand rows, which is not a large table.

Raising it to 1,024 roots reaches 1 GiB for an 8 KiB array of pointers. On its
own it made no measurable difference to insert cost, because the page list copy
was the term that mattered; this moves a ceiling rather than removing a cost,
and it is committed separately so the two are not confused.

The tests are the harness the page list work was measured with: whether insert
cost scales with row size, whether it grows as the table fills, what the floor
is with few pages, and what the persistence path sustains end to end. They are
all `#[ignore]`d, since they are measurements and not assertions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An insert that fits in the page already open and one that has to add a page
are different operations with different costs, and averaging them hides the
second behind the first. How much it hides depends on row size: at 256 bytes
one insert in sixty allocates, so allocation is a tail event, while at 4 KiB
one in three does and it is a third of all traffic.

Split by whether the page count moved, the page list change reads as what it
is - a tail-latency fix that leaves the common path alone. On 4 KiB rows the
allocating population goes from 59.67 to 7.08 microseconds at p50, 133.38 to
14.46 at p99, and 451.54 to 42.33 at its worst, while the existing-page
population does not move. On 256-byte rows the same change is worth about a
third, on the one insert in sixty that pays it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The chunked page list handed back an owned `Arc` from `get`, which costs an
atomic increment and the matching decrement on drop. The link-based read is a
read path and needs the page only for the length of one call, so it paid both
for nothing.

It measured. A delete went from 665 to 751 ns, while a select by primary key -
which goes through the page directory and never touches this - did not move.
Borrowing through `with_page` puts delete back at 654 ns, and the criterion
cases either side of it are unchanged or better: insert 442 -> 86 ns, select by
primary key 21.0 -> 20.1 ns.

Found by running the benchmark suite that already existed rather than the
ad-hoc probes the rest of this work was measured with.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
It was named "bulk load", which reads as loading a table from disk. It
inserts 25,000 rows.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Collapses what was PR #102 onto the arctic, event-ledger and page-list
work already on this branch, so WorkTable carries one pull request. #97,
#99 and #100 are folded in; #58 is not, being 224 commits behind master
and already conflicted, which is its own job.

`fsx` and every persistence signature go through `nagoya::io`, so the
production path names no runtime. A persisted table can set `page_size`,
which was refused before because the seeks computed offsets from a crate
constant while the generated table threaded the configured one.

Four places where the folded branches disagreed, each resolved by keeping
both rather than either:

* `arctic` and `ps-reclaim` keep #97's raised version floors and gain the
  `default-features = false` the no_std work needs.
* `allocated_bytes` keeps the page list as #100 left it and takes
  `core::mem` from the no_std work; the `.load()` in the older branch
  belonged to a shape #100 replaced.
* `batch.rs` and `task.rs` take the `core`/`alloc` imports, plus the `Arc`
  and `Location` the no_std lists had dropped and the code still uses.
* The s3 test goes back to tokio's extension traits. It talks to a tokio
  `TcpStream` it starts itself, so the sweep that took the storage path
  off tokio should never have touched it.

`event_ledger` is new here and was written against `std`. Its bookkeeping
moves to `core` and `alloc`; only the two parts that genuinely need an
operating system are gated, the backtrace capture and reading
`WT_EVENT_LEDGER`, so without `std` the ledger is simply never enabled.

`futures/std` goes in this crate's own `std` feature rather than on the
dependency line, where a `--no-default-features` build would still have
turned it on.

    cargo test --workspace --all-targets                  933 passed
      ... --all-features                                  935 passed
      ... --features versioned-row-publication            1029 passed
    cargo clippy --workspace --all-targets [--all-features] clean
    cargo check --no-default-features                     clean
`cargo check --no-default-features` passing said only that this crate's own
source is `std`-free. Its closure was not: `cargo tree
--no-default-features -e normal -i tokio` showed tokio linked with every
feature off, because none of the uses were ever gated. That is the same
shape as the `futures-io` mistake, where a crate compiled and exported
nothing.

Twelve production uses become one:

    tokio::sync::RwLock x10     nagoya::sync::RwLock
    tokio::sync::Semaphore x4   nagoya::sync::Semaphore
    tokio::sync::Notify         nagoya::sync::Notify
    tokio::time::sleep x5       nagoya::sleep
    tokio::task::yield_now      nagoya::yield_now
    tokio::pin!                 core::pin::pin!
    tokio::select! (vacuum)     nagoya::timeout
    tokio::spawn (vacuum)       crate::runtime::background().spawn

The vacuum `select!` was a timeout wearing a combinator: two arms racing
the waits against a fallback sleep. Saying `timeout` says the intent.

**The vacuum sweep stays inside the table**, so the spawn could not be
pushed onto the caller. The table starts two background threads of its
own, lazily, gated on `std`; without `std` the module does not exist and
neither does the sweep, which is the honest outcome for a build with no
threads rather than linking a runtime to pretend otherwise.

One production use remains, the persistence engine's `tokio::spawn`. It
needs `Debug` and an `&self` `abort` on nagoya's `JoinHandle`, which
currently consumes via `cancel`, plus the `select!` at task.rs:1793. Until
it lands tokio is still linked and the `no_std` claim stays qualified.

Not yet done: the vacuum tests have not been run against this scheduling
change, and three of them still call `.abort()` on the returned handle.
WorkTable declared `parking_lot = "0.12"`, plain upstream, while every
other consumer in this house takes `parking_lot_lite_hack`. Renamed through
`package`, so no call site changes.

It pins the `feat/fair-mutex` branch rather than the published 0.12.7,
because the published one does not have what this crate uses. Its own
module comment says so: "What is gone is `Condvar`, `Once`, `FairMutex`".
`empty_link_registry` needs `FairMutex` for `op_lock` and `targeted_pages`.

**This leaves two copies of the crate in the lock** and that is not a
resting place. WorkTablesIndex 0.0.13 takes the registry 0.12.7 while this
takes the branch. Publishing `feat/fair-mutex` as 0.12.8 collapses them;
until then a build carries both.

Vanilla `parking_lot` now arrives only through tokio, so it leaves when
tokio does.
`cargo tree -e normal -i tokio` now prints nothing, with default
features and without. It printed a tree before, in both, which is what
`cargo check --no-default-features` could never have told us: that check
only proves this crate's own source is std-free, never its closure.

Three things were holding it, and the engine was the smallest of them.

The persistence worker spawned onto whatever ambient runtime the caller
happened to be on. It runs on `nagoya::runtime::background` now, one
pool per process, behind `std` so a --no-default-features build cannot
silently acquire threads. Unlike the vacuum sweep, this one genuinely
needs a thread: a flush loop drains a queue, so there is no foreground
task it could be folded into.

`wait_for_ops` raced a notify against a sleep, which is a timeout.

And the part that actually kept tokio in every consumer's graph:
`worktable!` emitted six `tokio::` paths into the crates it expands in,
making a whole runtime part of the macro's contract whether or not the
consumer ran one. Those go through `worktable::prelude` now, the same
way generated code already reached `fsx`.

Note what stops being caught: tokio's `JoinError` reported a panicking
worker, and nagoya's handle does not, because the panic propagates out
of the await instead.

KNOWN FAILING, and pre-existing: `generation_swap_requirement` now
fails racily. Not caused by this commit. At the parent commit, with
tokio untouched, changing only `#[tokio::test]` to `flavor =
"multi_thread"` reproduces it exactly. `#[tokio::test]` defaults to
current_thread, so `tokio::spawn` had been putting the worker on the
test's own thread, where it could only run at the test's await points
and every insert pushed its whole CDC event sequence before the worker
looked. 364 of this suite's tokio tests are current_thread against 34
multi_thread, so the persistence suite has been blind to concurrent
producer and consumer throughout. Fixed separately.
It came through tokio. With tokio gone it comes through indexset 0.15
behind the vanilla-index default feature, so it is absent from a
--no-default-features build and present in a normal one. The old comment
would have had a reader expect it to leave on its own.
… "nothing yet"

Two separate findings, one of which is the fix.

The fix: `a_retired_generation_releases_its_memory` and
`a_generation_can_report_what_it_holds` both used a single `DIR` const,
each removing and recreating it on entry. The harness runs tests on
parallel threads, so two tables attached to one set of files and filled
them at once. Each table's event ids start at 0, so the loser saw the
other's event 2 land on a node its own writes had already advanced to
key 28, and reported a corrupt index. One directory per test.

This is why `generation_swap_requirement` began failing when the
persistence worker moved off `tokio::spawn`: it had been running on the
test's own current_thread runtime, so the two tests' writes never
actually overlapped in time. The engine was not at fault and neither was
the move.

The second finding is real but was not the cause, and is kept because it
is proven separately. `LastEventIds` stored an id where it needed an
`Option`: event ids start at 0 and `IndexChangeEventId::default()` is
also 0, so "nothing applied yet" and "applied event 0" were the same
value. The gap check could not ask a first batch whether it followed
what came before, and exempted it. A first batch of ids 3.. is
internally gapless, so nothing else rejected it either: it would be
applied, advancing node maxima past events that had not arrived.

Two tests cover it, and they fail with the exemption restored: a first
batch skipping the head of the stream defers, and one starting at event
0 still applies rather than deadlocking on the ambiguity the exemption
existed to avoid.

The missing-page error now names the event id and the identity it
wanted. The counts alone said a lookup failed and nothing about why; the
id and key together are what distinguished these two causes.
The rebase target moved the schema language into the `worktable_dsl`
crate and made row mutation async, so the columnar work needs adapting
rather than replaying:

- `codegen/src/common/{model,parser}` are `worktable_dsl` now, so the
  columnar model and parser modules move with them and drop their
  `crate::common::` paths. `type_name` and `name` were `pub(crate)`
  helpers inside one crate and have to be `pub` across two.
- `validate_columnar_indexes` joins the other rules in
  `worktable_dsl::validate` instead of living in the macro crate.
- `worktable_dsl::schema` mirrors the macro's section dispatch, so it
  gets the `columnar_indexes` arm too; without it a valid declaration
  parsed for code generation and was rejected by the schema constant.
- `IndexError::ColumnSlotIdExhausted` needs arms in the three rollback
  paths off-tokio added. Two of them unwind less than the columnar
  patch assumed: the primary index is no longer swung before the
  secondary work, so there is nothing to roll it back to.
- `insert` is async on this tree, so the columnar tests await it.
`cargo check --no-default-features` passed on the rebase target and
failed once the columnar work landed: `src/columnar.rs` imported
`std::{collections, fmt, hash, sync}`, and the generated side-index
data type named `std::collections::BTreeSet`/`BTreeMap` and
`std::mem::{take, replace}`.

Nothing here needs std. The module goes through `alloc` and `core`,
and the generated paths go through `worktable::prelude`, which is
where the rest of the emitted code already resolves its collections.
`BTreeSet` joins `BTreeMap` in the prelude so a generated type can
name it without the consumer taking a dependency.
CI runs cargo fmt --all --check as its own job and this branch fails it. The
import in the bench test moved because the crate it names changed from tokio to
nagoya, which changes where it sorts.
A table selects an async runtime, and the choice has to survive from the
declaration to code generation as data rather than as a token the generator
re-reads. Nagoya in its locality flavor is the default, so a declaration that
says nothing gets the same table as one writing `runtime: nagoya`.

No variant exists for forte, blocking or bwos. Those are recognised by the
parser only so that naming one produces a message saying they are not
implemented, which is a different mistake from a typo and wants a different
next step.
The surface syntax is postfix, matching config's columnar(chunk_rows(32_768))
and an index's using <backend>: the flavor is an argument to the backend
rather than a second key, so bare nagoya means the default flavor rather than
meaning unset.

The diagnostics carry the weight here. forte, blocking and bwos are held in a
list of strings so that naming one says it is not implemented and names what
is, rather than reading as a typo; a flavor on tokio, an unknown flavor and a
missing backend each say what was expected.

try_parse_section_runtime reads the profile name a query section may carry.
The token after runtime there is a profile, never a backend literal, so a
backend written in that position is rejected with the reason and the place it
belongs.
update, delete and in_place may carry `runtime <profile>` between the keyword
and the colon. The name is stored unresolved on Queries because the parser
cannot resolve it: a profile is declared by runtimes! elsewhere in the crate,
so whether it exists and whether its backend matches the table's is a question
for code generation.

The three block parsers return the annotation beside their operations. None is
not a default; it means the section falls back to the table's runtime, and to
the built-in default only after that.
Both of the crate's top-level dispatches learn the arm: Schema::from_tokens,
which is the grammar as data, and check's model_of, which is what an editor
calls. A declaration the macro will compile must not be rejected by either, so
the keyword has to land in both even though no validation rule reads it yet.

Free-order rather than positional, because nothing downstream of runtime
depends on having been read first, unlike persist and partition_by.

The IR carries it so the round trip does not lose it. The emitter writes the
runtime only when it is not the default, matching how using is written back on
a column: an omitted runtime and an explicit runtime: nagoya are the same
table, so writing one in would add noise and no meaning.
The async primitives this crate uses were hardcoded to nagoya after the
move off tokio, which is a choice nobody made. `Runtime` turns it into a
type parameter so a schema can name a backend.

The surface is derived rather than invented: every method on the helper
traits has a call site in `src/` today, listed in the module comment.
`RwLock::read().await` is absent because every `.read()` in this crate is
on a `parking_lot` lock, not an async one.

Two backend deltas are normalised on nagoya's shape. `JoinHandle::cancel`
consumes the handle where tokio's `abort` borrows it, and awaiting yields
`Option<T>` where tokio yields `Result<T, JoinError>`; `TokioJoinHandle`
adapts, resuming a task panic rather than handing it back as a value.
`Semaphore::acquire` returns the permit for the same reason: nagoya's
semaphore has no closed state.

`NagoyaRt<F>` selects a pool tuning through `FlavorMarker`. Locality is
what `nagoya::runtime::background()` already runs, so it takes the shared
pool, which also keeps the private thread marker that makes `local_wakes`
do anything. Spread and throughput both turn `local_wakes` off, so a pool
started here behaves the same as one nagoya started itself.

tokio is optional and off. Getting it out of the normal dependency graph
is the work this builds on, so `cargo tree -e normal -i tokio` printing
nothing in the default feature set is part of the contract.
Half of what the macro promises is a refusal, and nothing in `tests/` could
express one: a test only runs after its crate has compiled, so every rule of
that shape was unverified. `trybuild` compiles each case in `tests/ui/` alone
and diffs the compiler's output against a committed `.stderr`.

Nine cases, each pinning a rule the macro enforces today: no primary key, an
unknown index backend, a query over a column that is not there, `using
indexset` with a variable-sized key, a non-unique congee index, a congee key
type it cannot hold, a congee index without an explicit `persist`,
`autoincrement` over `usize`, and an `in_place` query over an indexed column.

The assertion is the message, not the failure. A case that only checked "this
did not compile" would stay green while the diagnostic decayed into one that
points at the wrong line, which is the failure these rules exist to prevent.
The harness was verified by breaking it both ways before it was trusted:
making a case valid reports "Expected test case to fail to compile, but it
succeeded", and editing an expected message reports a mismatch.

Cases are listed one by one rather than globbed, so a file dropped into
`tests/ui/` is inert until someone wires it up.
@pathscale
pathscale force-pushed the feat/runtime-backends branch from e39809b to ad32149 Compare September 12, 2026 03:35
@pathscale
pathscale marked this pull request as ready for review September 12, 2026 05:37
@pathscale
pathscale force-pushed the feat/runtime-backends branch 3 times, most recently from 974755a to af316ec Compare September 12, 2026 09:28
@pathscale
pathscale force-pushed the feat/runtime-backends branch from af316ec to 0b541b9 Compare September 12, 2026 09:30
@pathscale
pathscale force-pushed the feat/runtime-backends branch from 9733fbd to 5474ea8 Compare September 12, 2026 11:58
@pathscale
pathscale merged commit 46fb7ba into master Sep 13, 2026
10 checks passed
@pathscale
pathscale deleted the feat/runtime-backends branch September 13, 2026 01:39
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