Skip to content

WorkTable 1.9.0-alpha1: off tokio and tokio::fs, columnar side indexes, and a schema-selected runtime - #105

Open
pathscale wants to merge 84 commits into
masterfrom
feat/runtime-backends
Open

WorkTable 1.9.0-alpha1: off tokio and tokio::fs, columnar side indexes, and a schema-selected runtime#105
pathscale wants to merge 84 commits into
masterfrom
feat/runtime-backends

Conversation

@pathscale

@pathscale pathscale commented Sep 9, 2026

Copy link
Copy Markdown
Owner

One linear branch onto master. 44 commits, no merge commits. It contains
everything that was previously split across #102, #103, #58 and #104, all of
which are now closed so there is one place to look.

commits was what
1-14 #102 off tokio::fs, a persisted table chooses its page size, prettytable dropped
15-19 #103 the last of tokio out, and out of the macro's contract
20-23 #58 columnar fields and clustered side indexes, rebased onto the above
24-44 new runtime backend selection, and the version bump

Nothing was dropped in folding these together. #58's two commits replay as
commits 20 and 21 with their original authorship; the two files it added under
codegen/src/common/{model,parser}/columnar.rs now live at
dsl/src/{model,parser}/columnar.rs, because the crate they were in was
extracted into worktable_dsl after #58 was written. Every other file it added
is present unchanged, including the guide and its PDF, verified by blob hash.

Version goes to 1.9.0-alpha1 on worktable and worktable_codegen.
worktable_dsl keeps 1.0.0-beta.18.1: separately published, versioned by what
its API exports rather than by what this release is called.

Verification, on the whole thing

gate result
cargo test --workspace 1204 passing, 0 failed
cargo clippy --workspace --all-targets clean
cargo fmt --all --check clean
cargo check --no-default-features builds
cargo tree -e normal -i tokio nothing, default and --no-default-features

cargo fmt --all --check was failing on feat/off-tokio and CI runs it as
its own job, so that stack had a red check waiting. Fixed in commit 24.


One linear branch, 23 commits on feat/off-tokio (#103), no merge commits.

Two pieces of work that touch the same three section-dispatch loops, so they
cannot be reviewed or merged apart without doing the same conflict twice:

  1. PR feat: add tabular + columnar side indexes #58 rebased, commits 1 to 4. Stale since 2026-08-06, CONFLICTING,
    master 268 commits ahead of it. Supersedes feat: add tabular + columnar side indexes #58.
  2. Runtime backend selection, commits 5 to 23. The DSL surface, the
    Runtime trait with two backends, named profiles, and the repository's first
    compile-fail harness.

docs/magic.md carries the full argument and every measurement behind the
design. In one line: no scheduler wins every workload shape, four attempts to
find a static rule that wins both halves of the trade each reproduced one half
exactly, so the answer is selection rather than a better default.


Part one: the columnar rebase

The conflict was not what the diff suggested

Not tokio::yield_now becoming worktable::prelude::yield_now; that had already
happened cleanly in files this never had to touch. The real structural change is
that codegen/src/common/{model,parser} was extracted into the worktable_dsl
crate, so every one of #58's schema-language files had to move crates rather
than merge. Thirteen conflicts, two needing judgement:

ColumnSlotIdExhausted rollback arms in reinsert and reinsert_cdc called
primary_index.insert(pk, old_link). On this tree the primary index is no longer
swung before the secondary-index work, so that rollback would have written a
spurious entry. Dropped in both, matching the sibling AlreadyExists arm. Three
further arms were needed in paths added after #58 was written.

The PDF was not a merge problem: feat/off-tokio has neither .gitattributes
nor output/, so both applied as clean adds. Verified by blob hash rather than
by the status line, 38ac26c71522f5712560b3523acc1673bfddc942, 27746 bytes,
byte-identical to #58's.

Two bugs found while rebasing

worktable_dsl::schema mirrors the macro's section dispatch for the editor and
checker path, and had no columnar_indexes arm. A valid declaration parsed for
code generation and was then rejected by gen_schema_const.

cargo check --no-default-features passes on feat/off-tokio and failed once
#58 landed: src/columnar.rs imported std::{collections, fmt, hash, sync} and
the generated side-index type named std::collections::BTreeSet and
std::mem::{take, replace}. Getting std out of the macro's contract is the
same goal as getting tokio out, so the module goes through alloc/core and
the emitted paths through the prelude. Its own commit, because CI may not run
that check and it would have shipped silently.

What the columnar lock actually costs

Worth stating plainly, because #58's description understates it and because it
decides which benchmarks have to be re-run.

A table that declares no columnar fields pays nothing, structurally rather
than approximately: every emitter in codegen/src/generators/columnar.rs opens
with if columns.columnar_fields.is_empty() { return quote!{} }, so no field is
declared, no lock constructed, no #columnar_dirty token interpolated.
Generated code for a non-columnar table is byte-identical to pre-#58 output.
Existing benchmark numbers stand; only columnar benchmarks need re-running.

On a table that does declare columnar fields, every insert, delete, update,
in-place update and reinsert takes the exclusive lock. "Serializes concurrent
side-index writers" understates it: side-index writers are the whole
row-mutation path. And ensure_columnar_current() takes the writer lock
unconditionally on entry, before it can observe dirty == false
(codegen/src/generators/columnar.rs:441-442), so concurrent readers of a clean
replica serialize against each other too. If this path is benchmarked, the
number that matters is row-mutation throughput on a columnar table against the
same schema without columnar.


Part two: runtime backend selection

What a schema can say

worktable!(
    name: Orders,
    persist: true,
    runtime: nagoya(spread),
    columns: { id: u64 primary_key autoincrement, symbol: String },
);

runtimes! {
    fast_local: nagoya(locality),
    wide:       nagoya(spread),
}

table.select_all().limit(10_000).runtime(wide).execute()?;

runtime is its own keyword and using is untouched: using means index
backend and only that. Omitting runtime: emits byte-for-byte what
runtime: nagoya emits, which is the no-regression guarantee for every existing
declaration.

What is here

  • RuntimeBackend / Flavor in the DSL, parsed as a free-order section, with
    forte, blocking and bwos recognised only so they can be refused by name.
    Per feat: add tabular + columnar side indexes #58's own rule that inert declarations are errors, an unimplemented
    backend fails to compile rather than being accepted and ignored.
  • codegen/src/generators/runtime_backend.rs, mirroring index_backend.rs: the
    enum becomes a concrete type token and generated code names the type.
  • The Runtime trait, derived by reading every nagoya:: path in src/ rather
    than designed in the abstract, with NagoyaRt<Locality|Spread|Throughput> and
    TokioRt. It normalises two real API deltas onto nagoya's shape:
    cancel(self) rather than abort(&self), and Option<T> rather than
    Result<T, JoinError>.
  • runtimes!, whose profiles carry a backend marker type from the first commit,
    because everything else's error messages depend on it.
  • .runtime() on builder-returning selects only. select(pk) returns a row,
    not a builder, so there is nothing to attach it to, which is deliberate: a
    spawn is 21 ns and a wake ~2,250 ns median against a ~400 ns point read, so
    the hop costs more than the operation.
  • The repository's first compile-fail harness. tests/ui did not exist and no
    trybuild dependency did either, while about half of this design's rules are
    "must fail to compile". Nine live cases pin rules that exist today; ten runtime
    cases are drafted and wired in once the errors they assert are reachable.
  • runtime_backend_suite!, plus a version that runs today against the current
    hardcoded runtime, so the suite is proven correct before it is asked to
    discriminate between backends.

tokio is a backend, not a dependency

TokioRt lives behind an off-by-default tokio-runtime feature.

$ cargo tree -e normal -i tokio
warning: nothing to print.

$ cargo tree -e normal -i tokio --features tokio-runtime
tokio v1.53.1
└── worktable

Adding a tokio backend did not put tokio back in the graph. Same under
--no-default-features.


A finding about existing coverage, unrelated to either feature

309 of 344 #[tokio::test] in this repo are current-thread. Eight call
tokio::spawn, so their tasks never overlap, and six of those have "concurrent"
or "races" in the name:

  • index_backends.rs: logical_wti_recovers_concurrent_same_row_updates,
    native_art_backends_recover_concurrent_same_row_updates
  • nonunique_arctic.rs: concurrent_deletes_leave_no_stale_links,
    non_unique_arctic_recovers_concurrent_shared_key_writes
  • upsert_guard.rs: upsert_still_serialises_concurrent_writers
  • generation_swap_requirement.rs: a_retired_generation_releases_its_memory

native_art_backends_recover_concurrent_same_row_updates uses a
tokio::sync::Barrier across eight workers, which on one thread releases them
together and then runs them one at a time. Same mechanism as 2702c06, where
two-directory corruption stayed hidden because the persistence worker ran on the
test's own current-thread runtime.

Rather than a comment that would rot, tests/worktable/multi_thread_discipline.rs
fails on any new current-thread tokio::spawn test, and also fails when a
listed one is fixed, so the allowlist can only shrink. The eight are left
unfixed deliberately: each will surface its own real failures and that is
separate work.


Verification

gate result
cargo test --workspace 1204 passing, 0 failed
cargo clippy --workspace --all-targets clean
cargo fmt --all --check clean
cargo check --no-default-features builds
cargo tree -e normal -i tokio nothing, default and --no-default-features

Baseline on feat/off-tokio is 936 default / 1094 workspace, so the 933 figure
quoted elsewhere is stale.

Two problems on feat/off-tokio itself were found and fixed as the first commit
here: cargo fmt --all --check was failing, and CI runs it as its own job, so
#103 had a red check waiting.


Not done here, on purpose

Codegen does not yet emit the TableRuntime impl, so .runtime() has no table
to bind to end to end; the section annotation is parsed and stored but not
dispatched; and Rt is not threaded through LockMap, PersistenceTask and
VacuumManager.

That last one is bigger than expected and should be decided before it is
written: DataPages holds an EmptyLinkRegistry by value
(src/in_memory/pages.rs:378) and that registry holds
Arc<nagoya::sync::RwLock<()>>, so the parameter propagates into WorkTable
itself. Roughly 55 struct and impl headers, 40 codegen emit sites. Either Rt
goes on WorkTable with a default, making it a ninth type parameter, or the
registry's vacuum lock moves to a non-runtime primitive so the in-memory half
stays runtime-free.

One trap for whoever does it: LockAcquirer implements
Deref<Target = RwLock<LockType>> and generated code calls .write().await
straight through it. Once the target is Rt::RwLock, every generated module
needs RuntimeRwLock in scope, and without it the error is "no method named
write", which points nowhere near the cause.


Version

worktable and worktable_codegen go to 1.9.0-alpha1; they move together.
worktable_dsl keeps 1.0.0-beta.18.1, because it is separately published and
its version tracks what its API exports rather than what this release is called.


no_std: one fix here, three holes that were already there

cargo check --no-default-features passing proves less than it looks. This
crate contains no worktable! invocation of its own
, so checking it says
nothing about whether the macro emits names a no_std build can resolve.

Checked properly, with a consumer taking worktable at
default-features = false and invoking the macro. Without a gate the expansion
names NagoyaRt and Locality, which a no_std prelude does not export,
because every backend needs threads. Now gated: worktable_codegen gains a
std feature forwarded from worktable, and gen_runtime_type emits nothing
without it. Same mechanism index_backend.rs already uses and documents, for
the same reason: a cfg inside the expansion would test the consuming
package's unrelated feature namespace.

Verified by removing the gate and watching both names reappear in the
consumer's error list, then restoring it.

Three names were already in that state and are not touched here.
worktable! emits ArtPersistenceKey, WorkTableVacuum and EmptyDataVacuum
unconditionally, and all three are std-only prelude exports. So the crate is
no_std-clean while the macro's expansion is not yet usable from a no_std
consumer. Worth knowing before anyone counts on that.


What this leans on in nagoya, and what nagoya is missing

No nagoya change is needed to merge this, but three gaps shaped the
implementation and are worth fixing there rather than working around here:

  • Tuning is not re-exported, so this names st3::fanout::Tuning and takes
    ps-st3 as a direct dependency to do it. Already transitive, so no new crate.
  • nagoya::Runtime has no with_tuning, so NagoyaRt<Spread> and
    NagoyaRt<Throughput> build raw st3::fanout::Pools
    (src/runtime/nagoya_rt.rs:121). Two of the three flavors bypass nagoya.
  • nagoya::task::mark_current is private, so a pool started outside nagoya
    cannot mark its threads as workers, and that marker is the only thing that
    makes local_wakes: true do anything. spread and throughput are unharmed
    because both set it false, but it means nagoya cannot currently express a
    locality-tuned pool that is not the single process-wide one.

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.
meh added 30 commits September 10, 2026 17:57
Found by a benchmark rather than by the suite, which is the point: no
existing persistence test runs readers against writers, so nothing exercised
the path where a flush batch and a select overlap.

Two panics, reliably, on a persisted table with six selects against four
upserts:

    src/persistence/space/data.rs:381  should be available as pages parsed
                                       from these ids
    async-task/src/task.rs:452         Task polled after completion

The failing lookup is `batch_data.get(&id)` over the union of the pages a
batch created and the pages it parsed back. Every id in both sets comes from
`batch_data.keys()`, so the only way it misses is for a parsed page to carry
a header id that was never asked for.

Writers alone do not reproduce it: the same test with four writers and no
readers passes. That is why it survived until now.

Marked `#[ignore]` rather than left red, so the suite keeps meaning what it
says, with the reason on the attribute so it cannot be quietly deleted.
`update runtime fast_local:` parses and codegen ignores it, so nothing could
answer whether routing reads and writes to different pools is worth anything.
`executor_for_flavor` is the primitive that section annotation would compile
to, exposed now so the idea can be measured before it is designed into the
grammar.

What it measured: at eight or more concurrent tasks, reads and writes on
separate pools beat the best single pool by 10% to 41%. But all four flavor
pairings land within 1.2% of each other, including exact reversals, so the
gain is **isolation and not affinity**. Which flavor sits on each side does
not matter; that they are different pools does. A per-section flavor name
would be a knob nobody can usefully turn.
A link can name a page more than one past last_page_id: two writers
allocating pages at once hand the queue the higher page first. Both save
paths created only the named page and moved the high-water mark to it, so
the skipped ids stayed holes of zeros that the file nonetheless spans.

A hole is not a page. The next batch touching a skipped id classified it
as already existing, parsed the hole back, read a page_id of 0 out of the
zeroed header and looked up a key the batch never held, which is the
"should be available as pages parsed from these ids" panic. Reload reads
the same junk.

create_pages_up_to writes a real header for every id through the mark and
skips the ids the caller writes itself, so the batch path pays no second
write for the pages it is about to write with rows in them.

Reduced to two calls in a unit test: the 40 minute reproduction is now
0.01s. At 10,000 rows the integration test went from failing in 158s to
passing in 0.89s.
Collection returns None when it could not assemble a gapless event stream
from the operations it grouped. The drain loop treated that as a signal to
wait and slept 500ms. It is not: the operations it needs are already
queued, and the retry itself is what makes progress possible, because each
attempt widens the page limit and after COLLECT_WHOLE_QUEUE_AFTER_ATTEMPTS
it stops grouping by page and takes everything. The sleep delayed the only
thing that could help.

Only once collection has taken the whole queue and still found a hole does
the missing event have to arrive from somewhere else, and needs_more_
operations says exactly that.

This was the persisted shutdown cost, and it was invisible because it is
data dependent. Phase timings at 10,000 rows: insert 0.06s, 20,000
concurrent ops 0.03s, verify 0.01s, close 290.11s. 290 / 0.5 is 580
retries. A run with no inverted event closed the same table in 0.89s.

The regression test drains three operations across two pages with event
ids inverted, which took 2.027s, or four sleeps.
A batch can only apply a contiguous run of the primary event stream.
Collection grouped by page instead, so validation trimmed everything past
the contiguous prefix and requeued it to be collected again next round.

When page order and event order agree the two are the same thing, which is
why sequential inserts never showed it. Random-key upserts scatter across
pages, and there almost nothing in a collection survives the trim.
Draining 4,000 operations over 40 pages cost 202,000 collections and
198,000 requeues, and the waste grew with the queue: 500 ops 0.249s, 1,000
0.934s, 2,000 3.811s, 4,000 16.104s. Doubling the count quadrupled the
time. MAX_PAGE_AMOUNT was not the knob; 4 and 16 collected the same
202,000.

The queue table now carries the operation's place in the event stream and
an index on it, so collection takes the run directly. An operation with no
primary event cannot create a gap and keeps the key of the one queued
before it, so it holds its place rather than sorting to an end.
Validation is unchanged and remains the authority: event order only makes
the common case gapless by construction, so each operation is collected
once.

4,000 operations over 40 pages: 16.104s to 0.140s, 300 batches to 8. The
same count written sequentially took 0.144s before and after, which is
what says this closed a gap rather than skipped work.

Two tests changed because the behaviour they pinned is gone.
collection_recovers_when_event_order_and_operation_order_disagree required
a deferral on the first attempt; the inversion now costs nothing and the
budget stays to prove recovery. blocked_multi_collection_applies_the_
complete_earlier_group asserted the particular split the page walk
produced; it now asserts the invariant that split existed to protect,
which is that a multi operation is never applied in half.
It was ignored because it panicked in the batch save path and, when it did
not, took 2,437 seconds. Both causes are fixed: the page gap, the retry
sleep and the quadratic collection. It now runs in 1.93 seconds.

close is bounded, because the failure mode this guards against is a drain
that takes minutes rather than one that returns an error, and an unbounded
close turns that regression into a hung suite instead of a red test.

The size is not arbitrary and the comment says so: at 5,000 rows this
passed even with the page-gap bug present, and the wall time was the tell
rather than the panic.
Bare #[tokio::test] builds a current-thread runtime, so spawned tasks
interleave only at await points on one thread and never overlap. Six of
these have "concurrent" or "races" in the name or doc comment and proved no
such thing; the two in base.rs asserted only that a spawned mutation future
is Send and joins.

This is the same blind spot that hid the persisted batch bug: the suite had
no test where concurrent readers meet concurrent writers, so it was found
by a benchmark instead.

KNOWN_CURRENT_THREAD_SPAWNERS is now empty and kept rather than deleted,
because the check is two-sided. A new offender fails against the empty
list, and an entry that stops offending also fails, so re-adding one to
silence a failure cannot be done quietly.

659 integration tests pass, and the eight were repeated five times to check
they are not merely passing once.
The test asserted three operations drain in under 400ms against the 2.027s
the bug produced. Event-ordered selection then made that fixture drain on
the first attempt, so it stopped reaching the sleep at all. Replacing the
guard with an unconditional sleep left it green.

Found by mutating the fix rather than by reading it, which is the only way
a test that cannot fail is visible. It reads like cover, so it is deleted
rather than loosened.

What replaces it measures no time: it pins which states may wait. That
distinction is the whole of the bug, since collection returns None both
while it still has moves of its own and once it has run out of them, and
the drain loop could not tell those apart. The same mutation check now
fails on an off-by-one in the predicate.

Event-ordered selection also means None now only ever means a genuine
wait, so the guard is defence in depth rather than load-bearing. The doc
comment says so, because a guard nothing exercises is worth flagging to
whoever changes selection order next.
33 bounds across 13 files sat at 20, 30, 60 or 90 seconds. The whole
integration suite runs in about 10 seconds, so none of them were ever
reached: they only decided how long a hang took to report.

A bound loose enough to need a minute to trip is not a bound. Five
seconds is still two orders of magnitude of headroom over any test here.

Only bounds that fail a test were changed. Durations that define a test's
workload, the vacuum soak and the storm windows, are left alone, because
shortening those changes what is covered rather than how fast a failure
is reported.

Every site was checked to fail hard rather than continue: the two timeout
call sites expect or panic on elapse, and the deadline loops assert after
the loop, so an elapsed bound is a red test and never a quiet pass.

659 integration tests, run three times.
A crate that takes worktable with default-features off and invokes
worktable! failed on three names the expansion emits: ArtPersistenceKey,
WorkTableVacuum and EmptyDataVacuum.

The tempting fix, exporting them from the prelude unconditionally, is
wrong and was tried first. All three need std for real reasons rather than
by grouping. EmptyDataVacuum is not empty despite the name: it holds the
data pages, lock manager, primary and secondary indexes and the
persistence sink. Ungating the vacuum module cascaded into eight errors.

So the generated items are gated instead. A #[cfg(feature = "std")] the
macro emits cannot do it, because worktable! expands in the consumer's
crate and would test the consumer's feature of that name, which is a
different flag or none at all. __wt_if_std expands here, against this
crate's features, and asks the question the macro actually needs to ask:
does the worktable I am generating against have a disk and threads?

Verified by building a no_std consumer that invokes the macro, which is
the only thing that can find this: the crate itself builds with
--no-default-features either way, because it has no worktable! of its own.

326 lib and 659 integration tests unchanged on the std path.
The rule that worktable! emits nothing a no_std consumer cannot resolve
had no verifier, and could not have one inside this crate: worktable
builds with --no-default-features whether or not the macro is sound,
because the expansion only happens where the macro is invoked. So the
verifier is a separate crate that invokes it, deliberately outside the
workspace, since a member shares feature unification with everything built
beside it and would quietly turn std back on.

It found four more holes on its first run, past the three already fixed:

  futures::future::join_all   emitted at four sites, which made that crate
                              part of the macro's contract exactly as the
                              old tokio:: paths did
  Vec, vec!                   neither in scope in a no_std consumer
  Box                         same

All four now go through worktable::prelude, which already carried Arc,
HashMap and IntoIter for this reason.

A scratchpad probe had existed and passed. It was missing #![no_std] and
carried its own futures dependency, so it proved only that worktable
resolved, not that the expansion did. That is why the verifier is committed
with #![no_std] and the narrowest possible dependency list: it is the
absence of dependencies that does the work here.
The verifier invoked worktable! and stopped there, which is a weaker claim
than it looks: a type can name itself fine and still be unusable, and a
std-only path inside insert or select would have gone unnoticed.

It now calls insert, select and select_all, so any of those reaching for
std fails the build.

Not run. Running needs an allocator and an executor a no_std target brings
itself; compiling is the claim being made.
The parser read `columnar_indexes` into the model and the `Schema` built from
it never carried the block out again, so `to_dsl` emitted nothing for it. Same
for the `columnar(...)` column modifier and the `columnar_slot_id` and
`columnar_chunk_rows` config keys.

Everything downstream of `Schema` was therefore blind to columnar: the
TypeScript emitter, given a columnar table, emitted a table that was not one,
and the JSON dump omitted it. The macro was unaffected, which is why nothing
failed to compile. This is the same shape as the missing `columnar_indexes`
arm in check(): one dispatch over the grammar knowing something another does
not.

The corpus round trip could not catch it, and this is the part worth keeping
in mind. Its property is parse(emit(parse(s))) == parse(s), stated on Schema,
so a field Schema does not model is dropped on both sides and compares equal.
It reported success on the very declarations that use columnar. Now that the
type carries the field, that same property does catch it: reverting the
emitter fails both the corpus test and the new one.

The new test states the property against the text: one minimal declaration per
top-level block, and the keyword has to come back out. Coarse on purpose, since
a check that understood the contents would be the same code as the emitter and
would agree with it for the same reasons.

Options are written only when written. `columnar` and `columnar(chunk_rows(2))`
are different declarations and the second is not the first plus a default, so
nothing is filled in on the way out.

Checked by mutation: dropping the emitted block again fails both tests.
worktable-schemas walked tests/ui, which is the trybuild corpus of
declarations the macro must refuse. It counted those nine deliberate
refusals as rejections, so the dump reported nine failures on a healthy
tree and any consumer checking `rejected == 0` could never pass. The
TypeScript emitter's corpus test is one such consumer and had been failing
on it.

The same skip, with the same reasoning, is already in
dsl/tests/round_trip.rs. This is the second dispatch over the tree that did
not know what the first one did.

142 schemas, 0 rejected, 17 templates after the change.
It was stamped "written against 1.0.0-beta.19" and carried the same drift the
TypeScript emitter did.

Two claims were wrong. It said `worktables_index` is the default for persisted
indexes; the default is `arctic`, for persisted and in-memory alike. It said
Arctic and Congee must both state `persist` explicitly; only Congee does, since
Arctic became the default and a default that forced every table to state
persistence would make the common declaration illegal.

Two features were missing entirely. Columnar fields and indexes had no section
at all, including the `columnar_slot_id` and `columnar_chunk_rows` config keys
that live on the table rather than the column. Runtime selection had none
either, which is the whole of the flavor work.

The runtime section says to take the default and why, from the measurements
rather than from taste: every flavor lands inside the run-to-run noise of every
other over 9 to 16 repetitions, and the only choice that changes anything is the
negative one, putting an injector-waking flavor on a write-heavy table, which
costs 55% to 57%. It also says to report a range rather than a median, because a
3-run reading of exactly this reversed twice under 16 runs.

Also noted, because it bites silently: arctic cannot key an optional or
variable-width column, so an index over `String optional` must name
`worktables_index`. That declaration was valid before the default changed.

The page size section needed nothing: it already knew persisted tables take a
custom size with a 512-byte floor. It was the emitter that was stale there.

PDF rebuilt.
Twelve numbered examples immediately after Getting started, covering every
clause the macro accepts, each annotated in the code rather than around it.
Code blocks 7 to 19, code lines 47 to 192, prose 180 to 169.

The reference sections that the examples now cover were cut to what they add:
constraints, defaults and the measured advice. Index backends, durability, the
filesystem and concurrency lost their restatements.

Three things the examples get right that were wrong or absent elsewhere. The
range selector is `select_by_pk_range` for the key and `select_by_<column>_range`
for an indexed column, both generated as `select_by_{i}_range`. The default
`columnar_chunk_rows` is 65,536, not 32,768. And `row_derives` takes bare
identifiers: `row_derives: [Clone, Debug]` is rejected, which is what magic.md
showed, so that is corrected here too.

Also documented because it parses and does nothing: `update runtime <profile>:`
on a query block. Better said than discovered.
Two were tracked, and both are build output: docs/wt-user-guide.pdf and the
columnar guide. Their sources are in the repository, so every edit to a guide
put a fresh 180 KB binary blob in the history, and a rewrite of the guide this
session added another.

Rebuild with:
  typst compile docs/wt-user-guide.typ docs/wt-user-guide.pdf

.DS_Store is ignored here too. It is not tracked in this repository, but it is
untracked-and-visible in several siblings and has reached at least one branch
elsewhere.
The CHANGELOG's newest entry was beta.19 while the crate is 1.9.0-alpha1, so
the release had no entry at all. crate.md is the docs.rs front page and said
nothing about no_std, columnar or runtime selection. README said nothing
either.

The user guide was updated separately and magic.md was already current, so
these three were the gap.

Two of the changed items are things a reader will otherwise discover by
having a build break. The default index backend moved from worktables_index
to arctic, and arctic cannot key an optional or variable-width column, so an
index over `String optional` that needed nothing before must now say
`using worktables_index`. And only congee still requires `persist` stated,
because arctic became the default and a default that forced every table to
state persistence would make the common declaration illegal.
worktable! buys concurrency and durability with an archived row, paged
storage behind links, a row-level lock map and change-data-capture. A
single-threaded table that never persists pays all of it for nothing, and
the pattern an application grows instead is a Vec plus a BTreeMap.

worktable_vec! generates that, from the same declaration, so the two sit
side by side on identical rows.

What it drops is the point rather than an omission: the archived row, so no
rkyv and no serialize on write; paging and links, so nothing for vacuum to
do; the lock map, since every mutation takes &mut self and that is what
makes it single-writer; CDC, which exists to feed persistence and vacuum;
and the async surface, since nothing here can queue.

What it keeps is the declaration and the method names, so a table moves
between the two by changing which macro is called. Secondary indexes become
BTreeMap<Key, Vec<usize>> over positions, and unique ones still reject a
duplicate.

A separate macro rather than a mode of worktable!, for the reason
worktable_version! is: the choice changes the generated type, and inferring
it from the absence of other keys would give two identical declarations
different concurrency guarantees. Every block it cannot honour is an error
rather than a silent no-op, and each says why: persist, queries,
columnar_indexes, config, runtime.

Measured against the hand-written baseline it replaces, 50,000 rows, insert
then point-select every key: 54.2 ms for a Vec plus BTreeMap, 69.9 ms for
the generated table, 1.29x. The table also maintains a second index the
baseline does not, so it is not expected to tie.

delete does a Vec::remove and shifts the positions rather than swap_remove.
Swapping is cheaper and reorders the table, and select_all promising
insertion order is the reason to compare against a Vec at all.

Paths go through worktable::prelude, never bare alloc:: or std::. The first
draft emitted alloc:: and did not compile in a consumer, which is the same
mistake this crate has made with tokio::, futures:: and rkyv::.
The generator hardcoded `BTreeMap`. It also parsed the column grammar
`worktable!` parses, so `using arctic` reached the model and then reached
nothing: two declarations differing only in a `using` clause expanded into
the same code. A stated choice, dropped without a word.

What was dropped was most of the point. `worktable-vec` measures the two
representations against each other over a five-field row and a million
point lookups and reports 32.34 ns/query for `Vec + BTreeMap` against 5.25
for `Vec + Arctic`. This macro's whole claim is that it costs what a `Vec`
costs, and it was giving away the index to make that claim.

So the default is now arctic, which is `worktable!`'s default, and `using`
selects as it does there: `worktables_index` for WTI, `indexset` for a plain
`BTreeMap`. `congee` is refused, because it needs the persistence
declaration this macro has none of, and a non-unique WTI index is refused
because WTI's multimap and Arctic's do not share a trait and a second code
path with no measurement behind it is not worth having. A key arctic cannot
hold is refused by name rather than falling back, which would be the same
defect in a politer form.

`using indexset` stays because there is one reason to want it: `delete`
moves every position above the hole, and a `BTreeMap` rewrites its values in
place where an ART has to reinsert each affected entry.

Measured at 200,000 rows, nine interleaved rounds, p50:

  Vec + BTreeMap                 15.1 ms
  Vec + Arctic                    6.5 ms
  worktable_vec! indexset        25.9 ms
  worktable_vec! arctic          11.6 ms
  worktable_vec! arctic, bare     8.4 ms

The generated table now beats the hand-written pattern it replaces. The bare
arm has no secondary index, which is what separates the macro's own overhead
(1.29x against the same plumbing on the same backend) from the cost of the
extra index the other generated arms carry.

Tests: seven in the generator covering each backend and each refusal, and an
integration test that deletes from the middle of a six-row table whose
non-unique posting list straddles the hole, run against both the arctic and
the indexset table. Mutating the reinsert to skip the decrement fails it.
Two answers to the same question: is the generated Vec table worse than the
crate it is meant to replace. It was, on both counts.

**The cost.** `insert` asked `contains_key` and then `insert_value`, which is
two full traversals of the index on every write, to keep its promise of
handing a duplicate row back. That guard was the entire gap. Isolated by
adding it to the hand-written arm, which is the arm that has no such promise:

  Vec + Arctic, no guard                5.9 ms
  Vec + Arctic, contains then insert    7.7 ms
  Vec + Arctic, checked insert          6.2 ms
  worktable_vec! bare                   7.7 ms

Both backends can answer and act at once, so they do: `insert_value_checked`
for the ART-backed ones, the entry API for the `BTreeMap`. The unique
secondary indexes still check separately, because a rejection from one of them
must not leave the primary key inserted, and the insert order was reordered so
it cannot. There is a test for exactly that, and putting the order back fails
it.

`alloc::collections::btree_map::Entry` is re-exported from the prelude for
this, because the expansion cannot name `alloc::` in a consumer that never
declared it.

**Congee.** It was refused on the grounds that `worktable!` demands an
explicit `persist` before accepting it. That rule exists because congee
behaves differently persisted and the author has to say which they meant.
This macro has no persistence at all, so the question was already answered:
the refusal followed the rule's name rather than its reason, and cost a
backend for nothing. `CongeeIndex` already implements `UniqueIndex`, so it
joins the same path the other two ART backends use. A non-unique congee index
is refused, since congee has no multimap.

200,000 rows, nine interleaved rounds, p50:

  Vec + BTreeMap                 13.6 ms
  Vec + Arctic                    6.1 ms
  worktable_vec! indexset        16.7 ms
  worktable_vec! arctic          10.0 ms
  worktable_vec! arctic, bare     6.4 ms
  worktable_vec! congee, bare     9.9 ms
  -Vec IndexedTable              19.9 ms
  -Vec ArcticTable                6.4 ms

The bare arm now ties `worktable-vec`'s `ArcticTable` outright, and the
indexset arm is 0.84x its `IndexedTable`. Neither is a regression against the
crate any more.
It compared two arms holding the same `BTreeMap`, so a gap was the macro. The
default backend is arctic now and the arms differ in the index too, and the
two builds disagree about the sign: optimized the generated table runs 0.64x
the baseline, unoptimized, which is how `cargo test` runs it, 1.77x. An ART's
generics are uninlined calls until the optimizer sees them.

So the bound stays where it is and the comment stops pretending. Parity is
measured in perf-benchmarks against the crate's own `ArcticTable`, optimized
and interleaved; what survives here is that the table still looks up rather
than scanning.
This comment said a table "can be moved between the two by changing which
macro is called". That is false, and it advertised the one hazard actually
worth avoiding: a swap that weakens a table's concurrency and durability
while every call site still compiles.

The signatures differ four ways. `worktable!` insert is `async fn(&self, Row)
-> Result<Pk, WorkTableError>`; here it is `fn(&mut self, Row) -> Result<(),
Row>`. Select clones a row out there and lends one here. A missing `.await`,
`&self` against `&mut self`, an owned row against a borrowed one: the swap is
rejected before it can quietly change anything.

That is what makes the divergence safe to live with, and it is not the macro
split. A second macro, or a second crate, relabels a divergence without
catching it. The guarantees differ, so the types differ, so the compiler is
the thing standing between the two.
Both emitted `{Name}Row`, so declaring the same table through `worktable!`
and `worktable_vec!` in one module failed with `the name PointRow is defined
multiple times` and no hint about which macro to rename.

That is the expected case rather than a strange one. Declaring a table both
ways is how you compare them, and a migration has both present at once. The
table was already `{Name}VecTable`, so the row follows the same prefix.

The two rows are different types with different guarantees, which is the same
principle as the differing method signatures: what keeps the divergence safe
is that it is visible in the type rather than hidden behind a shared name.

The regression test declares `Coexist` through both macros. It passes by
compiling; the body only checks that the two really are separate types
holding separate data, so a future collapse back into one name cannot slip
through.

The CHANGELOG repeated the interchangeability claim that the generator's own
comment carried until `0f18ff0`. Corrected there too.
…rate

Two changes that only make sense together, because the second needed a home.

**One macro.** `worktable_vec!` generated `<Name>VecRow` and `<Name>VecTable`,
a parallel vocabulary to learn, and one table declared both ways collided on
the row. A `storage:` key removes the collision at its source: one macro, one
`<Name>Row`, one `<Name>WorkTable`, whatever holds the rows. It is positional,
after `version` and before `persist`, because it does not describe part of the
table, it decides which table is generated.

The second macro is gone rather than deprecated. It shipped inside an
unreleased alpha and nothing outside this repository names it.

The two storages are still not interchangeable, which is the point. The
signatures differ four ways, so moving a declaration between them fails to
compile at every call site instead of quietly weakening its guarantees.

`queries`, `columnar_indexes`, `runtime`, `partition_by` and `config` are
refused with an error naming what to use instead. `runtime: nagoya(locality)`
on a synchronous table is a reasonable thing to write and a meaningless thing
to have accepted.

**Hydrate.** `storage: vec` with `persist: true` generates `unload` and
`load`. Rows go out as 16 KiB pages, each standing alone, so damage is local
to one page and an append does not rewrite the file. Every page carries a
CRC-32 of its body and a row directory; a row-type fingerprint refuses another
table's file rather than reading it as debris.

The indexes are not written. They are positions into the row vector, so they
are rebuilt on load, which is cheaper than writing, validating and keeping
them consistent with the rows on disk.

The codec is ported from `worktable-vec`'s `hydrate`, where the format was
designed and where its own tests still live. Reproduced rather than depended
on, because a dependency inverts the direction this is meant to travel.

The files are **not** interchangeable with that crate's. It stores
`Vec<(K, V)>` because its value has no key in it; a row here already carries
its primary key as a column, so this stores `Vec<Row>` rather than writing the
key twice. Different archives, different fingerprints, and the fingerprint is
what makes that a refusal instead of silent misreading.

rkyv's derives are emitted only under `persist: true` and go through
`worktable::prelude::rkyv` with `#[rkyv(crate = ..)]`, so a consumer does not
have to declare rkyv. The paged path still emits a bare `rkyv::` and is the
remaining half of that leak.

`storage` reaches the canonical schema and the emitter, with `serde(default)`
paged so a schema written before the field reads back as the table it was.
Paged is never written out, since it would add a line to every schema in the
corpus to say nothing.

Five new round-trip tests: a table with a deleted row, an empty table, a
flipped bit, a truncated file, another row type's file, and rows spanning many
pages. Mutating away the CRC check, the fingerprint check, or the index
rebuild fails one each.

The corruption test caught a flaw in itself first: it flipped byte 64, which
for a one-row page is in the zero padding outside what the checksum covers, so
the file loaded cleanly. It reads the body length from the header now.

Parity is unchanged by the fold. 200,000 rows, nine interleaved rounds, p50:
6.6 ms bare against `-Vec`'s `ArcticTable` at 6.5, and 13.6 for the
hand-written `Vec` plus `BTreeMap`.
Four of the five things `worktable-vec` had and this did not. The fifth is
`AtomicKeyTable`, which is a different table rather than a missing method.

`with_capacity`, `capacity` and `reserve` size the row vector. Only the rows:
the indexes are trees and have no equivalent knob, so an accurate capacity
removes the row vector's growth entirely and leaves theirs alone.

`iter` and `into_rows` hand the rows out. `into_rows` drops the indexes rather
than returning them, because they are positions into the vector it is giving
away and mean nothing without it.

`update` is the one with a decision in it. `worktable-vec` hands out
`&mut (K, V)`, but only from `LinearTable`, which has no indexes to
invalidate. Doing that here would let a caller change an indexed column and
leave the index pointing at a key the row no longer has: silent, and the row
is then unfindable under either key. So it takes a closure, copies the key
columns before running it, and repairs whatever moved. Copies the key columns,
not the row.

A re-key onto an occupied key panics, after restoring the row. The alternative
is two rows under one key, and there is no return value a caller could
sensibly ignore.

Mutating away the index repair fails the first test; mutating away the
collision check fails the second.
Applied from `worktable-pr105-source-fixes.patch` in ~/code/patch, whose
author states that nothing was compiled or run. Its base is 27 commits behind
this branch, so it needed a three-way merge, and two of the twenty-nine files
conflicted.

It is compiled and run now: 326 lib, 675 integration, 81 codegen and 79 DSL
tests pass, and `--no-default-features` still checks.

Taken:

A shared/exclusive publication gate around columnar maintenance. Insert,
reinsert and delete hold the shared side through publication; a dirty rebuild
holds the exclusive side, so a rebuild can no longer snapshot the gap between
secondary maintenance and primary publication. Clean scans skip the exclusive
lock entirely.

Node and slot counts capped at 65,535, so a large persisted page cannot
overflow the `u16` the format stores them in. Byte strides stay independently
configurable.

CDC deletion clones secondary keys on columnar tables before passing ownership
to removal. Non-columnar tables keep the move-only path, so nothing pays for
it that does not need it.

Each persistence engine gets a private worker, with a scope guard shutting its
pool down on completion or drop, so blocking file calls stop occupying compute
workers. One thread per engine, not per process; foreground snapshot loading
is still blocking.

Snapshot loaders open primary, secondary and data files read-only rather than
asking for write permission. The TOC fallback compares file length against the
configured stride rather than the global page size. A disabled event ledger no
longer allocates the per-operation ID vector.

Not taken: the canonical schema's columnar metadata.

That finding is real and was already fixed here, in `84fe98e`, which is inside
the 27 commits the patch's base predates. The patch carries a second design
for it, nesting the metadata under `schema.columnar`, where this branch has it
flat as `schema.columnar_indexes` and a `columnar` field per column. Both
answer the finding. Taking the patch's would churn the emitter, the schema
corpus and the TypeScript emitter that already speak the flat one, for no
behaviour. So its `dsl/src/schema`, `check.rs`, `validate.rs`, `model/columnar.rs`
and `dsl/tests/columnar_schema.rs` changes are dropped.

Two things fixed while folding:

`row_publication(&self.indexes)` does not compile. `indexes` is an
`Arc<SecondaryIndexes>` and the trait is implemented for the inner type;
fully-qualified call syntax does not auto-deref, so eight call sites needed
`&*`. This is what "no compilation was performed" costs.

The DSL requirement was pinned with `=1.0.0-beta.19`. Relaxed to a caret. An
exact pin makes every dependent naming any other beta.19.x unresolvable, and
nothing here needs that.
`worktable!` emitted bare `rkyv::`, `eyre::`, `uuid::` and `derive_more`
paths. The expansion lands in the consumer's crate, so every one of those had
to be a dependency there, declared by someone who never wrote the name. A
crate that depended only on `worktable` could not compile a table
declaration.

88 emission sites. `rkyv`, `eyre` and `uuid` are re-exported from the prelude
and the emitted paths go through it. rkyv needs both halves: the derive path
and `#[rkyv(crate = worktable::prelude::rkyv)]`, because the derive generates
`::rkyv::` internally and redirecting the path alone does not reach that.

`derive_more` is gone rather than redirected. It has no crate-path option, and
what it was deriving is six impls:

  `Display` on the AvailableIndexes enum, whose variants are all fieldless, so
  it delegates to `Debug`, which prints exactly the variant name it printed.

  `From` on the AvailableTypes enum, one newtype variant per column type.

  `From` and `Into` on the generated primary-key newtype, in both the single
  and composite shapes.

Writing those out is cheaper than a dependency that every consumer inherits.
The crate stays in this crate's own manifest, where it is this crate's
business.

Verified by `mixprobe`, a crate outside the workspace whose only dependency is
`worktable`, declaring a paged table and a Vec table in one module. It failed
with `cannot find module or crate rkyv` before and prints its assertion now.

326 lib, 675 integration and 81 codegen tests pass, and
`--no-default-features` still checks.
`storage: vec` was a key I invented while drafting an options menu, not one I
chose. A flag is the shape `persist:` already has, adds no noun to explain,
and the argument I made for the key does not survive being checked.

That argument was that booleans need an exclusivity matrix, because vec and
persist were legal together while vec and atomic would not be. But vec and
persist were legal only because I had made them so, hours earlier, when I
wired hydrate to `persist: true`. Forbidding it collapses the matrix to one
uniform rule, and the key stops paying for anything.

The other argument was compile cost: that always deriving rkyv, with no
`persist` to gate it, would be expensive. Measured at 20 tables of five
columns each, interleaved three times: 305 ms without the derives, 470 ms
with. About 8 ms a table. Real, consistent, and nowhere near enough to justify
a key. So they are unconditional now.

What survives from the key is the model. The grammar is a flag; `Storage`
stays an enum, because everything past the parser crosses a boundary where two
flags could disagree. The schema is serialized, round-tripped through
`to_dsl`, and handed to a TypeScript emitter, and serde enforces no
cross-field invariant for anyone. One enum cannot say two things, so the
illegal combination stops existing at the parser instead of being re-checked
by every consumer.

`persist` is refused beside `vec: true` with an error saying why: there is no
engine, no task and no flush, so the table pays no synchronisation for
durability nobody asked for. `unload` and `load` are still generated, because
they are the manual path, and `load` rebuilds the indexes, which a free
function over `&[Row]` cannot.

Also removes an import left stale by dropping derive_more.
Measured today, because nothing in either benchmark suite covers it and the
defaults are wrong in a way that stays green.

A linear scan beats the index below 32 rows: 1.0 ns against 9.8 at four rows,
4.4 against 11.2 at thirty-two. Above 64 arctic wins and never gives the lead
back, reaching 1,333x at 131,072 rows, and its lookup stays flat at about 9 ns
throughout.

Memory crosses somewhere else entirely. At 64 rows arctic holds 601 bytes for
every 24-byte row, 18x what either std map holds. It does not settle until
about a thousand rows, and past 131,072 it is 16 B/row, 2.1x smaller than
either. Arctic is the right default for a large table on both axes and the
worst of the three for a small one.

Also recorded: `HashMap` is 6.0 ns a lookup against arctic's 10.3 and
`BTreeMap`'s 26.6, so arctic is 2.6x better than `BTreeMap` at equal capability
and 1.7x worse than a hash that cannot range-scan. There is no hash-shaped
backend in the grammar.

An adaptive prototype that skips the index below a threshold costs nothing
above it, 1.00x at four large sizes, and wins 1.9x to 7x below 32 rows. It also
caught its own threshold being set at 64, where arctic has already won and the
adaptive table is 1.40x slower.

Then the production half. web3.trading's `OrderBook` is two or three rows per
partition across up to 2,000 partitions, 832-byte rows, read and written ten
thousand times a second. A partition costs 28,459 bytes to hold 2,496 bytes of
rows, and an **empty** partition costs 28,395: the rows add sixty-four bytes.
`PartitionSet` holds a whole table per partition, so the apparatus is what is
being paid for, two thousand times.

At 2,000 partitions that is 55 MB to store 4.9 MB. Reading is 48 ns, which at
10k reads and 10k writes a second is a tenth of a percent of a core, so speed
is not the problem.

`config: { page_size: 4096 }` cuts it to 16,171 bytes a partition, 30.8 MB,
today, with no change to this crate: three 832-byte rows leave a 16 KB page 84%
empty.

The conclusion the abstract half reached, that adaptive indexing is a
micro-optimisation with no workload behind it, was half wrong. There is a
workload. The index is simply the smallest of the three things it pays for.
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