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
Open
WorkTable 1.9.0-alpha1: off tokio and tokio::fs, columnar side indexes, and a schema-selected runtime#105pathscale wants to merge 84 commits into
pathscale wants to merge 84 commits into
Conversation
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.
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
One linear branch onto
master. 44 commits, no merge commits. It containseverything that was previously split across #102, #103, #58 and #104, all of
which are now closed so there is one place to look.
tokio::fs, a persisted table chooses its page size, prettytable droppedNothing 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.rsnow live atdsl/src/{model,parser}/columnar.rs, because the crate they were in wasextracted into
worktable_dslafter #58 was written. Every other file it addedis present unchanged, including the guide and its PDF, verified by blob hash.
Version goes to 1.9.0-alpha1 on
worktableandworktable_codegen.worktable_dslkeeps1.0.0-beta.18.1: separately published, versioned by whatits API exports rather than by what this release is called.
Verification, on the whole thing
cargo test --workspacecargo clippy --workspace --all-targetscargo fmt --all --checkcargo check --no-default-featurescargo tree -e normal -i tokio--no-default-featurescargo fmt --all --checkwas failing onfeat/off-tokioand CI runs it asits 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:
master 268 commits ahead of it. Supersedes feat: add tabular + columnar side indexes #58.
Runtimetrait with two backends, named profiles, and the repository's firstcompile-fail harness.
docs/magic.mdcarries the full argument and every measurement behind thedesign. 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_nowbecomingworktable::prelude::yield_now; that had alreadyhappened cleanly in files this never had to touch. The real structural change is
that
codegen/src/common/{model,parser}was extracted into theworktable_dslcrate, so every one of #58's schema-language files had to move crates rather
than merge. Thirteen conflicts, two needing judgement:
ColumnSlotIdExhaustedrollback arms inreinsertandreinsert_cdccalledprimary_index.insert(pk, old_link). On this tree the primary index is no longerswung before the secondary-index work, so that rollback would have written a
spurious entry. Dropped in both, matching the sibling
AlreadyExistsarm. Threefurther arms were needed in paths added after #58 was written.
The PDF was not a merge problem:
feat/off-tokiohas neither.gitattributesnor
output/, so both applied as clean adds. Verified by blob hash rather thanby the status line,
38ac26c71522f5712560b3523acc1673bfddc942, 27746 bytes,byte-identical to #58's.
Two bugs found while rebasing
worktable_dsl::schemamirrors the macro's section dispatch for the editor andchecker path, and had no
columnar_indexesarm. A valid declaration parsed forcode generation and was then rejected by
gen_schema_const.cargo check --no-default-featurespasses onfeat/off-tokioand failed once#58 landed:
src/columnar.rsimportedstd::{collections, fmt, hash, sync}andthe generated side-index type named
std::collections::BTreeSetandstd::mem::{take, replace}. Gettingstdout of the macro's contract is thesame goal as getting tokio out, so the module goes through
alloc/coreandthe 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.rsopenswith
if columns.columnar_fields.is_empty() { return quote!{} }, so no field isdeclared, no lock constructed, no
#columnar_dirtytoken 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 lockunconditionally on entry, before it can observe
dirty == false(
codegen/src/generators/columnar.rs:441-442), so concurrent readers of a cleanreplica 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
runtimeis its own keyword andusingis untouched:usingmeans indexbackend and only that. Omitting
runtime:emits byte-for-byte whatruntime: nagoyaemits, which is the no-regression guarantee for every existingdeclaration.
What is here
RuntimeBackend/Flavorin the DSL, parsed as a free-order section, withforte,blockingandbwosrecognised 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, mirroringindex_backend.rs: theenum becomes a concrete type token and generated code names the type.
Runtimetrait, derived by reading everynagoya::path insrc/ratherthan designed in the abstract, with
NagoyaRt<Locality|Spread|Throughput>andTokioRt. It normalises two real API deltas onto nagoya's shape:cancel(self)rather thanabort(&self), andOption<T>rather thanResult<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.
tests/uidid not exist and notrybuilddependency 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 currenthardcoded runtime, so the suite is proven correct before it is asked to
discriminate between backends.
tokio is a backend, not a dependency
TokioRtlives behind an off-by-defaulttokio-runtimefeature.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 calltokio::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_updatesnonunique_arctic.rs:concurrent_deletes_leave_no_stale_links,non_unique_arctic_recovers_concurrent_shared_key_writesupsert_guard.rs:upsert_still_serialises_concurrent_writersgeneration_swap_requirement.rs:a_retired_generation_releases_its_memorynative_art_backends_recover_concurrent_same_row_updatesuses atokio::sync::Barrieracross eight workers, which on one thread releases themtogether 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.rsfails on any new current-thread
tokio::spawntest, and also fails when alisted 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
cargo test --workspacecargo clippy --workspace --all-targetscargo fmt --all --checkcargo check --no-default-featurescargo tree -e normal -i tokio--no-default-featuresBaseline on
feat/off-tokiois 936 default / 1094 workspace, so the 933 figurequoted elsewhere is stale.
Two problems on
feat/off-tokioitself were found and fixed as the first commithere:
cargo fmt --all --checkwas 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
TableRuntimeimpl, so.runtime()has no tableto bind to end to end; the section annotation is parsed and stored but not
dispatched; and
Rtis not threaded throughLockMap,PersistenceTaskandVacuumManager.That last one is bigger than expected and should be decided before it is
written:
DataPagesholds anEmptyLinkRegistryby value(
src/in_memory/pages.rs:378) and that registry holdsArc<nagoya::sync::RwLock<()>>, so the parameter propagates intoWorkTableitself. Roughly 55 struct and impl headers, 40 codegen emit sites. Either
Rtgoes on
WorkTablewith a default, making it a ninth type parameter, or theregistry's vacuum lock moves to a non-runtime primitive so the in-memory half
stays runtime-free.
One trap for whoever does it:
LockAcquirerimplementsDeref<Target = RwLock<LockType>>and generated code calls.write().awaitstraight through it. Once the target is
Rt::RwLock, every generated moduleneeds
RuntimeRwLockin scope, and without it the error is "no method namedwrite", which points nowhere near the cause.Version
worktableandworktable_codegengo to 1.9.0-alpha1; they move together.worktable_dslkeeps1.0.0-beta.18.1, because it is separately published andits 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-featurespassing proves less than it looks. Thiscrate contains no
worktable!invocation of its own, so checking it saysnothing about whether the macro emits names a
no_stdbuild can resolve.Checked properly, with a consumer taking
worktableatdefault-features = falseand invoking the macro. Without a gate the expansionnames
NagoyaRtandLocality, which ano_stdprelude does not export,because every backend needs threads. Now gated:
worktable_codegengains astdfeature forwarded fromworktable, andgen_runtime_typeemits nothingwithout it. Same mechanism
index_backend.rsalready uses and documents, forthe same reason: a
cfginside the expansion would test the consumingpackage'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!emitsArtPersistenceKey,WorkTableVacuumandEmptyDataVacuumunconditionally, and all three are
std-only prelude exports. So the crate isno_std-clean while the macro's expansion is not yet usable from ano_stdconsumer. 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:
Tuningis not re-exported, so this namesst3::fanout::Tuningand takesps-st3as a direct dependency to do it. Already transitive, so no new crate.nagoya::Runtimehas nowith_tuning, soNagoyaRt<Spread>andNagoyaRt<Throughput>build rawst3::fanout::Pools(
src/runtime/nagoya_rt.rs:121). Two of the three flavors bypass nagoya.nagoya::task::mark_currentis private, so a pool started outside nagoyacannot mark its threads as workers, and that marker is the only thing that
makes
local_wakes: truedo anything.spreadandthroughputare unharmedbecause both set it false, but it means nagoya cannot currently express a
locality-tuned pool that is not the single process-wide one.