WorkTable 1.9.0-alpha1: v3 persistence, integrated Vec tables and runtime selection - #105
Merged
Merged
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.
pathscale
force-pushed
the
feat/runtime-backends
branch
from
September 12, 2026 03:35
e39809b to
ad32149
Compare
added 6 commits
September 12, 2026 11:04
pathscale
marked this pull request as ready for review
September 12, 2026 05:37
pathscale
force-pushed
the
feat/runtime-backends
branch
3 times, most recently
from
September 12, 2026 09:28
974755a to
af316ec
Compare
pathscale
force-pushed
the
feat/runtime-backends
branch
from
September 12, 2026 09:30
af316ec to
0b541b9
Compare
pathscale
force-pushed
the
feat/runtime-backends
branch
from
September 12, 2026 11:58
9733fbd to
5474ea8
Compare
added 6 commits
September 12, 2026 21:36
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.
WorkTable 1.9.0-alpha1 consolidates runtime selection, columnar layouts and Vec tables with the coordinated DataBucket v3 format cutover. This is the single release PR for WorkTable.
Resulting behavior
Generated tables default to Nagoya Locality with four empty search rounds of 128 spin hints before host parking. Existing flavor numbers and grammar stay unchanged. LowLatency remains an explicit longer-spin option; Tokio is an optional backend. Nagoya uses the broad
^0.1requirement; the resolved 0.1.2 release carries the reviewed runtime fixes.WorkTablesIndex now uses standard slice search by default. At the default 1,024-key node width, the isolated matrix measured randomized lookup at 45.4 ns versus 101.9 ns for predictable search. The alternating table A/B retained predictable search as an explicit write-heavy option: it reduced persisted insert-and-drain medians by about 12-14%, while standard search was faster for stable four-client in-memory insertion. The feature name describes the algorithm and does not require Rust std.
Query profiles execute through owned, cancellable tasks. Select builders materialize borrowed iteration and predicates on the caller, then send owned filtering, sorting, offset and limit work to the selected pool. An explicit profile requires execute_async; synchronous execute reports RuntimeRequiresAsync. Runtime-annotated mutations require an Arc table receiver and owned Send/static arguments. Unannotated calls retain borrowed signatures. Profiles may select another flavor within the same backend family. Dropping a waiter cancels pending work; synchronous work already running can finish, and cancellation is not rollback.
Vec storage and its indexes are integrated into WorkTable. The review fixes secondary-key collisions and panic-safe candidate edits, snapshot page identity/append validation, stale unique-index locations during row relocation, generated index capacity and columnar/layout emission. Columnar fields, clustered side indexes, partitioning and Rust callsite tuning are covered in the canonical guide.
Ordinary persisted pages carry a live-row directory and integrity checksum. Independent scans find live rows without an index, including after deletion and variable-length relocation. Vacuum maintains the directory, clears reclaimed pages before advertising reuse and restores free-range ownership on reopen. vacuum_with_pacing extends the Rust callsite API without new grammar.
Final correctness review
A released collision could let two readers of the same archived row use separate lock entries, allowing a writer to miss one reader. The first mutex-based repair was sound but serialized random reads. The final implementation maps offsets through a full-bit mix onto 256 stable reader/writer stripes per data page. Colliding readers may proceed together; a colliding write waits conservatively. A generated in-place callback may synchronously read a different row on its owned stripe; same-row reads and nested writes return
CellLockReentry. Native regressions and two bounded Loom models cover the reproduced interleaving, callback collision and read/write exclusion. Stripe state and writer ownership use 3 KiB per 16 KiB page on 64-bit hosts. This storage is runtime-only and does not change the archived image or grammar.The vacuum wake regression now exercises the complete wake-to-sweep path with the same operation-wide bulk guard used by ranged deletes. The prior test stopped at a wall-clock sampling helper and could fail when a loaded runner starved the delete task for one settle interval, even though the production sweep still waited at its activity gate. The corrected end-to-end case passed 20 consecutive local repetitions and the exact-head versioned-publication lane. Runtime code is unchanged by this test correction.
Distinct live lock dependencies could also collapse when diagnostic u16 labels repeated. Equality and hashing now use the existing shared flag allocation, while labels remain diagnostic. Explicit unlock performs cleanup once. Regressions reproduce both defects against the prior implementation.
The allocation regression test had a separate accounting race: parallel tests reset and charged one process-global counter. Thread-local measurement regions now isolate each test and restore disabled accounting after panic. Existing memory limits remain unchanged. This changes test accounting, not table storage.
Breaking data cutover
WorkTable and DataBucket move from page format v2 to v3 together. Readers reject incompatible stores without rewriting or deleting them. Explicitly recreate regenerable data; retained data requires application-specific conversion with the old reader. Page-size changes also change layout. Vec snapshots use a separate container and are not interchangeable with ordinary space files.
Validation
The full local CI sequence passed on the page-extent implementation: default and versioned workspace tests, all-feature builds/tests, formatting, default/all-feature workspace Clippy with warnings denied, the two bounded cell-lock models, generated no_std consumer tests, Windows GNU no_std, and three portable WTI search graphs. After the final buffered-scanner refactor, exact-head S3 Clippy, all six manifest/extent unit tests and the stateful transfer/restore integration test passed again at 4e01926. All nine exact-head GitHub CI lanes pass.
A concurrent persisted insert/delete/reuse stress test exposed an order inversion: primary-index events are assigned during the mutation, while UUID operation ids are created later. Durable row coalescing now follows primary-event order, matching index replay. The prior head reproduced a key/row mismatch on iteration 15; the fix passes 50 consecutive fresh reopen cycles and the focused batch regression.
The final local checks use Rust 1.97.1, RUST_TEST_THREADS=2 and WT_RUNTIME_WORKERS=2, retaining each test's internal concurrency. GitHub uses the newest stable toolchain. The exact-head remote run passes the same formatting, duplicate-graph, default/all-feature Clippy, Loom, no_std, default, versioned-publication and all-feature gates against the published dependency graph. The earlier five-second shutdown failure under heavy host load is preserved in the release audit; the final all-feature run passes without increasing that timeout.
The database-wide storage addition passes DataBucket's 79 library tests, workspace all-feature tests, all-target all-feature Clippy, and all three no_std gates. WorkTable passes all-target all-feature Clippy, both no_std consumer checks, and 732 non-ignored all-feature integration tests. The local trybuild subprocess must resolve the published DataBucket 0.7.1 feature graph, so that final lane is intentionally gated on DataBucket #77 merging and publishing before this PR.
The complete no_std target graph builds with Rust std removed from its sysroot, retaining libc/native OS services and host proc macros. Checks pass on macOS ARM64, Windows GNU x64 and Linux musl ARM64. An isolated generated-table consumer runs CRUD, concurrent page growth and ordered UUID v7 generation. Three portable WTI search combinations pass; std-only optional features declare that requirement explicitly. Published psc-nanoid 3.2.0 replaces the temporary NanoID patch. Additional cross-target/feature gates are part of both CI and the local script.
The archived-row lock was remeasured with a controlled same-source A/B: identical
f5eb6f0source and dependency graph, changing only the lock implementation. The exact-row registry repaired correctness but retained only 85.3%, 51.6% and 35.4% of the stripe baseline at 1, 4 and 12 clients; its twelve-client median was 48.50 million reads/s. The final owner-aware stripes measure 99.2%, 99.5% and 100.5% of the plain-stripe baseline at 1, 4 and 12 clients, with the twelve-client median moving from 154.27 to 155.99 million reads/s. At twelve clients, update50 changes by +4.4%, +7.5% and +2.4%, while read-modify-write changes by -0.8%, +6.6% and +3.5% across balanced, almost_tokio and Tokio. The final source adds only a zero-sized!Sendguard marker and lint cleanup after the measured implementation, so it has the same runtime code.Historical results retain their original source revisions. The clean final evidence in https://github.com/pathscale/perf-benchmarks/pull/3 is one consolidated 889-metric report containing the repeated supplement, isolated search matrix, structural/logical persistence matrix and alternating default-search A/B. It passes self-validation with unique metric identities. No measurements ran alongside compiler activity or native UI QA.
The recommended S3 path now uses one database-wide DataBucket storage domain and one real generated WorkTable system catalog.
database_s3_persistence!(TableName)is a Rust callsite extension, with no table grammar change. One cloneableS3Databaseis shared across table engines. DataBucket stages content-addressed data pages and stable 16 KiB index chunks, WorkTable prepares the generated catalog checkpoint, and a conditional 160-byte head publishes the generation only after both are durable. Applications receive read-only catalog views for tables, pages, indexes, and replication state.The table mutation path only enqueues work. Local persistence, catalog accounting, hashing, blocking HTTP, conditional publication, and restore run on the private one-worker persistence runtime. The stateful adapter fixture covers a generated-table insert, complete background commit, local deletion, catalog restart, bounded page reads, atomic file rebuild, and row lookup. It measures 33,016 uploaded bytes for one page with a small catalog and 49,544 bytes after the catalog grows beyond one page. The 4 MiB target remains a coalescing ceiling rather than a mutation floor. The existing per-table
s3_sync_persistence!engine remains available for old manifests during transition.The canonical manuals are docs/wt-user-guide.typ and docs/why-worktables.typ, with rendered PDFs. The user guide covers declaration features and Rust callsites, including vacuum pacing and the search-policy tradeoff. Markdown entry points link to canonical sources. The offline gate validates the S3 protocol and transfer shape against a stateful local object service; a configured support.cafe/Tigris smoke remains part of the later application cutover rather than a correctness substitute.
Release order
WorkTablesIndex 0.0.15, parking_lot_lite_hack 0.12.8, Congee 0.4.6, data_bucket_derive 0.3.18, ps-st3 0.6.2, Nagoya 0.1.2, psc-nanoid 3.2.0 and Arctic 0.1.12 are available. DataBucket #77 must merge and publish 0.7.1 first because it supplies the storage-domain and
s3-supportAPIs. WorkTable #105 follows; its CI publishes worktable_dsl, codegen and WorkTable in dependency order. Production cutover still needs the application's explicit data policy.Storage architecture and remaining spill work
The DataBucket storage-domain contract, S3 adapter and generated system catalog are implemented here and in DataBucket #77. The Upstash and hybrid dual-write protocols plus the bounded WorkTable partial-hydration model remain specified in docs/remote-page-stores-and-partial-hydration.md. The catalog and bounded
read_pagepath are the foundation for partial hydration; generated queries do not yet evict or fault row pages, so the document does not claim spill mode is complete.The deployed provider gate selects Tigris for production page-segment storage and keeps 4 MiB as the coalescing target rather than a minimum. Wasabi passed twice with 29-33 ms median page PUT, 11-14 ms median 256 KiB range GET, 275-296 concurrent page writes/s and safe conditional heads, but its 90-day overwrite/deletion charge is a poor fit for database churn. A pure-Singapore Bunny S3 zone significantly outperformed Tigris for point and range reads while Tigris retained stronger sustained writes, so Bunny remains measured evidence rather than the selected backend. Cloudflare R2 preserved exact reads and safe conditional heads, but its 49.73 ms range median, 369.15 ms mean 4 MiB PUT and 90.90 Mbit/s write rate miss the performance gate. Upstash's roughly 216 ms request floor disqualifies synchronous per-page persistence. The reusable Rust drivers are in DataBucket #77, and the consolidated evidence is in perf-benchmarks #3.