Release WorkTable 1.10.0 beta1 - #106
Merged
Merged
Conversation
Owner
Author
|
Cross-stack release validation on 2026-09-13 used WorkTable
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. |
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.
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.
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.10.0-beta1 brings the frozen
LinearTableAPI 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
Arcallocation 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 indexedLinearTablelookup 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. Declaredupdate:operations generate typed selected-column calls such asupdate_by_id(id, CustomerPaymentColumns::STATUS, value); declaredupdate_in_place:operations generateupdate_in_place_by_id(id, CustomerPaymentColumns::STATUS, closure). Multi-column declarations expose one table-scopedFIELD_AND_FIELDselector 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 classificationcargo test --test mod update_in_place_unsized: 15 passed across WTI, Congee, and Arctic, including opaque-only full-row, custom-wrapper, and optional-string updatescargo test --test mod targeted_update_of_string_wrapper_survives_read_and_reload: passed; same-slot WAL bytes and changed-size reinsert both survived cold reopencargo clippy --workspace --all-targets -- -D warnings: passedsh scripts/check-no-std.sh -p worktable --lib --no-default-features: passedExact before/after performance:
runtime-flavours,wt-owned-runtime,wt-tunables, andwt-feature-smokeare byte-identical to parent0a46d35; 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 PaysCustomerPaymentstatus 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 typedupdate_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 coveragecargo fmt --all -- --check: passedcargo clippy --lib --tests -- -D warnings: passedsh scripts/check-no-std.sh -p worktable --lib --no-default-features: passed with a sysroot that hasstdremovedCARGO_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 unchangedFull release validation, including the final pre-selector remote run:
./scripts/ci-local.sh: all jobs passedworktable_codegenpublish dry run: passedworktable_codegen 1.10.0-beta1is published firstSelector 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 pathscargo test -p worktable_dsl: passedcargo test -p worktable_codegen: 102 passedcargo test --test ui --all-features: passed, including wrong selector value type and generated selector collision diagnosticscargo clippy --workspace --all-targets --all-features -- -D warnings: passedShipping-schema mutation evidence (
d4b8aac)Linear appends on
fix/heap-initialize-pagesafter remote7299aa9:72b018f(typed mutation field sets),45c015d(persisted index page sizing / batch lookup),d4b8aac(user-guide chart).Pays
CustomerPaymentshipping fixture, 32,768 persisted rows, 9 balanced fresh-process samples,taskpolicy -b, Apple M4 Max. Private key is autoincrementu64; public key is packed 16-character Base62payment_id; four secondary indexes; monetary columns are strings.b1b9546ns/op45c015dns/opupsertreplaceupdateupdate_in_placeTyped 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 #4data/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; noupdate_rangeprimitive), and unique-u64 WTI / Arctic / Congee persist plus in-memory FxHash. Guide commitd9b6847.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*const LockMapguards were reachable from safe code: build a map, take a guard, drop the map, drop the guard, and that is UB with nounsafein the caller. Onlynew_with_mutationwasunsafewhile five siblings with the identical contract were not. Also fixes a permanent row-lock-map entry leak through the publicinsert, which resetacquirersto 0 under a live acquirer and wrapped the count tousize::MAXon its drop.Refuse char on the zero-copy select_with pathcharwas in theInlineArchivedallowlist. It archives torend::char_le, which transmutes itsu32on a validity promise, and two valid chars tear into a surrogate. Achartable now losesselect_with, which is only an optimisation.Stop assume_init reading the uninitialised tail of a seqlock copycopy_row_seqlockcalledassume_initon 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 aMaybeUninitwritten throughcopy_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 observeif quiet == 1 { return; }andquiet_sampleswas 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 onnagoya::block_oninDropparks with no deadline, so a hung write hangs the dropping thread, which on acurrent_threadruntime 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 lookupStop duplicating the retry loop and the opaque-rebuild armPoint the old in_place keyword at its new spellingin_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_retryis#[inline]and generic soeach 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-featuresclean. Benchmarks in perf-benchmarks PR #4.
Two items the review raised and this does not change, both deliberate:
get_aligned_disk_page_capacityis documented as format-affecting for unsizedprimary keys rather than reverted, and the
writer_key != 0guards stay with anote on why they are unreachable-false today, since that rests on an
undocumented store order in
write.