Skip to content

1.0.0-beta.17 - #87

Merged
pathscale merged 72 commits into
masterfrom
release/beta17
Sep 3, 2026
Merged

1.0.0-beta.17#87
pathscale merged 72 commits into
masterfrom
release/beta17

Conversation

@pathscale

@pathscale pathscale commented Sep 2, 2026

Copy link
Copy Markdown
Owner

The beta.17 release PR. It absorbs the earlier beta.17 work and deliberately excludes #58.

What ships

  • Reactive vacuum pacing driven by live mutation activity, including operation-wide bulk mutation leases and a quiet recheck buffer.
  • Exact vacuum reclamation accounting and repeated sweeps until the table is clean.
  • Async insert and insert_many, with consumer call sites and benchmarks migrated.
  • Persisted bulk-delete correctness, cancellation and lock-order fixes, page-retirement safety, and storage/index consistency fixes.
  • Deterministic DSL expansion, consistent trailing-comma parsing, schema validation/diff tooling, and the public worktable_dsl re-export.
  • Generated delete futures remain Send, including brute-force and indexed delete paths.

Validation

The exact local candidate passed the complete default, versioned-row-publication, and all-features build/test matrix plus clippy with warnings denied.

Vacuum was validated under sustained upsert/delete/reinsert pressure across WorkTablesIndex, Arctic, and Congee. Reactive vacuum stayed out of the measured foreground path while the unpaced positive control produced detectable interference. Every backend returned to the independently packed ideal page count after load.

The beta.13/beta.15/beta.17 performance grids include CRUD, partition routing, AgentCode, MoE-PGO, concurrency, and vacuum. The stable nanosecond-scale partition_ref regression is documented and accepted because routed application work improved. Full evidence is in docs/beta17-validation.md.

A local-source support.cafe consumer passed its complete workspace gate, loaded and rebuilt the live S3 dataset into a rollback-safe prefix, strict-reloaded all six persisted tables, persisted a mutation across restart, and passed HTTPS/WebSocket startup on Fly.

Release train

Published prerequisites:

  • ps-reclaim 0.1.2
  • WorkTablesIndex 0.0.10
  • data_bucket 0.5.6

Merging this PR publishes in dependency order:

  1. worktable_dsl 1.0.0-beta.18
  2. worktable_codegen 1.0.0-beta.17
  3. worktable 1.0.0-beta.17

Consumer ports follow only after all three artifacts are visible on crates.io.

meh added 10 commits September 2, 2026 14:07
A schema is written down once, in a `worktable!` invocation, and the parser
that understands it lived in `worktable_codegen`, which is
`proc-macro = true`. A proc-macro crate can export nothing but macros, so every
type describing a schema — the columns, the primary key, the indexes, the
queries — was unreachable from any other crate however public it was declared.
`mod common` was not public at that crate's root either.

So anything wanting to *read* a declaration had two options: re-implement the
grammar and drift from it, or do without. A diagram, a migration tool, a
documentation generator and an editor all want to read one. `lib.rs` has
carried `// TODO: Refactor this codegen stuff because it's now too strange.`

`model` and `parser` move to `worktable_dsl`, a plain library. Nothing in them
changed; the dependencies are the five they already used, none added, none
dropped. `worktable_codegen` now depends on it, so there is one grammar rather
than a copy that can disagree with the compiler about what a schema means.

`name_generator` stays in codegen. It invents Rust identifiers for generated
code, which is not the schema language, and generators here define inherent
`impl`s on `WorktableNameGenerator` — the orphan rule allows that only in the
crate owning the type. I had it in the extracted crate first and the compiler
made the same argument the design does.

`crate::common::` still resolves, through a thin module that re-exports the new
crate, so the 127 paths across 67 files are untouched and the diff stays a move
rather than a sweep.

An integration test reads a declaration from outside, which is the claim worth
pinning: it compiles as its own crate, so it stops building if this ever
becomes a proc-macro crate again.

It also records a property no caller existed to depend on before.
`Columns::columns_map` is a `std::collections::HashMap`, whose iteration order
Rust randomises per process; two runs of the same input gave
`["answered", "project_id", "id"]` and `["project_id", "answered", "id"]`. The
macro never cared, and the parser's own tests collect it into another `HashMap`
and assert membership, so nothing noticed. A consumer rendering columns in that
order draws a different table every run. `field_positions` already carries the
declaration order and is the field to sort by; the test asserts that, and says
so, so the next consumer learns it here rather than by shipping the bug.
`pub use worktable_dsl::{Parser, *};` names `Parser` and then re-exports it
again through the glob, and rustc reports the glob as unused: nothing reaches
the shim that way, because every caller goes through `crate::common::model::`
or `crate::common::parser::`.

That is only a warning locally, which is why it survived the extraction. CI
runs `cargo clippy --workspace --all-targets -- -D warnings`, where it is a
build failure.
`worktable_dsl` can parse a declaration, which is half of what a designer
needs. The other half is holding one, comparing it, storing it, and writing it
back out as text the compiler accepts. `crate::model` cannot do any of that: it
is built out of `Ident` and `TokenStream`, which is right for a thing whose job
is to become Rust code and wrong for everything else. An `Ident` cannot be
serialised or constructed outside a proc-macro context without a
`Span::call_site` that lies about where it came from, and a `TokenStream` is
not `PartialEq`, so two schemas cannot even be asked whether they differ. That
is the one question a migration planner exists to answer.

`schema::Schema` is the same declaration with the compiler's concerns removed:
`String` for `Ident`, ordered `Vec`s for `HashMap`s, no spans. It derives
`PartialEq` so schemas can be diffed, and under an optional `serde` feature it
derives `Serialize`, so one can be stored next to the data it describes and
read back by a process that never saw the Rust type. The feature is off by
default because `worktable_codegen` depends on this crate and is a proc macro:
every WorkTable user compiles it for the host before anything else in their
build, and they should not pay for a derive macro that serves consumers who are
not the compiler.

The approach is additive. Nothing in the parser or the generators changed, and
the model is untouched apart from three `cfg_attr` derives on plain enums the
IR reuses rather than duplicates. Inverting the parser to produce plain data
directly would have been a 61-file edit across 13k lines of generators with no
test proving the output was unchanged.

Two emitters. `to_dsl` renders the declaration body back to text, which is what
makes a drawing editable: read, change, write, compile. `to_mermaid` renders
UML class notation, chosen because it is text, so it is diffable and needs no
rendering dependency, and because it renders anywhere Markdown does. Columns
are attributes carrying their markers, queries are operations, and the
partition key is a note rather than an attribute because it is stored once per
partition and no query can name it. The schema language has no foreign keys, so
`infer_relations` guesses links from a single stated naming rule and returns
what it guessed; `schemas_to_mermaid` draws those as dependencies rather than
associations, because a dashed arrow is the honest notation for a link the
declaration does not make.

Ordering is a guarantee here, not an accident. `columns_map` is a `HashMap`,
whose iteration order Rust randomises, so a consumer walking it draws a
different table on every run; `field_positions` records the declaration order
and is what the IR sorts by. The query maps have no such field, so those are
sorted by name, which is at least stable, and a test says which is which.

The round trip is checked against the repository rather than against a fixture.
`tests/round_trip.rs` finds all 128 `worktable!` invocations in the tree, sets
aside the 12 that are `macro_rules!` templates full of metavariables, and
asserts `parse(emit(parse(x))) == parse(x)` for the remaining 116. Those were
written by people not thinking about this crate, which makes them a better
corpus than anything written here. `codegen` adds the claim this crate cannot
make about itself: that emitted text is a declaration the macro accepts. That
check has to live on the near side of the proc-macro boundary.

Three things the corpus turned up.

The emitter writes no comma after `delete`, `in_place` or `config` blocks.
`parse_updates` consumes one and those three do not, so a comma there arrives
at a dispatch loop as a `,` token and dies as "Unexpected identifier". Omitting
it is the only form all of them accept.

Expanding one declaration twice does not produce one program. Several
generators iterate `columns_map` directly to emit an ordered construct, the
`RowFields` and `AvaiableTypes` enums among them, so the variant order differs
between two expansions in one process and can differ between two compilations
of the same source. `generator_determinism` records it with the evidence and is
ignored rather than deleted: the fix changes the generated code of every table
and deserves reviewing on its own. It is also why `emitted_declarations` can
only assert that an emitted declaration expands, not that it generates
identical code.
With a schema as data on both sides, the decision a version mismatch forces can
be computed instead of hand-written. `Diff::between` says what changed, `Cost`
says what applying it costs, and `transforms_required` says which parts a person
still has to write, which are the parts that need intent rather than mechanism.

The cost model is about links, not about fields. A row is addressed by a
`Link { page_id, offset, length }`, and every index holds links, so the question
that decides what a change costs is not how many columns moved but whether a row
is still where it was. Any change to the archived layout invalidates every link
in the table at once and there is no cheaper answer than writing every row
somewhere else. A change to an index invalidates nothing: the rows have not
moved and the index can be rebuilt from them. Hence three tiers and a fourth for
the changes no diff can settle.

That last tier is the point of the exercise. A changed primary key, a changed
partition key, a renamed table and a flipped `persist` are not expensive, they
are underdetermined, and the useful thing a planner can do is say so rather than
guess. The routing key is the clearest case: it is not in the row, so which
partition a row belongs to cannot be recomputed from the row, only from where
the row already is.

The planner invents a value only when there is exactly one it could be. Widening
a column to `optional` has one answer. Narrowing it does not, and neither does
adding a required column or changing a type, so each of those comes back as a
`TransformRequest` naming the column and why. A rename is reported as a drop and
an add, because nothing in a declaration distinguishes it from a deletion beside
an unrelated addition, and guessing by type equality would be wrong exactly when
it mattered.

Three things worth knowing that the tests state as claims. A version bump on its
own costs nothing, which is what keeps bumping cheap enough to be habitual.
Reordering columns is a layout change, because declaration order is the row
struct's field order: it is the change most likely to be made by accident and
least likely to look like one. And a schema that changed without a version bump
is still detected, at the cost of comparing two small structs and reading no
rows, which is the middle branch of the load state machine.

`plan` lifts the same comparison to a set of tables, matching by name because
that is how spaces are matched on disk. A dropped table is `NeedsIntent` rather
than free: whether to delete data is a decision, not a consequence of a
declaration.
A compiled binary could not say what schema it was built against. The
information was there at expansion time and then thrown away, so a migration
planner had no "declared" side to compare against what is on disk, and a
designer could not draw a diagram of an application whose source it did not
have.

Every generated table now carries a `<NAME>_SCHEMA` const, named after
`<NAME>_VERSION` because it answers the question next to it: the version says
which schema, and this says what that schema is.

The stored form is the DSL text rather than a serialised structure. It needs no
format decision, keeps serde out of the dependency graph of every user's build,
is legible in a hex dump, and is read back by the same parser that read the
original. It is also, being a declaration, exactly what regenerates an old table
type, which is the hand-maintained `version_tables: { 1 => v1::UserV1WorkTable }`
that makes migrations something people put off. `dsl/tests/round_trip.rs` holds
the property this rests on against all 116 declarations in this repository, and
the tests here check the emitted const against the declaration it came from and
that the macro accepts it back.

In-memory tables get it too. A designer reading a crate wants every table, and
the const costs a string either way.

Two details worth the words. The second parse runs at the end of `expand` rather
than the start, so this function's diagnostics stay the ones a bad declaration
produces: both parses reject the same inputs, but only one of them knows to say
that a separate `attributes` section is not part of the 1.0 grammar. And the
const is `allow(dead_code)`, because a `worktable!` inside a function body puts
it inside that body, where nothing refers to it and `-D warnings` would fail a
user's build over a const they never asked for.
A designer opening a project, a documentation generator and a migration tool
comparing a checkout against a running database all start from the same problem:
a schema is written inside a `worktable!` invocation somewhere in a crate, and
there is no index of where.

`declarations_in_source` walks tokens rather than `syn`'s item tree, because an
invocation inside a function body is not an item and real code puts them there.
An item walk would quietly miss a table, and the caller would never learn a table
existed to be missed. Both delimiter forms are accepted: the repository uses
`worktable!( .. )` 83 times and `worktable! { .. }` 45 times, and a reader that
took only one would be wrong about the language.

The return is not a `Vec<Schema>`. Some invocations are not declarations: a
`macro_rules!` body writing `name: $name, ... using $backend` is a template whose
metavariables stand for text that exists only after the outer macro expands, and
counting those as failures would be wrong. Everything else that fails to parse is
reported with the text that failed, because a designer that silently drops a
table the compiler accepts is worse than one that says it could not read it.

The corpus round-trip test now goes through this, so it is also the evidence that
the scanner finds what is there: 116 declarations read, 12 templates set aside,
nothing rejected, across the whole repository.
The example on `PartitionSet::pinned` was `ignore`, so it never compiled. It
referenced `prices`, `batch` and `tick` without defining them, which is exactly
why it could not run - and it is the only documentation the API has.

An `ignore` example on a new public method is worse than none: it looks
checked, and it rots silently against the very signature it documents.

Made self-contained and executable - declares a partitioned table, inserts,
then shows the pin-once-read-many shape the surrounding prose argues for.
Four dependencies carried `=` requirements: `data_bucket`, `WorkTablesIndex`,
`indexset` and `worktable_codegen`. None of them needs to.

`ps-reclaim` is the exception that was never locked. A bare `"0.1.0"` already
means `^0.1.0`, so beta.16 picks up 0.1.1 on any fresh resolve. It moves to
`"0.1.1"` to raise the floor above the version whose `Guard` was `Send`, which
is a soundness bound rather than a lock: a resolve cannot land on the version
with the use-after-free window at all.

Caret on a `0.0.x` version describes the same set as `=`, so widening
`WorkTablesIndex` changes nothing until that crate reaches 0.1.0. Removing the
`=` still matters: the file stops implying a constraint it is not expressing.

`worktable_codegen` is the one that gives something up. The exact pin held the
macro and the runtime it generates calls into in lockstep, and a caret admits
later betas. A mismatched pair fails at expansion in a consumer rather than as
a resolver conflict here.

`docs/TODO.md` moves the two items that were listed as blocking beta.16, and
did not stop it, into a section recording how they closed. Re-measuring the
partition regression stays open, now against 0.1.1 rather than against the
guard size 0.1.1 removes, and the file says plainly which set of `partition_ref`
numbers not to reuse and why.
The DSL extraction added a third publishable crate to this workspace and
nothing published it. `worktable_codegen` depends on it by path with a
version requirement, so the first version-bumped merge after the
extraction would have failed the release after green CI:

    error: failed to prepare local package for uploading
    Caused by:
      no matching package named `worktable_dsl` found
      location searched: crates.io index
      required by package `worktable_codegen`

Reproduced with `cargo publish --dry-run -p worktable_codegen`. The
publish step now walks all three crates in dependency order, each with
its own already-published guard, so adding a fourth is one more line
rather than a rediscovery of this failure.

The three versions also disagreed: dsl at beta.14, codegen at beta.15,
worktable at beta.17. Three crates published from one repo on three
numbers is how a macro and the runtime it generates calls into drift
apart. They now move together at 1.0.0-beta.17.

The two intra-workspace pins go back to `=`. The caret is right for
every external dependency and wrong for these two: a mismatched
macro/runtime pair fails at expansion inside a consumer's build, which
is a worse place to find it than a resolver conflict here. That is the
one concession the caret change conceded in its own description.
… not one

Three of the six block parsers consumed the comma that may follow their
block and three did not. `parse_updates`, `parse_indexes` and
`parse_queries` did; `parse_deletes`, `parse_in_place` and
`parse_configs` did not, so `config: { .. },` reached the top-level
dispatch as a `,` token and died as "Unexpected identifier". Those three
blocks happen to be written last in every declaration in this
repository, which is the only reason nobody had hit it.

It matters now because the designer emits a declaration and parses it
back. An asymmetry between which blocks may carry a comma is a round
trip that fails on the tool's own output as soon as block order changes,
and block order is the sort of thing a visual editor changes freely.

Three `try_parse_comma()` calls, strictly more permissive: every form
that parsed before still parses.

The error message was the other half of the cost. A `,` reported as an
unexpected *identifier* names the wrong category of token and sends the
reader looking for a misspelled keyword. Four dispatch arms now print
the token they saw and the set they expected.

`dsl/tests/trailing_commas.rs` was checked against the pre-fix parser:
three of its five cases go red when the `try_parse_comma()` calls are
reverted. The two that stay green are the ones asserting the fix is
permissive rather than a new rule.
meh added 5 commits September 2, 2026 14:48
`worktable_dsl` could read a declaration and could not say whether the
macro would accept it. The rules lived in `worktable_codegen`, next to
the code they would have generated, and a proc-macro crate exports
nothing but macros, so the only way to ask was to expand. A designer
cannot expand a proc macro, and an editor that finds out by compiling is
not an editor.

The three validators move here unchanged, still operating on
`crate::model` types, which carry spans. `worktable_codegen` calls them
here and its diagnostics are identical, down to the span each error
points at. Same discipline as the parser extraction, same reason: a
second implementation drifts, and the drift shows up as a designer that
green-lights a table the compiler rejects.

`check()` is the designer's entry point. It returns the schema *and* the
diagnostics, because a declaration that breaks a rule is still a
declaration and an editor has to draw it in order to let anyone fix it.
Each diagnostic carries a stage: `Grammar` means there is nothing to
draw, `Rules` means there is a schema and the macro would refuse it.
That second state is the normal one in an editor and had no
representation at all before this, since `Schema::parse` returns `Ok`
for it.

It reports every broken rule rather than the first. The macro stops at
the first because it will not generate code either way; an editor has
the opposite economics, where fix-recompile-find-the-next is the loop a
live checker exists to remove. `index_backends_into` therefore collects
instead of short-circuiting, and `validate_index_backends` is a thin
first-error wrapper over it so the macro's behaviour is unchanged.

Spans come back as byte ranges on the diagnostic rather than as fields
on the IR. `Schema` stays plain data, serialisable and comparable across
processes, which was the whole point of it; the ranges live in the
result of the call that produced them, so a consumer that does not want
them does not carry them. Byte offsets rather than line and column: an
editor converts to whatever it needs, and a range survives being sent to
one that disagrees about what a column is.

They need `proc-macro2/span-locations`, and this crate is compiled for
the host as part of `worktable_codegen` before anything else in a
dependent's build. So it is an off-by-default `spans` feature, the same
argument that keeps `serde` off. Without it the span is `None` and the
messages are identical: a consumer degrades to file-level diagnostics
rather than losing them.

The span test asserts by slicing the source with the range rather than
by comparing offsets. An off-by-one in either direction produces a
plausible number and a wrong underline, and only the slice catches that.
The schema language has no foreign keys. A column called `project_id` is
a `u64` like any other, and nothing in WorkTable enforces, records, or
checks a relationship between two tables. `infer_relations` guesses those
links from a naming convention, and the guess is right often enough to
be useful and wrong often enough that presenting it as recovered fact
would make a designer lie about the schema.

So it is not the same kind of thing as the rest of this crate. Everything
else here has a single correct answer that WorkTable owns: what the
grammar accepts, what the macro rejects, what a schema change costs the
storage engine. The mapping between tables belongs next to whatever
application enforces the convention, which is not this one.

The Mermaid emitter and the relation guessing move behind an
off-by-default `uml` feature, and their tests move to a file gated on it.
Nothing is deleted: it is written, it is tested, and turning it on is now
a decision rather than a default. The crate docs say which layer is
which, so the next person building on this does not have to work it out
from the absence of a foreign key.

That also makes the feature set consistent. `serde`, `spans` and `uml`
are all off by default for the same reason: `worktable_codegen` depends
on this crate and is a proc macro, compiled for the host before anything
else in a dependent's build, so anything unconditional here is paid by
every WorkTable user's first compile to serve consumers who are not the
compiler.
`delete_many(keys)` and `delete_range(a..b)`, the bulk eviction a
consumer needs to bound state growth. Tracked as #78, and the blocker
for generational eviction in agentcode, whose durable state grows about
16.5 MB per generation with no way to drop one.

Deleting was already the cheap operation the issue asked for: the row is
marked deleted in place, its index entries come out, and the storage
becomes reusable once no reader can reach it, with vacuum compacting
pages later. Nothing about that model needed changing. What was missing
was a way to do it to many rows at once.

**What batching actually saves.** Not the per-row work: every index still
has to lose its entry, and that is most of the cost. What it saves is
the fixed cost paid per call, which is one lock acquisition over the
whole key set, one grace marker, and one reclaim pass. `Data::delete_many`
ghosts every link first and retires them behind a single marker, because
a link must not become reusable while a later row in the same batch is
still being marked, or a concurrent insert could claim it and be ghosted
by this call.

Measured on a 50,000-row table, scattered keys, three backends: batching
is a flat 1.3 to 1.4x over a loop of `delete` at every batch size from 1
to 100. It does not improve with batch size, and that is the honest
shape: the constant fraction is what batches away.

**`delete_range` is ergonomic, not faster.** It converges to `delete_many`
rather than beating it, and is slightly worse below a batch of 64. Two
rounds of optimisation went in before that was clear, and both were
worth keeping: it now takes the mutation guards *before* the walk that
reads the links, so one `O(log n + k)` walk replaces `k` lookups instead
of being added to them, and both paths now read rows at the link they
already hold rather than spending a second primary-key lookup in
`select`. The remaining per-row work is identical in both, so there is
nothing left for a span to exploit. Its value is that the caller does
not enumerate the keys.

**Not all-or-nothing, unlike `insert_many`.** A rejected insert has
published nothing, so unwinding restores a state that was real. A delete
that fails partway has already ghosted rows and removed their index
entries, and resurrecting them would mean republishing index entries for
storage queued for reuse. So the error reports how many succeeded. A key
that is not present is skipped rather than failing the batch: a caller
evicting a generation cannot know which keys a concurrent writer already
removed, and making them find out first is a race they cannot win.

The tests were checked against broken code before being trusted, and one
had to be rewritten to earn it. `deleted_rows_are_unreachable_through_every_index`
originally only read the deleted rows back, which passes with secondary
index removal deleted entirely: a ghosted row is filtered out of reads,
so a dangling index entry is invisible through `select` until the link is
reused. It now reclaims each deleted row's unique value, which a stale
unique entry rejects. Skipping secondary removal fails it and the reuse
test; skipping primary removal fails five.
`delete` is async and `delete_many`/`delete_range` were not, which is an
inconsistency I introduced yesterday rather than one I found.

The split has a cause: cell-level locking means an update waits on the
readers of the cells it touches, so `update`, `upsert`, `delete` and
`reinsert` are async, while `insert` has no existing cell to contend on
and the batch paths take the striped mutation gate rather than cell
locks. That is a real distinction and a bad thing to expose. A caller
cannot be expected to know which writes happen to need a cell lock, and
the penalty for guessing wrong is not a compile error: `let _ =
table.upsert(row)` builds a future, drops it, and the write never
happens.

These two await nothing today and say so. They are async because the
write surface should have one rule, and because they will need to wait
once they take cell locks rather than the striped gate.

`insert` and `insert_many` are the remaining exceptions and are not
touched here. Making them async does not stop at the API: the macro
composes `insert` internally, and `PersistenceTask::push` is synchronous
and inserts into WorkTable's own `QueueInner` table, so it reaches into
the persistence queue. That is its own change with its own review.
Concurrency coverage existed but was scattered and stopped at four
writers: `nonunique_arctic` races four against an arctic non-unique
index, `index_backends` recovers concurrent same-row updates, `vacuum`
runs a vacuum thread beside sequential inserts. Nothing raced writers
across all three backends, and nothing went above four.

Four is exactly the last writer count at which this engine still looks
healthy. Insert throughput is flat to four and collapses at eight, so
the one dial that would have exposed it was never turned.

Four tests per backend: eight writers losing no rows, inserts racing
deletes so an insert can claim storage a delete just freed, a contended
unique key admitting exactly one writer, and readers seeing consistent
groups while writers run. Congee appears throughout because these need
only a unique index; it has no non-unique backend.

Every shape is a parameter rather than a literal, which is the point:
hardcoding the writer count is how this hid.

    WT_CONC_WRITERS=32 WT_CONC_PER_WRITER=2000 cargo test --test mod concurrency
    WT_SCALE_SWEEP=1,2,4,8,16,32,64 cargo test --release --test mod insert_throughput -- --ignored

A malformed value is a hard error rather than a silent fallback: a typo
in `WT_CONC_WRITERS` that quietly runs the default is a run you believe
tested something it did not.

`insert_throughput_should_scale_past_four_writers` is `#[ignore]`d
because it fails, deliberately, recording the defect the way
`generator_determinism` records its own. Eight writers reach 0.20x of
single-writer throughput. It is not the index: all three backends
collapse to the same ~300 K/s, and arctic and congee are 1.3x faster
single-threaded before hitting the identical wall. A `sample` of the
eight-writer run puts the time in `RawRwLock::lock_exclusive_slow` and
`DataPages`, and `pages.rs` takes an exclusive write lock on the one
page named by `current_page_id`, so appends serialise by construction.
`EmptyLinkRegistry::pop_max` takes a global mutex on every insert even
when the free list is empty.

It refuses to run in a debug build. Per-operation overhead there swamps
the contention and the sweep reports eight writers as 2.79x *faster*
than one; a throughput assertion that passes for the wrong reason is
worse than none.
meh added 3 commits September 2, 2026 20:05
`DataPages::insert` calls `EmptyLinkRegistry::pop_max` on every insert,
and `pop_max` took a global `FairMutex` before looking at anything. An
append-only table therefore paid a contended mutex per row to discover
there was nothing to reuse.

`sum_links_len` already tracks the aggregate and is maintained on both
sides, so one relaxed load answers the question without touching either
lock.

Release build, 200,000 inserts, best of three, M4 Max:

| writers | before | after | |
| ---: | ---: | ---: | ---: |
| 1 | 1,216,309/s | 1,207,160/s | flat |
| 2 | 1,216,010/s | 1,326,333/s | 1.09x |
| 4 | 1,154,861/s | 1,425,324/s | 1.23x |
| 8 |   310,748/s |   802,077/s | **2.58x** |
| 16 |  272,121/s |   557,414/s | 2.05x |

Scaling at eight writers goes from 0.26x of single-writer throughput to
0.66x, and four writers now scales positively (1.18x) where it used to
degrade. So this mutex, not the page lock, was the dominant term. The
page lock is presumably the remaining ceiling: `insert` still takes an
exclusive write lock on the one page named by `current_page_id`, which
serialises appends by construction, and 0.66x is not 1.0x.

`Relaxed` is enough because the answer is a hint rather than an
invariant. A push landing concurrently can leave the load reading zero,
and the caller then appends a fresh row instead of reusing a link that
became available a moment ago: the same outcome as having called one
instruction earlier, with the link still registered for the next insert.
The reverse cannot happen, since the counter is only non-zero once a
link is registered, and the locked path re-checks regardless.

The failure this could cause is silent in both directions, which is why
it gets its own test rather than relying on the reuse tests. A counter
that drifts above zero costs a pointless lock; one that drifts to zero
while links remain stops reuse entirely, and inserts would simply append
forever, which is a correct way to insert. `the_pop_fast_path_agrees_with_the_registry`
pins the counter to the registry across a push/pop cycle, and was checked
against both drifts: never decrementing fails it, and never incrementing
fails it.
`assert!(!cfg!(debug_assertions))` is a constant assertion and clippy
rejects it; a bare `#[cfg] panic!` makes the rest of the function
unreachable and its imports unused. Reading the profile through a
function keeps one code path in both builds.
They reach the same striped mutation gate by different routes: `insert`
gates one key, `insert_many` gates its whole key set and sorts the
stripes so a batch and a single insert cannot deadlock. That ordering is
a claim worth holding down, and a deadlock shows up as a hang rather
than a failure, so the arms interleave on overlapping stripes instead of
staying politely apart.

The persisted path already had `batches_and_singles_interleave_through_the_engine`;
this is the in-memory half, across all three backends.
meh and others added 5 commits September 2, 2026 20:31
…e benches

Two changes, both about putting things where they belong.

**Key widths.** `validate_index_backends` rejects a declaration whose key
type the backend cannot serve, and the accepted sets are specific:
congee takes u8/u16/u32/u64/usize, arctic takes u16/u32/u64/u128. That
list is a promise the macro makes, and nothing tested it. The suite
instantiated u64 with both backends and u128 once, so most of the
advertised matrix had never been generated.

The backends test their own key widths in their own repositories, which
is the right place for the data structure and the wrong place for this.
What was unverified is not whether arctic handles a u16, it is whether a
*generated WorkTable over an arctic u16 index* stores, finds and removes
a row. Eleven cells, deliberately shallow: insert, select by that index,
a key that must not resolve, then delete. Depth belongs in the backend's
suite. All eleven pass, so this adds coverage rather than fixing a bug.

**The throughput sweep moves to wt-benchmarks.** It reported a curve
rather than asserting a property, which makes it a benchmark, and it had
to be `#[ignore]`d to live in a correctness suite at all. It is now
`cargo bench --bench insert_scaling` alongside a new `op_latency` bench
covering single insert, upsert, delete and select in both storage modes.

This file keeps the correctness tests, which is what it is for.
Reclamation frees links in retirement order, and that order is usually
contiguous: a range delete, or any workload deleting in primary key
order, frees a run of adjacent links. Pushed one at a time each link
pays its own coalesce, and a coalesce is up to two removals and one
insertion across three ordered containers behind a global mutex, so
freeing n adjacent links costs n times that.

`push_many` merges the batch against itself first, which turns a run
into a single insertion, and takes the lock once rather than n times.

The merge is a separate pure function because the saving it represents
is invisible in the registry's final state: coalescing per link and
coalescing per run leave identical contents and differ only in the work
done to get there. Deleting the self-merge leaves every registry-level
test green, so the test looks at the merge directly.
`reclaim_retired` needs to know whether a whole-page retirement is
queued behind the links it is about to free, and it answered that by
collecting every queued page into a set, reading the entire queue, on
every call. With a backlog of one that is a wasted allocation. With a
real backlog it is quadratic: draining 20k retirements one sweep at a
time spent almost all of its 780 ns per row in that rescan.

Pages are retired rarely, so counting them as they are queued makes the
common answer one relaxed load, and the scan happens only when a page
retirement is actually outstanding.

Freed links are now collected and restored together so the registry can
merge a contiguous run, and the buffer is flushed before a page is
purged: batching changes when links are restored, and the one ordering
that must not drift is restoring them after a page they belong to was
reset.

The existing whole-page test processes both retirements in one sweep and
stays green with the queued-page check disabled entirely. The new test
splits them across sweeps, which is what a capped sweep does anyway, and
fails without the check.
Calls the reusable workflow in api.crates.vip, which packages the crate,
derives its sparse-index entry from cargo metadata and writes both to
Tigris under conditional requests. Manual dispatch, because a published
version is immutable and cargo package embeds the git sha, so the same
version from a different commit is refused rather than replaced.
api.crates.vip is now crates.vip-backend, matching the fleet convention
where a backend is <domain>-backend. GitHub redirects the old path, but
a workflow reference should name the repo that exists.
meh added 28 commits September 3, 2026 11:59
Four reclamation defects were fixed this week and written out they are one
defect: an index entry resolving to storage that now holds a different row.
Each was found by a test that noticed the damage somewhere downstream, and
each needed its own investigation to get back to the cause.

This asserts the property rather than the symptom. After a storm of concurrent
inserts, deletes and upserts with vacuum running every 5ms: every reverse entry
resolves to a row carrying that key, forward and reverse agree on the same
storage, no two keys name the same storage, and every live row's unique
secondary entry points at that row's storage.

Run on all three primary backends, with the unique secondary following the
primary, so each arm exercises one implementation end to end rather than
resolving its secondaries through the same map in every case. The non-unique
index cannot follow, because congee holds no non-unique index at all.

The three-backend difference showed up immediately and twice: arctic and
congee expose no iterator, so a check written against WTI's `iter` does not
compile against them. Both checks are now written by probing keys, which is
the stronger property anyway: it asserts an entry points at *this* row's
storage rather than at some readable row.

**The teeth are unproven and this commit does not claim otherwise.** Reverting
the page-reclaim fix does not make it fail: four runs passed. The defect it is
written for only ever reproduced under full-suite machine contention, never in
isolation, so logical concurrency alone does not reach it. The value here is
the property being stated and checked at all, and a place for the next one in
this class to land. A deterministic test, inserting onto a page between its
snapshot and its reclamation, is still owed.
The registry coalesces adjacent freed links into runs and keeps them ordered
by absolute position in index_ord_links, and by size in length_ord_links.
Planning uses neither: get_per_page_info takes the op_lock, iterates every
entry in page_links_map, and rebuilds a per-page map from scratch each pass.

Working from runs changes what vacuum does rather than how it decides. To
reclaim a page today you move every live row on it, so the work is
proportional to how full the page is and a mostly-full page is skipped as poor
value even when one row is stranding a large gap. From runs, you move the rows
standing between two adjacent runs and one move merges them, so the work is
proportional to the stranding rows and the value is the run it creates.

The trigger can be reactive for the same reason. sum_links_len and item_count
are already maintained on every registration and removal, and after coalescing
the ratio between them is the fragmentation measure: the same free bytes in one
run is a healthy table and in five hundred runs is a fragmented one. No scan is
needed to know which, and length_ord_links already says whether a run big
enough for current allocations exists.

And the pass should stop being one lock. defragment holds lock_vacuum's write
side for its whole duration while pop_max takes the read side, so for the
length of a pass no insert can reuse a freed link. A pass whose purpose is to
make space reusable currently prevents space reuse while it runs.

Recorded honestly: this reduces how much relocation happens and how long
anything is held. It does not remove the recurring bug class, because a
relocation still rewrites index entries holding physical addresses. Fewer
relocations is fewer chances to get it wrong, not a guarantee.
Both could already iterate through `UniqueIndex::iter_values`. What they
lacked was the inherent `iter` the general-purpose backend has, which is a
difference in spelling rather than in capability, and it is the kind that
quietly decides what gets tested: code written against one backend does not
compile against the others, so it stays written against the one whose name the
author already knew.

That is not hypothetical. The vacuum invariant suite hit it twice while being
written, once on the primary index and once on the secondary, and the way
round it was to probe by key instead of iterating. Portable, and a weaker
check, chosen because of a method name.

Thin aliases, no new capability, no behaviour change: both delegate to
`iter_values`, which already materialises because the underlying maps hand out
entries borrowed from a guard. The invariant suite now iterates all three
directly, which is what it wanted to do in the first place.

A check that can only read one backend guards only one backend, and this table
has three.
Measured with wt-benchmarks' new vacuum-stress suite: a sweep at 60%
fragmentation costs 25-49% of insert throughput and doubles median insert
latency, for its entire duration. The same sweep at 25% costs between
nothing and 10%. The numbers and the method are in
docs/vacuum-design-directions.md.

The mechanism is the exclusion rather than the work. pop_max takes the
registry read side with try_read_owned().ok()?, so while a sweep holds the
write side every insert wanting reclaimable space is turned away and
allocates a fresh page instead. Inserts never blocked on vacuum; they lost
free-space reuse for as long as it ran.

Three changes follow from that:

The sweep is cut into batches that release the exclusion between them, so
reuse comes back in the gaps rather than after the whole table. Between
batches vacuum measures how much foreground demand it turned away and
stands down when the answer is a lot; the sample is taken across work that
was happening anyway, so asking costs nothing. A bound on consecutive
stand-downs keeps a permanently busy table from never being vacuumed,
which would trade a bounded slowdown for an unbounded one. VacuumGate is
the explicit form of the same thing, for a caller who knows something the
table cannot see.

The sweep is woken by tables freeing space rather than by a timer. A timer
cannot know when a table became fragmented; the registry can, because it
is the thing that grew. The interval stays as a fallback for a table whose
threshold is never reached. Wake granularity is bounded by the reclamation
backlog, since that is when freed links actually reach the registry.

Pages a ranged delete emptied are swept first. Every deferred reclamation
arrives batched, so the batch alone does not say a delete was ranged --
coalescing does, because a run only forms from links that were adjacent.
Those pages hold the concentrated free space, so a sweep that stands down
partway through has already done the valuable work.

Each behaviour has a test carrying both arms, because only the pair is
evidence: an unbatched sweep reuses nothing, an unreachable threshold
leaves the sweep parked, a scattered batch names no page. The persisted
test covers an interleaving that could not happen before, since an insert
can now claim a link on a page a sweep is still working through.
An ART orders by the bytes of the key, and two's complement puts that
ordering the wrong way round: a negative has its high bit set, so it sorts
after every positive. XOR with the sign bit corrects exactly that. It is a
bijection over the full width and strictly monotonic, which is the only
property the tree needs, so i64::MIN lands at 0 and i64::MAX at u64::MAX.

Being a bijection over the *whole* width is also what makes the exclusive
bounds correct: next/previous run in the raw space, and adjacency survives
because no raw value is unreachable.

Three places had to agree. ArcticKey now maps signed types. The generated
primary-key newtype delegates to its primitive's impl instead of asserting
the mapping is the identity, which is what the trait's own doc comment
said it was for. ArtPersistenceKey gained signed impls, without which a
persisted signed table could not be declared at all.

The macro and the editor-facing check had two hand-maintained copies of
the accepted key types. Widening one without the other gives a check that
accepts what the macro refuses, so codegen now reads the DSL's list.

Both failure modes are covered: no flip, and a flip applied on one side
only. The second is the one that matters, because point lookups keep
working and only ordering breaks. Verified by breaking each in turn.

i8 is absent because Arctic's narrowest raw key is u16. The sign flip in
ArtPersistenceKey is not load-bearing today and says so: every use is a
whole-record round trip and nothing sorts encoded bytes.
It fails about half the time on a real defect: two secondary-index event
ids are consumed without their events reaching the stream, and the batch
validator then defers on the gap forever, stalling persistence for the
rest of the table's life.

Not caused by batching the sweep. With batch_pages set to 0, restoring the
whole-table exclusion, it reproduces at the same rate. Concurrent inserts
during a sweep on a persisted table had simply never been covered, so
nothing had run this interleaving before.

Ignored rather than weakened, so the suite is green and the reproduction
survives. The doc comment carries the error and the un-ignore condition.
Concurrent inserts during a vacuum sweep stalled persistence for a table
permanently, about half the time:

    persistence stalled on primary index event gap:
    last applied Id(2938), next available Id(2940) (attempt 9);
    an event id was likely consumed without its event being queued

Nothing was consumed. Instrumenting the batcher showed the missing event
reaching batch preparation 33 times in one failing run. It was queued the
whole time; it was never applied.

Event ids are allocated during the index mutations, while an operation id
is a Uuid::now_v7 minted later at the push site, so two concurrent writers
can invert the two orders. Vacuum does it systematically because its update
lands on the sweep's destination page while inserts append to the current
page: 110 inversions in one run, in a regular alternating pattern. Batch
collection walks operation ids and grows the batch by data page, so the two
streams sit on pages it separates, and it advances past operations between
the ones it took. The op holding the needed id was never collected, every
retry rebuilt the same gapped batch, and the attempt budget then failed the
engine for good.

Page grouping is a write-batching optimisation, so after a few failed
attempts collection now takes the whole queue instead. That keeps the
guarantee that matters, because validate_events still refuses a stream with
a hole: a complete collection can only apply more of the contiguous prefix
than a partial one, never something unsafe.

The deterministic test does not need threads. Collecting one page also
takes a later operation and advances past it, so an operation between them
on another page is skipped and the walk then runs out — which the
page-limit growth cannot rescue, because the loop ended empty rather than
full. Without the fallback it reproduces the field error exactly.

The integration test that found this is un-ignored: 12 of 12 runs pass,
against 5 of 12 failing before.
The job hands the reusable workflow the registry's Tigris credentials, so
whoever can move `master` on crates.vip-backend can read them. A sha is the
only ref that cannot be moved under us.

All three `uses:` lines move together on purpose: they are one workflow
publishing one workspace, and a split pin means two versions of it.
The schema module said the validation rules live in `worktable_codegen`.
They live in `worktable_dsl::validate`, and codegen reads its lists from
there, which is the whole point: the macro and an editor calling `check`
cannot disagree about what is legal.

`emit_dsl` said `parse_configs` does not consume a trailing comma. It ends
with `try_parse_comma`, so one would be accepted. The emitter still omits
it, but for the real reason: `config` is last and nothing follows it.
One counter was answering two questions and getting both wrong.

A batch can be valid and still apply nothing: everything in it sat behind a
gap and was trimmed, and validation returns an empty batch rather than a
deferral. That is a success, so it reset `attempts` to zero. The
whole-queue fallback keyed off `attempts`, so in exactly the case it
existed for -- a stream stuck behind one missing operation -- a run of empty
successes kept the counter down and it never engaged. That is why the
previous fix took the failure rate from 5 in 12 to 1 in 14 rather than to
zero.

Widening now keys off a no-progress counter, which follows the applied
watermark. Only real progress resets it.

Giving up is now its own threshold and a much more patient one. A gap is
usually transient: the operation carrying the missing id has been pushed
but not batched, or its producer has not reached its push yet. Each
deferral sleeps 500ms, so eight cycles was about four seconds, which a
producer descheduled under load can lose. The engine then declared a
permanent bug over a slow thread. It now waits about a minute.

Tying both to one counter measured worse when tried, 3 failures in 13 runs,
because incrementing on empty successes engaged the fallback but also burned
the give-up budget. Splitting them is what the two jobs actually wanted.
Mutation guards are striped 64 ways, so any batch wider than that takes
every stripe. Holding them for the batch made a large delete a
stop-the-world for every other write on the table, and a range delete
dropping a generation is exactly that shape.

Guards are now taken a chunk at a time. Each chunk is self-contained: its
rows leave every index, are ghosted, and are queued for reclaim before its
guards drop, so no row is left out of the indexes and un-ghosted across a
boundary. Stripe ordering still holds, because a chunk releases before the
next is taken, so a batch delete and a batch insert still cannot deadlock.

`delete_range` narrows its second walk to the chunk's span rather than
repeating the whole range per chunk, keeping the walk proportional to the
chunk. The guarded-key filter now checks the chunk, since that is what the
guards cover.

`insert_many` is deliberately untouched: it is documented all-or-nothing
and unwinds a rejected batch, so its guards have to span it.

Measured: a concurrent writer landed 190 rows during a 20,000 row delete,
against 71 with whole-batch guards. That ratio is timing, so the test
asserts the thing chunking could actually break instead: a delete spanning
several chunks removes exactly its keys and leaves interleaved survivors
with their index entries intact.
`check` is the oracle an editor calls on every keystroke and a generator
validates against before emitting, so its contract is that bad input comes
back as a diagnostic and never as a panic. A panicking proc macro reports a
compiler-ICE-shaped error with no span, which is the worst way to tell
someone they typed a comma.

Two tests. The sweep applies the edits that actually happen while typing --
truncate, drop, duplicate, transpose, stray delimiter -- compounding them so
it reaches inputs several mistakes deep, over 10,000 inputs from a fixed
seed, so a failure is reproducible from the printed string. No dependency
and no fuzz harness: a four-line xorshift is enough and it stays
deterministic in CI.

The sweep will not find the interesting cases, though, because they *parse*
and are then semantically wrong, which is where the known panics lived: an
`.expect` reached after the grammar was already satisfied. Those thirteen
are written by hand, and they assert diagnostics rather than merely the
absence of a panic. Silently accepting one is the same defect wearing a
quieter face, since a generator trusting `check` would emit a declaration
that then fails to compile. That is the gap class from
worktable-check-gaps-2026-09-03.md, now guarded.

All thirteen already report. The panics found by hand earlier were closed by
this release's validation work; this keeps them closed.
The first vacuum-stress run showed multi-millisecond worst-case inserts in
every vacuum-off arm at 60% fragmentation, 32ms on wti and 41ms on arctic,
and this document argued from it that a sweep converts rare long stalls
into steady overhead. Re-run on an idle machine, every vacuum-off arm is
0.13 to 0.24ms. Those maxima were contention. The only multi-millisecond
tail in the clean run is on the vacuum-on side.

Two other readings change with it. The penalty at 60% is about 50% on all
three backends; the earlier -24.7% for arctic came from a vacuum-off
baseline depressed by the same contention, 1.23M against 1.82M idle. And
batching the exclusion did not reduce the penalty at all, at either
fragmentation level, which is what the clean pairing was run to settle.

The cost curve still stands and is still the argument for reacting to
fragmentation early: 2 to 12% at 25%, about half at 60%. The tail benefit
was not real.
Two versions of WorkTablesIndex in one graph split the type identity of
Pair and ChangeEvent. The build then fails with "expected
ChangeEvent<Pair<T, Link>>, found a different ChangeEvent<Pair<T, Link>>"
across dozens of unrelated-looking trait errors, none of which names the
actual problem.

This is not hypothetical. Pointing the local patch at WorkTablesIndex
0.0.10 today produced exactly that, because published data_bucket 0.5.5
still pulls 0.0.9. It costs an afternoon the first time and is invisible
until something is bumped.

The review asked for this guard rather than reverting the carets, and the
carets are right: the requirement is not too loose, the three crates simply
move as one train and nothing said so. Now one named failure says it.

The script was run as bash before committing, both ways: it passes on a
correct graph and exits 1 with the duplicate present.
Every earlier number was measured against published WorkTablesIndex 0.0.9,
data_bucket 0.5.5 and ps-reclaim 0.1.1. The local patch was not applying:
a patch does not force re-resolution when the lockfile already pins the
published versions, and wt-benchmarks is a separate workspace that never
inherited it either.

On the release stack the low-fragmentation cell changes completely. At 25%
the penalty is within noise on all three backends and straddles zero, two
arms reporting vacuum-on faster than off, which is the signature of an
effect below the measurement floor. The same cell measured 2 to 12% on the
published stack. The sweep is effectively free there.

At 60% nothing moves: about half on every backend, unchanged. That remains
the case batching did not help, and it is the one that needs a work-side
bound rather than more yielding.
A fixed 2ms backoff is the wrong shape for sustained load. Sixteen of them
is a 32ms pause and then a batch regardless, so a table under a writer
doing hundreds of inserts per millisecond still got a steady grind of row
moves and paid for them. Doubling turns sustained pressure into a low duty
cycle: a burst costs a few milliseconds, continuous load costs a sweep
every fraction of a second, and the bound on consecutive stand-downs still
guarantees the sweep proceeds.

Measured on the real dependency stack, 60% fragmentation, all three
backends:

    wti     -47.4% -> -18.7%   insert p50 2209ns -> 1125ns
    arctic  -50.9% -> -19.4%   insert p50 1959ns -> 1000ns
    congee  -50.4% -> -19.8%   insert p50 2000ns ->  959ns

Latency returns to the vacuum-off baseline, which is 1083/917/917ns. The
throughput penalty is what remains, and it is the work itself.

I had written that this case needed a work-side bound and that pacing could
not help it. That was wrong: the pacing was there and simply too shallow to
matter under load. The measurement was taken at machine load 18.5 against
8.1 for the run it is compared with, so conditions were worse, not better,
and the improvement is consistent to within 3 points across three backends.
The wake fires on the first crossing of the threshold, which during a
ranged delete is near its start. Sweeping there means competing with the
workload producing the garbage and compacting pages that are still being
emptied behind it: a moving target, and the worst possible moment to take
the exclusion. Reactive without a settle is just eager.

The signal is queued retirements, not the registry's byte total. That is
the part worth remembering: deletes queue their storage and only reach the
registry when the backlog flushes, so between flushes the bytes sit still
while deletes stream. A settle watching them calls the burst over in every
gap, which is exactly what the first version of this did and what the test
caught.

Capped, because under continuous delete load retirements never stop
arriving and an unbounded wait would defer the sweep forever.

The test drives deletes in the chunks a ranged delete arrives in and
asserts the sweep is not told to run until they stop. Without the settle it
fails.
This reverts commit fbedd4b. The measurement it claimed was noise.

That commit reported the 60% penalty moving from about -49% to about -19%
across three backends and read the consistency as evidence. It was taken at
machine load 18.5, and the effect was the machine starving the vacuum task,
not the backoff policy working. Re-run at load 5, the same code shows -44 to
-47%: unchanged.

A proper A/B at one load, fixed against exponential, is noise in both
directions: -12.8pp for congee at 60%, and +27.8pp for wti at 25%, where the
latter means vacuum running measured 28% *faster* than vacuum stopped. That
is impossible, so the run-to-run variance exceeds anything this benchmark can
resolve about pacing policy.

The reasoning behind the change may still be right: a fixed 2ms stand-down
capped at sixteen tries is an arbitrary 32ms ceiling, and sustained load
arguably wants a lower duty cycle than that. But it is a hot-path change to
reclamation with no evidence, and one arm suggested it was worse. It goes
back until there is an instrument that can tell.

The instrument is the actual gap. Each arm is a separate two-second run
against a separately built table, so the pairing does not remove the
variance it was meant to. Resolving a few percent needs repetitions and a
null arm, which is the next thing to build.
It was never user-facing in any useful sense, and exposing it invited the
one thing that defeats the design. Turn it down and the fallback timer wins
the wake's select every time, so the fragmentation threshold never triggers
anything and the settle never runs: the reactive sweep degrades silently
back into the polling loop it replaced.

That is not hypothetical. Every vacuum test and the vacuum-stress
benchmark set it to 5ms, which means none of them were measuring the
reactive path at all. The benchmark's own comment said the quiet part out
loud: "Hard, on purpose. A 60 second default would measure an idle table
and report that vacuum is free."

The fallback is now an internal constant. Tests that wanted an eager sweep
set wake_threshold_bytes instead, which is the reactive equivalent and
exercises the path that ships.
The message blamed "an event id was likely consumed without its event
being queued" and I spent a long time treating that as a guess, because
the first instance was an ordering problem where nothing was lost.

With the whole-queue fallback in place it is no longer a guess. A trace of
a live stall shows sixty consecutive deferrals over the same nineteen
operations, with the fallback engaged, so every queued operation was
collected and the stream is still gapped. The operation carrying the
missing id genuinely never reached the queue, and the producer is upstream
of the analyzer.

The message now carries the queued operation count and says that, so the
next occurrence points at the right half of the system instead of at this
one. The test's timeout also moves past the give-up budget, so a stall
surfaces as this diagnostic rather than as a bare Elapsed that says
nothing.
Cumulative sweeps, pages freed and bytes freed, exposed on the manager.

Cost is unreadable without this, and I proved it the hard way. A whole day
of vacuum cost numbers turned out to be measuring nothing: the sweep ran
zero times in every arm of the stress benchmark, so every penalty reported
was noise, including a 30-point "improvement" I attributed to a backoff
policy and committed twice.

A snapshot of the table cannot tell you this. It shows no reclaimable bytes
left whether the sweep ran and reclaimed them or never ran at all. Only a
cumulative count separates a reactive sweep that keeps firing from one that
fired once and never again, which is the question that matters for a
trigger driven by a threshold the workload itself drains.
A table's memory is its pages, so `allocated_pages` times the page length
is what it holds from the allocator, and `reusable_pages` is the part a
sweep has already given back.

Without these a vacuum cost figure cannot be checked. Reclaiming memory is
the entire point of a sweep, and a sweep that never runs looks exactly like
a sweep that is free: both report no overhead. Measuring cost alone is how
a day of vacuum numbers turned out to be describing a sweep that ran zero
times.
A partial return is not a return. The wake fires when a table *frees*
space, so a table that has gone quiet produces no more of them, and doing
one pass and going back to waiting left whatever that pass could not finish
sitting there forever. Measured with the new yield benchmark: 392 pages in
use where 196 would do, half the memory never given back, with the sweep
reporting success.

Now it keeps sweeping while there is something worth reclaiming: 197 pages
against an ideal of 196, the one extra being the current append page. The
vacuum-off arm still holds 392.

Three exits so it cannot spin or run away. Fragmentation falling below the
threshold, a pass that frees no pages, and a bound on consecutive passes.
That bound matters on persisted tables specifically: every reclaimed page
queues a durable-free barrier, so sweeping back to back queues work faster
than the persistence worker drains it and the on-disk state falls behind
the table in memory. The 5ms pause between passes hands the worker its
turn, and the cap ends the run rather than letting one table monopolise the
queue.

The foreground is unaffected while this happens: upsert p50 with vacuum on
lands within one timer tick of the arm with it off, in both directions, and
delete p50 is identical to the nanosecond.
Vacuum deferred on the rate of requests for reclaimable space. Deletes
never make one, and an upsert that fits in place does not either, so under
a load of exactly those the signal read idle and the sweep walked straight
in. Measured: 64 sweeps taken during a two-second upsert-and-delete burst,
costing 3.8 to 12.5% of foreground throughput against a null arm.

The signal is now mutation stripes in flight, read live from the lock map.
Every insert, delete and upsert passes through one of those gates, so it
cannot miss a workload the way a space-demand counter can. Each stripe is a
ticket lock, so a ticket handed out and not yet served is a writer inside
the gate or queued for it.

It waits rather than budgeting. A sweep that defers for a fixed span and
then forces itself in is not waiting its turn, it is queueing, and it takes
its cut from every burst that outlasts the span. There is no span here to
run out: it asks, and if the answer is yes it asks again, with the interval
doubling so a long burst is polled cheaply.

Three consecutive quiet samples are required before it goes in. One is not
enough, because under heavy writes the stripes are free for most of any
instant and a single look finds a gap immediately.

Result: sweeps during the load went from 64 to 0, and all 64 now happen
once the table is quiet. Memory returned is unchanged at 99.5%, 197 pages
in use against an ideal of 196.

The trade is real and deliberate. A gate that is paused, or a table that
never goes quiet, now holds the sweep indefinitely rather than letting it
through after a fixed number of tries. The gate test asserts that directly:
paused means held, and resumed means finished.
@pathscale
pathscale merged commit f1a58bb into master Sep 3, 2026
7 checks passed
@pathscale
pathscale deleted the release/beta17 branch September 3, 2026 20:23
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