Skip to content

Release WorkTable 1.10.0 beta1 - #106

Merged
pathscale merged 45 commits into
masterfrom
fix/heap-initialize-pages
Sep 16, 2026
Merged

pathscale merged 45 commits into
masterfrom
fix/heap-initialize-pages

Conversation

@pathscale

@pathscale pathscale commented Sep 13, 2026

Copy link
Copy Markdown
Owner

WorkTable 1.10.0-beta1 brings the frozen LinearTable API into the main crate, makes persisted-table drop finish queued writes before returning, and removes the generated startup stack overflow seen when AgentCode loads eight tables on Tokio's normal worker stack. Busy drop now also contains a persistence worker panic instead of allowing it to escape a destructor; the existing completion guard records the same terminal lifecycle failure for observers.

The startup path now initializes page storage in its final Arc allocation and pins nested load futures. The generated load future shrank from 17,832 bytes to 2,392 bytes. Existing-page steady-state reads, updates, read-modify-write operations, scheduler dispatch, and explicit-drain behavior do not cross the changed code. PathDB's indexed LinearTable lookup compiled to identical AArch64 instructions before and after the API move. Fresh construction, inserts that allocate a new page, and startup/reopen measurements do cross the changed allocation/load path; their focused beta1 rerun is tracked in perf-benchmarks PR #4 rather than relabeling older source data.

The release also updates the user guide, Why WorkTables document, changelog, and package versions to 1.10.0-beta1.

The beta API now uses standard mutation vocabulary. replace(row) replaces a complete existing row. Declared update: operations generate typed selected-column calls such as update_by_id(id, CustomerPaymentColumns::STATUS, value); declared update_in_place: operations generate update_in_place_by_id(id, CustomerPaymentColumns::STATUS, closure). Multi-column declarations expose one table-scoped FIELD_AND_FIELD selector and take their generated query struct, preserving the declared atomic field set. Selector dispatch is sealed, allocation-free, and available only for declared selector/lookup combinations. The same surface is generated for in-memory, persisted, Vec, and dense table shapes, with each shape retaining its existing async/result contract.

Validation on current head 7299aa9:

  • Opaque archived fields and Option<String> now rebuild the complete row under its full lock instead of moving relative pointers out of a temporary query buffer. Same-size unindexed updates retain their slot; indexed or resized updates reinsert. Persisted same-slot writes enqueue the replacement slot bytes before returning.

  • cargo test -p worktable_codegen opaque: 3 passed, covering memory/persistence emission, indexed opaque updates, and conservative scalar/UUID/optional classification

  • cargo test --test mod update_in_place_unsized: 15 passed across WTI, Congee, and Arctic, including opaque-only full-row, custom-wrapper, and optional-string updates

  • cargo test --test mod targeted_update_of_string_wrapper_survives_read_and_reload: passed; same-slot WAL bytes and changed-size reinsert both survived cold reopen

  • cargo clippy --workspace --all-targets -- -D warnings: passed

  • sh scripts/check-no-std.sh -p worktable --lib --no-default-features: passed

  • Exact before/after performance: runtime-flavours, wt-owned-runtime, wt-tunables, and wt-feature-smoke are byte-identical to parent 0a46d35; 50,000/200,000-row reopen pairs stay within about 2%. The isolated pointer-bearing wrapper repair costs 546.8 ns/row versus the corrupt legacy path at 206.2 ns/row. A real persisted Pays CustomerPayment status transition exposed the conservative custom-type cost: 8.286 us/payment before, 14.512 us/payment after the emitter repair (1.75x), and 8.241 us/payment after Pays PR #143 moved its fixed-size enum to the typed update_in_place_by_id(..., CustomerPaymentColumns::STATUS, closure) callsite. The final Pays result is within 0.5 percent of the pre-fix median.

  • cargo test --lib persistence::task::lifecycle_tests -- --nocapture: 18 passed, including deterministic busy-drop/panicking-worker coverage

  • cargo fmt --all -- --check: passed

  • cargo clippy --lib --tests -- -D warnings: passed

  • sh scripts/check-no-std.sh -p worktable --lib --no-default-features: passed with a sysroot that has std removed

  • CARGO_PROFILE_TEST_DEBUG=0 cargo test --workspace --all-targets --all-features --no-run --verbose: passed locally, including the large integration-test link; feature and target coverage are unchanged

Full release validation, including the final pre-selector remote run:

  • ./scripts/ci-local.sh: all jobs passed
  • Default and all-feature test suites: 736 passed, 0 failed, 13 ignored in the largest suite
  • Loom cell-lock models: 2 passed
  • Native and Windows GNU no-std builds, feature variants, and no-std consumer tests: passed
  • Workspace default and all-feature Clippy with warnings denied: passed
  • worktable_codegen publish dry run: passed
  • Root package preparation is expected to resolve after worktable_codegen 1.10.0-beta1 is published first
  • Both Typst PDFs compiled and were visually checked

Selector API validation on 7299aa9:

  • CARGO_PROFILE_TEST_DEBUG=0 cargo test --test mod --all-features: 736 passed, 13 ignored, 0 failed, covering in-memory, persisted, Vec, dense, and secondary-index mutation paths
  • cargo test -p worktable_dsl: passed
  • cargo test -p worktable_codegen: 102 passed
  • cargo test --test ui --all-features: passed, including wrong selector value type and generated selector collision diagnostics
  • External two-crate probe: public selector calls compile; external dispatch-trait implementation is rejected by the private sealed supertrait
  • cargo clippy --workspace --all-targets --all-features -- -D warnings: passed
  • no-default library, no-std consumer build/tests, and no-default Clippy: passed

Shipping-schema mutation evidence (d4b8aac)

Linear appends on fix/heap-initialize-pages after remote 7299aa9: 72b018f (typed mutation field sets), 45c015d (persisted index page sizing / batch lookup), d4b8aac (user-guide chart).

Pays CustomerPayment shipping fixture, 32,768 persisted rows, 9 balanced fresh-process samples, taskpolicy -b, Apple M4 Max. Private key is autoincrement u64; public key is packed 16-character Base62 payment_id; four secondary indexes; monetary columns are strings.

mutation base b1b9546 ns/op final 45c015d ns/op final / in-place
upsert 76175 74430 9.83x
replace 71118 69518 9.18x
update 11907 11561 1.53x
update_in_place 7340 7571 1.00x

Typed selectors stay inside sample noise of the historical update(row) / generated-query-struct spellings. The user-guide mutation table now carries this chart; the PDF was compiled with Typst and visually inspected. Full sample table: perf-benchmarks PR #4 data/apple-m4-max-darwin-arm64/2026-09-13-wt-mutation-flavours.md. Do not merge or publish from this PR.

2026-09-14 extended mutation evidence

User guide now includes application-layer updates/s, disjoint-worker scaling (peak 8 workers), application-layer range (range_ids / range_scan; no update_range primitive), and unique-u64 WTI / Arctic / Congee persist plus in-memory FxHash. Guide commit d9b6847.

2026-09-17 review fixes

Eight commits answering the review of this PR. Each builds on its own, so the
series bisects; nothing here is a squash.

Make the borrowed lock guards unsafe to obtain The *const LockMap guards were reachable from safe code: build a map, take a guard, drop the map, drop the guard, and that is UB with no unsafe in the caller. Only new_with_mutation was unsafe while five siblings with the identical contract were not. Also fixes a permanent row-lock-map entry leak through the public insert, which reset acquirers to 0 under a live acquirer and wrapped the count to usize::MAX on its drop.
Refuse char on the zero-copy select_with path char was in the InlineArchived allowlist. It archives to rend::char_le, which transmutes its u32 on a validity promise, and two valid chars tear into a surrogate. A char table now loses select_with, which is only an optimisation.
Stop assume_init reading the uninitialised tail of a seqlock copy copy_row_seqlock called assume_init on a 256-byte buffer of which only the row's bytes were written, then moved it: a read of uninitialised memory on the default read path. Now a MaybeUninit written through copy_nonoverlapping, which is cheaper. Adds retry backoff and the three loom models the protocol never had.
Give vacuum's idle fast path a window to observe The fast-path guard's conditions were all implied on the first pass, so it reduced to if quiet == 1 { return; } and quiet_samples was dead. The entry epoch is sampled before the yield now, so the comparison spans a real window.
Bound the join a busy persistence drop waits on nagoya::block_on in Drop parks with no deadline, so a hung write hangs the dropping thread, which on a current_thread runtime is the only one. Bounded; on expiry the handle detaches, which is what this code did before the join existed.
Fence the index batch's positional page lookup The positional fallback always returned something, so the error branch was unreachable and a genuinely corrupt batch was applied to whichever page sorted nearest. Now fenced to the stale-maximum shape it was written for; everything else stays a hard error.
Stop duplicating the retry loop and the opaque-rebuild arm Three copies of one retry loop, and ~300 verbatim lines of the opaque-field rebuild in both update generators on a correctness-critical path.
Point the old in_place keyword at its new spelling in_place: got a generic "unexpected token" instead of naming its rename.

No performance regression is intended and none is measured: the guard change
is compile-time only, the seqlock fix removes a 256-byte copy, the backoff is
on the retry path alone, and with_link_retry is #[inline] and generic so
each call site monomorphises to what it compiled to before.

Verification. 1,182 tests across 13 targets, 11 loom models (including the
three new seqlock ones), clippy clean, rustfmt clean, no-default-features
clean. Benchmarks in perf-benchmarks PR #4.

Two items the review raised and this does not change, both deliberate:
get_aligned_disk_page_capacity is documented as format-affecting for unsized
primary keys rather than reverted, and the writer_key != 0 guards stay with a
note on why they are unreachable-false today, since that rests on an
undocumented store order in write.

@pathscale

Copy link
Copy Markdown
Owner Author

Cross-stack release validation on 2026-09-13 used WorkTable dc35d62, honey_id-types b4de978, auth backend 6ea5028 (source-equivalent lifecycle code to the locally tested bad4083), and API backend 77c04d0.

  • Native Honey ps-qa: 302/302 checks across 16 groups.
  • Strict control inventories: PlatformAdmin 170 checked / 0 unverified; AppAdmin 54 / 0; Guest 16 / 0.
  • Destructive app lifecycle: 19/19, including API-key regeneration, hide, reveal, delete cleanup, and logout.
  • Recovery: 33/33, including two rotations and replay rejection.

No Fly deployment was performed. Registry publication remains ordered: WorkTable 1.9.0-beta1 first, then honey_id-types 2.1.1, followed by registry-only backend checks.

@pathscale pathscale changed the title Release WorkTable 1.9.0 beta1 Release WorkTable 1.10.0 beta1 Sep 13, 2026
meh added 13 commits September 13, 2026 16:56
Record the Pays CustomerPayment 9-sample medians next to the mutation
API table so replace, typed update, and in-place stay distinct from
upsert, with the string-money rerun caveat.
Keep ns/op and add application-layer updates/s. Record disjoint-worker
scaling, the absence of an update_range primitive, and unique-u64 WTI /
Arctic / Congee / in-memory FxHash replace cost on the same campaign.
Eight tokio tasks overlapped. taskpolicy -b still held the process to
about one core, which is why 8-worker replace only moved 1.22x. Inherit
policy burns ~6 cores for 1.27x replace; in-place loses throughput.
Every acquire miss and every LockAcquirer drop took a table-wide
RwLock write. Eight disjoint in-place workers then used ~6.7 cores
and lost throughput. 64 shards, same hash as mutation stripes.
Packed stripe atomics serialized shared select at ~2.4× while vec: true
and a private paged table both scaled ~7×. Copy archived bytes, validate
the stripe, then deserialize. Pad stripe states to a cache line.
Owned select still copies an archived cell and builds a Row. Shared
readers that only need fields can seqlock-load the archived inner row
in place. select_ref keeps a pin-guard snapshot for callers that want
a named view.
A writer releases by storing 0 and load_stable returns 0 for an idle
cell, so a write that began and finished inside one reader's window
left no trace in the state word. still_stable compared 0 to 0, called
the snapshot good, and get_row_seqlock handed rkyv::access_unchecked a
buffer copied out of the middle of that write.

The counter lives in the padding PaddedCellState already reserved, so
it costs no extra line. Bump on write release before clearing the
writer bit, and in reset, which also replaces page contents.

still_stable keeps load_stable's reentry exception: this task's own
write on a different row of the same stripe is not a retry reason, and
rejecting it spins forever because only that task can clear the bit.

paged_select w8/w1 is 7.78x before and after.
with_archived_seqlock handed the caller's closure a reference into the
live page and only checked stability afterwards. A torn u64 would be
harmless there, since the retry discards the result, but the closure
receives &Archived and rkyv archived types carry relative pointers: a
torn pointer dereferenced inside the closure is undefined behaviour
before still_stable runs. The shipping test row has a String column, so
this is reachable.

copy_row_seqlock already validates its copy, so reuse it and let the
closure see one write generation.

This costs select_with the memcpy it was added to avoid: 53.8M to 31.1M
at w=1, level with plain select. Scaling is unaffected at 8.0x. Keeping
the fast path for rows whose archived form holds no pointers is the
open question, not something to assume here.
The previous commit bought soundness with a copy, which cost
select_with the whole reason it exists: 53.8M to 31.1M at one worker,
level with plain select. Neither the tear nor the copy is acceptable,
so make the compiler separate the two cases.

select_with reads the cell in place and validates afterwards, so the
closure can see a value a writer is changing. For an inline scalar that
is recoverable, since the retry discards whatever the closure computed
from a torn number. For a relative pointer, an archived String or Vec
field, the closure dereferences it before the check runs.

InlineArchived marks rows that hold no relative pointers. The macro
emits it only when every column is a known scalar shape, and generates
select_with only for those tables. A table with a String column now has
no select_with, so the call is a compile error naming the missing
method rather than a silent copy or a torn pointer. Opaque user types
are refused, because the macro cannot inspect their archived layout:
that costs a table the fast path, it never grants one unsoundly.

select_with is back to 52.2M at one worker and 365M at eight.
The suite covered concurrent same-row updates through the full update
path and through the index backends, but nothing raced writers at a
single key through update_in_place specifically.

That gap hid a real defect. An uncontended fast path for this operation
measured 85 percent faster at eight workers and deadlocked the mixed
in-place-plus-update tests; the attempt is written up in the perf
evidence rather than kept. A counter row makes the failure legible: a
lost claim is a lost increment, so the final value says how many writes
survived.
Every mutation built a hashbrown::HashSet to hold the locks it must
wait for. That set holds one entry per column the lock type covers, a
handful in any real schema, and the caller only iterates it. Building
it seeded a fresh foldhash hasher per operation, which profiling put at
about a tenth of the in-place update path.

A Vec deduplicated by Arc::ptr_eq does the same job. Linear scan over a
few pointers beats seeding a hasher, and nothing downstream needs set
semantics.

Measured on the arm that isolates per-operation cost, one table per
worker so there is no lock contention to hide behind: 13.8M to 27.7M
ops/s at eight workers, and the slope from 1.83x to 4.04x. The shared
arm improves too but stays negative, which is a different problem.

Changed in the trait, in FullRowLock, and in all five codegen sites:
the lock type's own lock and merge for in_memory, persist and
read_only, plus the per-query lock functions for in_memory and
persist.
meh added 18 commits September 15, 2026 02:36
An in-place update on a shared table got slower with every worker
added: 7.4M ops/s at one, 1.9M at eight. Four earlier attempts assumed
that was lock contention and changed what the lock map does. None of
them moved it, because the cost was not in the map at all.

Two measurements located it. The collapse happens at two workers, not
eight, so it is a hard serialization point rather than growing
contention. And it does not change when the key space grows 256 times,
1k rows to 262k, so it cannot be shard collisions or key conflicts.

What is left is how the map is reached. Every operation cloned
Arc<LockMap> three times: once into the LockAcquirer, once into the
PendingLock, once into the LockGuard. Three atomic read-modify-writes
on one cache line that every worker shares, whatever key they touch. A
twenty-line program doing the same three clones reproduces the whole
curve with no WorkTable in it: 92M ops/s at one worker, 5.4M at eight.

All three now borrow. The caller reached the map through the table's
own Arc and holds it for the operation, so it outlives every guard;
the fields carry that argument and the Send impls carry it too.

paged_in_place, 200k ops per worker: 1.89M to 7.44M at eight workers,
and the slope from 0.255x to 0.903x. The table no longer gets slower as
workers are added, which was the defect. It does not yet scale, so
there is a second ceiling under this one.
A shared in-place update lost throughput as workers were added. Removing the
three `Arc<LockMap>` clones fixed the negative slope but left it flat at 1.03x,
and the remaining ceiling was three more per-operation writes to memory that
every worker on the table touches whatever key it holds.

The lock label was minted from one table-wide `AtomicU16`. `Lock::id` is a
diagnostic label, not dependency identity, and nothing in the protocol reads it
back, but the counter is independent of the key, so sharding could not dilute
it. It now comes from the key's own mutation stripe.

`MutationStripe` was two atomics, so eight stripes shared one 128-byte granule
and a ticket taken for one invalidated the line seven others were spinning on.
Each stripe now owns a granule, and carries that key range's label counter so
an operation touches one line rather than two.

`MutationGuard` cloned the stripe array's `Arc`, which is the same defect as
the map clones in the one place on the path that still had it. It borrows now,
on the same contract as the other guards in this module.

With the shared lines gone the cost was map work and allocation. The
predecessor wait is skipped when there is no predecessor, which on disjoint
keys is every operation. `LockEntry::acquirers` is inline rather than an `Arc`:
the acquirer cloned it to decrement without touching the map, but its drop then
took the shard write lock anyway to re-look the entry up, so the clone bought
nothing and cost an allocation and a free per operation. That decrement now
happens under the same guard as the removal check.

Last, the map had too few shards. An operation takes its shard lock twice, to
insert its entry and to remove it, and a collision parks the loser in the
kernel; at 64 shards eight workers collide about a tenth of the time. Measured
at 64, 256, 512, 1024 and 2048, the slope goes 1.70x, 2.39x, 2.63x, 2.81x,
3.07x. 1024 is where it stops paying for itself. Shard and stripe indices are
now separate reductions of one hash: they were one const, and tuning them
together hid which of them mattered.

`paged_in_place` at 16384 rows, 200k ops per worker, arctic, eight workers
against one: 0.798x to 2.76x, and 6.27M to 26.3M ops/s.

`LockGuard::new_with_mutation` is `unsafe` because it dereferences the raw map
pointer it is handed. It already did; clippy's `not_unsafe_ptr_arg_deref`
failed the build before this change.
`Lock::locked` was an `Arc<AtomicBool>` so that a `LockWait` could outlive the
lock it waits on. A wait can hold an `Arc<Lock>` instead and keep the whole
lock alive, which costs one pointer in a future that is only built when a
predecessor is actually held, and saves an allocation and a free on every
locked operation whether or not anything waits.

Once the map's per-operation exclusive acquisitions were gone, freeing was 30%
of the in-place update profile. This is half of what it was freeing.

Lock identity is unchanged in meaning: equality and hashing were pointer
identity on the flag's allocation, which existed one per lock, and are now
pointer identity on the lock. The label stays a label.

`paged_private_update`, 16384 rows, 200k ops per worker, arctic: 8.5M to 10.0M
at one worker and 47M to 51M at eight. `paged_in_place` 9.5M to 10.5M at one.
Five CI jobs had been failing since before the scaling work, and the branch
cannot merge while they are red. None of them is a new failure and all five
reproduce locally.

Formatting: thirteen files, from several earlier commits on this branch.
`cargo fmt --all`.

Clippy under `-D warnings`, which is stricter than a bare `cargo clippy` and is
what CI runs. Two errors. The row-lock map's shard array tripped
`type_complexity`, now a named `LockShard` alias. `ArchivedCopy` tripped
`large_enum_variant`, where the lint's own fix is the defect: boxing the
`Stack` variant puts the small-row snapshot on the heap, which is the
allocation the type exists to avoid, and the enum is a local of the select path
that is never stored in a collection. Allowed, with that reason.

No default features: `src/lock/map.rs` used `Box` and `src/lock/row_lock.rs`
used `Vec` without importing them from `alloc`, which is invisible in any build
where `std`'s prelude supplies both. The other 22 errors were type inference
failing on the back of those two.

The loom models overflowed their coroutine stack before reaching an assertion,
so the archived-row lock has had no model coverage at all on this branch. They
built `CellLocks` with `default()`, which returns two 256-slot atomic arrays by
value and so reserves a copy on the caller's stack: affordable on a real thread
and not on a loom coroutine, whose atomics each carry tracking state.
`CellLocks::initialize_at` exists for precisely this and is what `Data` uses in
production; the models now build through it. Both pass and both now reach their
assertions.
Vacuum takes a full row lock once per compaction candidate, while foreground
writers are running, so it was minting its label from the table-wide counter
that the generated paths stopped using: the one shared cache line the striping
exists to avoid, contended from the one place still writing it on a per-row
path. It has the primary key in hand, so it uses the same striped counter.

The table-wide next_id now has no per-row caller left; what remains is a test.
Shard and stripe indices are both a usize reduced from the same key hash, and
nothing keeps them apart: indexing the map with the stripe index compiles,
stays in bounds, and silently caps the reachable shard count at the stripe
count. That defect survived a whole shard-count sweep, which read as 'the shard
count does not matter' because shards above 64 were never addressed.

The test drives shard() and identifies the shard by address, so it measures
where the lookup actually lands. Verified by injecting the defect: it fails
with 'only 64 of 1024 shards are addressable' and passes once reverted. An
earlier version asserted only that shard_of and stripe_of have the right
ranges, which holds no matter which one shard() calls, and passed with the
defect injected.
`wait_until_quiet` is called at every batch boundary and slept unconditionally
before returning: three quiet samples at 2 ms each, 6 ms a batch, whether or not
anything was writing.

The samples exist to tell a real lull from the gap between two writes. That
ambiguity only exists when something has been writing. If nothing is in flight,
the mutation epoch has not moved since entry and no stand-down happened, there
is no gap to be fooled by and nothing to confirm.

`ghost-vs-drop` measured a 40-page sweep at 31,519 us, of which roughly 36 ms
was this arithmetic: the vacuum was not slow, it was waiting for permission
nobody was withholding. 31,519 to 4,861 us, and per page freed 788 to 121.5.

The reactive design is unchanged and is what it claims to be: delete marks a
ghost and queues the storage, the sweep parks on a threshold armed by the
registry rather than a timer, and under load it stands down and doubles its
backoff to a 128 ms ceiling. Only the idle path is affected.

1,137 tests pass, including the 36 vacuum tests that assert stand-down under
concurrent mutation.
LockAcquirer, MutationGuard, LockGuard and PendingLock hold a *const
LockMap rather than an owning Arc, which is what removed the per-operation
refcount traffic from one key-independent cache line. The precondition that
buys is real and unstateable: the map has to outlive the guard.

Every constructor that handed one out was safe, so three safe lines reached
undefined behaviour with no unsafe anywhere in the caller:

    let map = Arc::new(LockMap::default());
    let acquirer = map.get_or_insert_with(1, FullRowLock::new);
    drop(map);
    drop(acquirer);

Only new_with_mutation was unsafe, and it has the identical contract to the
five that were not. Mark get_or_insert_with, mutation_guard, mutation_guards,
LockGuard::new, PendingLock::new and FullRowLock::guard unsafe, document the
contract once on the map, and take the four guard types out of the visible
prelude. They are not a user-facing API: every caller is generated code in
this crate's own operation bodies, which is why the generated blocks carry
the SAFETY note rather than the user.

No runtime change. The pointer, and the win it measures, are untouched.

Also: LockMap::insert replaced an entry with acquirers at 0, so a live
LockAcquirer for that key decremented from 0 to usize::MAX on drop and
remove_if_unused could never reclaim it again, leaking the entry for the
life of the table. The count is inline in the entry now, not the shared
Arc<AtomicUsize> an acquirer used to own, so the replacement has to carry it.
InlineArchived's contract is that a concurrent writer can tear a value the
closure reads but cannot hand it one that is invalid: a torn read yields a
wrong number, and the seqlock retry throws it away. char does not satisfy
that. It archives to rend::char_le, whose to_native transmutes its u32 on
the promise that it holds a valid scalar value, and two valid chars tear
into one that is not: U+1D800 is [00 D8 01 00] and U+0041 is [41 00 00 00],
so a copy taking the low half of the first and the high half of the second
reads 0x0000D800, a surrogate. The closure transmutes that before
still_stable ever runs.

So a table {id: u64 primary_key, c: char} got unsafe impl InlineArchived and
a generated select_with whose whole soundness argument did not hold for it.

Take char out of the allowlist. It is an optimisation gate, so such a table
simply loses select_with. bool stays: one byte, so it cannot tear into a
third value. The doc now says what the list is actually for, which is not
"no relative pointers" but "no validity invariant either".
copy_row_seqlock built a MaybeUninit<AlignedBytes<256>>, wrote only the
row's own bytes into it, and then called assume_init on the whole array and
moved the resulting Copy value into ArchivedCopy::Stack. A row is at most
256 bytes and usually far less, so the tail was never written: that is a
256-byte read of uninitialised memory on the default read path, every select
of a row that fits the stack buffer. as_bytes only ever exposed ..len, so
nothing observed the garbage, but the assume_init and the move are undefined
by the documented contract and LLVM may treat the tail as poison. Taking a
&mut [u8; 256] over uninit storage to do the copy is separately questionable
under Stacked and Tree Borrows.

Keep the storage MaybeUninit, copy through the raw pointer with
copy_nonoverlapping, and expose only ..len. This is cheaper, not dearer: the
256-byte Copy move is gone.

Also on this path: the retry loops in copy_row_seqlock and
with_archived_seqlock spun with no backoff and no cap. A stripe covers many
offsets on a page, so one hot writer could keep readers of unrelated rows
re-copying indefinitely, and load_stable's own backoff does not cover the
case a rejected stamp reports. They now back off through CellLocks::wait,
which costs nothing when the first attempt validates.

And three loom models for the snapshot protocol, which had none: select
takes it for every row under 256 bytes and neither existing model drove
load_stable or still_stable. The cell is two ordered atomics rather than a
loom UnsafeCell, because the reader legitimately races the writer and loom
reports that as the defect; the note on the fixture says why the ordering
there is load-bearing for the model and not for the real path.

The four hand-written initialize_at/initialize_arc_at helpers write every
field through addr_of_mut! and then assume_init, which the compiler cannot
check for exhaustiveness: adding a field and updating only the safe twin
beside it compiles and hands out an Arc with one field uninitialised. Each
now has a cfg(test) destructure without `..` that breaks the build instead.
The guard that lets an idle table skip the quiet buffer sampled entry_epoch
two lines before the loop, so all three of its extra conditions were already
implied on the first pass: reaching quiet += 1 requires not paused, nothing
in flight and current_epoch == observed_epoch, and observed_epoch was
entry_epoch. The condition reduced to `if quiet == 1 { return; }` and
quiet_samples was dead. A writer at t=0 and t=5ms with vacuum entering at
t=1ms is exactly the gap-between-two-writes ambiguity the sampling exists to
resolve, and this walked straight into it.

Sample the entry epoch before the yield instead. Now the comparison spans an
actual window: a mutation that completes while vacuum is off the executor
moves the epoch and sends the call down the sampling path. An idle table
still returns after one yield with no sleeps, which is the measured win.

Drop the mutations_in_flight repetition, which the loop head already
established. The stand-down count stays, because a concurrent sweep can move
it between entry and the check.

The existing test set active = true before entry, so the busy branch fired,
note_stand_down ran and the fast path was disabled for the whole call: it
passed without executing the new code once. Two tests now cover it, one that
an idle table sleeps not at all and one that work across the entry yield
costs the full buffer. The second drives the epoch from the read index
rather than a second task, because nagoya::yield_now wakes itself before
returning Pending and a current-thread scheduler runs the waiter through
both reads before the test is polled again: there is no window to race into.
The busy-drop arm replaced a log line with nagoya::block_on(handle), which
parks the calling thread on a Signal between polls with no timeout and no
attempt bound. Dropping a PersistedWorkTable with a non-empty queue then
parks that thread until the engine has drained and fsynced everything.

Drop runs on whatever thread drops the table. On a current_thread runtime
that is the only thread there is. It also runs during unwinding, so a panic
in a caller could block on I/O instead of unwinding, and a hung file or S3
write means drop never returns at all, with no diagnostic. The comment's own
safety argument -- that joining cannot occupy the worker that must make
progress -- holds only because the engine has its own one-worker pool, and a
drop reached from that worker self-deadlocks.

Keep the join, which is what stops an immediate same-path reopen racing the
last writes, and bound it. After BUSY_DROP_JOIN_TIMEOUT the handle is
dropped, and nagoya::JoinHandle::drop detaches rather than cancels, so the
worker still finishes its in-flight write exactly as it did before the join
existed. The difference is that the caller is told rather than stalled, so
the error line that was removed comes back for that case.

The new test covered panic containment; nothing covered a worker that does
not finish. Two tests now do, one either side of the timeout.
resolve_batch_page gained a third fallback for InsertAt and RemoveAt: when
both the table-of-contents lookup and the batch aliases miss, resolve the
page by page_containing(event_value), which returns the smallest maximum
>= value and falls back to the highest-keyed page when there is none. That
tail arm always returns something for a non-empty table of contents, so the
error branch above it became unreachable for insert and remove events: a
batch whose identity is missing through real corruption, rather than through
the stale-maximum case the fallback was written for, was applied to whatever
page the ordering picked and that wrong write was persisted.

The tail arm cannot simply go: it is the arm the intended repair needs. When
a preceding batch removed a page's maximum, the next batch's value is above
every surviving maximum, which is precisely the cross_batch_max fixture.

So fence it instead. Pass the stale identity too, and require the page found
to be one that identity could have belonged to: its maximum strictly below
the named one, with no surviving page between the two, since such a page
would own that range and would have answered the identity lookup. Both
conditions are checkable and both are checked, so a torn table of contents,
a stream applied out of order or two writers on one file find nothing and
stay a hard error.

Folded into the existing pass rather than added beside it, because this runs
per fallback event inside the batch loop and was already linear in the
table of contents.
Three copies of one fifteen-line link-recheck loop in select, select_ref and
select_with, two of them added by this branch, differing only in the body.
Extracted as with_link_retry, which is #[inline] and generic over the
closure, so each call site monomorphises to what it compiled to before.
select_ref attaches its pin to the copy afterwards, because the pin is moved
into the result and cannot be captured by a closure the loop may call more
than once. Bound and behaviour unchanged.

Around three hundred lines of the requires_rebuild arm stood verbatim in both
update generators, including the same in-place attempt and the same full-row
lock dance, differing only in a trailing CDC block. That is a
correctness-critical path -- it is what keeps an opaque archived field's
relative pointers based in the destination slot -- and it had to be changed
in two places in lockstep. It is one function now, in
generators::opaque_rebuild, taking the difference as a parameter.

Also six #[allow(clippy::mutable_key_type)] left on code that returns Vec
since the lock generators stopped using HashSet. Clippy is clean without
them.
in_place: became update_in_place: in 1.10. The old spelling fell through to
the generic arm and produced "Unexpected token `in_place`", which lists the
new keyword but does not say it is the same section renamed, so a reader has
to guess whether their table still works. Name it and say so.
`legacy_in_place_section_is_rejected` still asserted the generic
"Unexpected token `in_place`" that the previous commit replaced.
A table that still says `in_place:` is not using an unknown
keyword, it is using the last version's spelling of this one, and
the error is only useful if it says so.

The assertion failed in all three test jobs and nowhere else: fmt,
clippy, the no-std build and the concurrency models all passed
over it, because none of them run the dsl crate's tests.
The build job ran `cargo build --workspace --all-targets` and then
`cargo test --workspace --all-targets`, and only the test step set
CARGO_PROFILE_TEST_DEBUG. The two commands disagreed about the test
profile, so the second missed the first's cache and compiled the
workspace again. On the all-features leg the build step also
compiled with the full DWARF that `test_debug: 0` exists to avoid,
which is the linker crash the comment there describes. One step now,
the one that sets the profile.

A pull request builds the default leg only. versioned-publication
and all-features answer a release question, not a review one, and
cost about 38 minutes of two-core runner between them on every
push. The concurrency models go the same way: they explore an
interleaving space rather than run a suite, and they build into
their own CARGO_TARGET_DIR, so they share no cache and pay a cold
compile each time.

Both still run on master, which is what publish gates on, and
workflow_dispatch is added so either can be asked for by hand.
Build and test is a ten-minute compile on a two-core runner before
it runs anything, three times over the matrix, and it is the same
command scripts/ci-local.sh runs locally in a fraction of that. It
moves to master and workflow_dispatch, along with the concurrency
models, which explore an interleaving space rather than run a suite
and build into their own CARGO_TARGET_DIR so they share no cache.

What a pull request gets instead is every crate's library tests
without --all-targets. That leaves out the integration-test binary
and the compile-fail harness, where the ten minutes live: the
trybuild tests shell out to nested cargo builds and one alone took
56 seconds. 569 tests, 2.7 seconds once compiled.

It is not a token gesture. The assertion that turned this workflow
red was worktable_dsl's legacy_in_place_section_is_rejected, a lib
test this job runs, and fmt, clippy and the no-std build all passed
straight over it.
@pathscale
pathscale merged commit 31220fa into master Sep 16, 2026
9 checks passed
@pathscale
pathscale deleted the fix/heap-initialize-pages branch September 16, 2026 22:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant