Skip to content

Concrete errors, one write per run of pages, and the page stride as a parameter - #75

Open
pathscale wants to merge 10 commits into
masterfrom
feat/tunable-page-stride
Open

Concrete errors, one write per run of pages, and the page stride as a parameter#75
pathscale wants to merge 10 commits into
masterfrom
feat/tunable-page-stride

Conversation

@pathscale

@pathscale pathscale commented Sep 9, 2026

Copy link
Copy Markdown
Owner

PAGE_SIZE has carried a // TODO: Move to config since it was written, and every offset in the crate was computed from it. This makes the stride a parameter, which is what WorkTable needs before page_size can mean anything for a persisted table.

Four functions do the offset arithmetic, so those four are the whole of the change: page_start_offset, seek_to_page_start, seek_to_page_start_relatively and seek_by_link. The persist and parse helpers above them pass a stride through rather than reaching for the constant. Index pages take it too. The stride is a u32 because WorkTable's SpaceData is already generic over const PAGE_SIZE: u32, and converting a usize in generic argument position needs unstable generic_const_exprs.

Three latent bugs this uncovered, all silent

parse_data_page and parse_data_pages_batch already took a const PAGE_SIZE and never used it. parse_data_page_in_place ignores the parameter and the seek underneath went to the crate constant, so a caller passing a different size would have read at the wrong stride and been told nothing.

persist_page_in_place checked its write against INNER_PAGE_SIZE, the crate default. A table with a larger page could not have filled it; a table with a smaller one would have overrun in silence. IndexPageUtility::persist_index_page_utility had the same fault.

check_value_write_bounds measured an index slot write against the crate default too. At a smaller page that let the write run past the end of the page and into its neighbour, and return success. The corruption then surfaced as the next page failing to parse, a long way from the cause. UnsizedIndexPage::persist_value had the same check, where a value filling tail-first would instead run back through this page's header into the previous page.

The test

Three pages written at half the default stride and read back, then the same file read at the default stride with page 1 required not to be found there. Without that second half a seek that ignored its parameter would pass the round trip.

72 tests pass, clippy clean.

Paired with pathscale/WorkTable#102, which cannot compile without this. Together they make page_size work for a persisted table.

🤖 Generated with Claude Code

meh and others added 6 commits September 7, 2026 21:04
`eyre::Report` was in the return type of every fallible function here, so the
crate reached `std` through its own signatures. Nothing about page framing
needs an operating system: the layout is bytes and the checks are arithmetic.
This is the type that lets the rest of the crate say so, and it is the
prerequisite for the file access moving behind a trait - doing that first
would buy nothing while every signature still named a `std` type.

The enum is also more useful than formatted prose. A caller could not tell
"this page is full" from "these bytes are damaged", because both arrived as a
`Report` carrying a string. Six variants now carry the numbers that justify
them: LinkLengthMismatch, LinkOutOfBounds, PageOverflow, Corrupt, Encode, Io.

    18 construction sites, 7 files, no .context or .wrap_err chains to unpick
    Io holds raw_os_error rather than an io::Error, which is the std type this
    change exists to stop depending on

Three tests asserted on eyre's message strings and now match on variants with
their fields, which is what the change is for.

    cargo test     69 + 2 passed, 0 failed
    clippy         clean

**This breaks consumers**, so 0.6.0 rather than 0.5.8. Not every `?` converts:
a function returning `persist_page(..).await` in tail position needs `?` and
an `Ok(())`, and `error.wrap_err(..)` has to become
`eyre::Report::new(error).wrap_err(..)`. WorkTable needs exactly three such
edits, verified by building it against this branch; the patch is not applied
there because that checkout has another agent's uncommitted work in it.
Three changes to the write path, none of which change a byte of what lands on
disk. There are two new tests holding that.

**`persist_page` no longer calls `stream_position`.** It seeks to the page
start, writes, and then has to pad to the next page boundary, and to do that it
asked the operating system where the cursor had got to. The number was already
in hand: `persist_page_in_place` computes the inner length two lines earlier and
the header length is right there. It returns the total now and the padding is
arithmetic.

The call is not new and nobody introduced it recently: `git log -S` puts it in
`c33dfd1`, "finalise persist methods", November 2024. It was defensible when
both writes were inline in `persist_page` and a later refactor could have
changed what got written; `863e3b5` in June 2025 is that refactor, and after it
the lengths were genuinely hidden inside the helper. Returning them gives them
back.

**`persist_pages_batch` writes a run of consecutive pages in one call.** Every
page occupies exactly `PAGE_SIZE` at a known offset, so a run can be laid out in
memory and handed over once instead of seeking and writing per page. A run ends
wherever the page ids stop being consecutive, so a caller passing a
non-contiguous batch still gets a correct file, one write per run.

**`seek_to_page_start_relatively` is gone.** It asked the file where it was and
then seeked by the difference, arriving at exactly the offset
`seek_to_page_start` reaches in one call. Two system calls for the same place,
on both batch read paths as well as the write one.

Measured on the real async path, `examples/write-pages.rs`, 6,400 pages and
104.9 MB, interleaved over two passes:

                            before              after
    persist_page        235.5, 233.1 ms    180.5, 180.0 ms    1.30x
    persist_pages_batch 201.4, 195.7 ms     49.4,  36.2 ms    4.1 to 5.4x

Old one-at-a-time against the new batch is about 5.4x.

The two new tests are the ones that matter. `a_batch_writes_what_one_at_a_time_writes`
compares the two files byte for byte, and caught the first version of this
padding the final page: writing one at a time only *seeks* past the last page,
and a seek past the end of a file does not extend it, so the file ends at the
last page's content rather than on a boundary. `a_batch_with_a_gap_puts_each_page_at_its_own_offset`
holds the run-breaking behaviour, including that the skipped pages stay zero.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three things the first version of this branch got wrong or left open, found by
asking what it does where it actually runs: a Linux VM writing local pages that
are then synced to Tigris.

**A `usize` underflow.** `PAGE_SIZE - written` is fine while a header
serialises to exactly `GENERAL_HEADER_SIZE`, and is a panic in debug and an
absurd seek in release the moment one does not. The inner length is already
guarded, so reaching it needs a header change, which is exactly the kind of
change that arrives without anyone thinking about this function. It is
`checked_sub` now and returns `PageOverflow`. The old code had the same hole
with a different ending: it computed the padding in `i64` and would have seeked
*backwards* over the page it had just written.

**An unbounded buffer.** A run of ten thousand pages was a 160 MB allocation.
On a VM whose memory is not ours to spend that is not a trade worth making for
a few more megabytes per write, so a run flushes every 512 pages, which is
8 MiB. It costs nothing measurable: 47.7 ms against 49.4 and 36.2 before the
bound.

**And one behaviour difference that is real and now written down.** Writing
page by page *seeks* over the space between a page's content and the next
page's start, so on a filesystem with holes that space is never allocated.
Writing a run in one call puts explicit zeroes there. The bytes read back are
identical, which is what `a_batch_writes_what_one_at_a_time_writes` holds; what
differs is blocks allocated, and on `ext4` a file of half-empty pages will now
occupy what it claims to. Data pages are full and pad to nothing; index and
space pages do not.

`a_run_longer_than_the_buffer_bound_still_joins_up` is the guard on the bound:
520 pages, crossing a seam, byte for byte against writing them one at a time.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The benchmark opened every file with `truncate(true)` and wrote ids 0..6400
in order, so both arms rewrote a whole file from empty. That is the batch
path's best case: one uninterrupted consecutive run, which is exactly what
the coalescing was written for and close to the least of what a database
does.

The new arm updates every tenth page of a file that already exists. The run
breaks at every page, so the batch path falls back to one write per page and
can only win by what it saves per page. It does: 1.28x, and the saving is
the removed `stream_position` call rather than any joining up. Against 7.7x
on the whole-file arm, and at 352 MB/s against 4019, a scattered page also
costs more than a sequential one before either path helps it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The 5.6x for `persist_pages_batch` is one call carrying a whole 104 MB file.
Swept against the sizes a caller actually passes, it is 0.98x at one page,
1.08x at sixteen, and does not reach 2x until 256. Below about sixty-four
pages both paths sit at 4-5 ms on both versions, which is `sync_all` and not
the write path: there is nothing there for batching to save.

So the change is worth what the callers make it worth, and today two of the
three pass `HashMap::values()`, whose order is arbitrary, so their runs break
at every page and they would see 1.0x at any size.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Run in a fixed order, whichever arm goes second inherits a file the first
just wrote. That is what made an fsync'd write measure faster than an
undurable one in another benchmark this morning, and this one had the same
shape: the batch arm always ran second.

Reversed, it holds - 3272 and 4167 MB/s batch-first against 2023 and 3961
batch-second - so the order was not flattering it. What the check did show
is that the arm swings 2023 to 4167 MB/s across runs, so it is a 2 to 4 GB/s
range and not the single figure it had been quoted as. The other arms are
steady to a few percent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Collapses what was PR #75 onto the error and write-path work already on
this branch, so DataBucket carries one pull request.

The stride stops being a crate constant and becomes a `const STRIDE: u32`
threaded through every seek, persist and parse. Three faults had to be
found to get there, all silent: index wrappers that took no stride and
compiled because a default supplied one, a slot-bounds check measured
against the crate default rather than the page's own budget, and one of
two generator emissions hardcoding the constant.

The file stops being a concrete type. `AsyncFile` is `nagoya::io::File`,
which is the point at which `std::io::Error` leaves this crate entirely:
`error::Error` now converts from `nagoya::io::Error` rather than from
`std::io::Error`, which is what the module documentation already claimed
and could not deliver while the crate opened files itself.

Three places where the two lines of work disagreed, resolved toward
keeping both rather than either:

* `persist_page` keeps the arithmetic padding and does not go back to
  asking the file its position, but computes against `STRIDE` instead of
  the constant. `seek_to_page_start_relatively` stays deleted.
* `persist_page_in_place` keeps returning bytes written, and gains the
  stride parameter.
* `Error::Io` carries `nagoya::io::ErrorKind` instead of a raw OS code,
  because nagoya's error has no code to carry. Mapping it to `None` would
  have compiled and made every file failure indistinguishable.

`[patch.crates-io]` pins nagoya and ps-st3 by git. Without them this
branch resolves nowhere, because nagoya is unpublished and `[patch]` does
not reach a dependency's own patches. Both blocks go when those publish.

    cargo test                        73 + 2 passed
    cargo check --all-targets         clean
    cargo check --no-default-features clean
    cargo fmt --check                 clean
@pathscale
pathscale force-pushed the feat/tunable-page-stride branch from 50b26dc to ac914f2 Compare September 9, 2026 08:58
@pathscale pathscale changed the title Take the page stride as a parameter instead of a crate constant Concrete errors, one write per run of pages, and the page stride as a parameter Sep 9, 2026
@pathscale

Copy link
Copy Markdown
Owner Author

Collapsed, 9 September

DataBucket now carries one PR. #73 (concrete errors, 0.6.0) and #74 (one write per run of pages) are closed and their commits are on this branch, with the page-stride work rebased on top. Seven commits, linear, no merge commit.

I did this as one resolution against the final states rather than replaying nine commits, because both lines of work rewrote the same functions in page/util.rs. Replaying would have meant resolving the same persistence conflict nine times against intermediate states that never existed, which is how a blanket edit once put the wrong constant into 60 sites here.

Where the two lines disagreed, and how it went

Three conflicts were not symmetric, and taking either side wholesale would have silently reverted work:

  • persist_page. Stop asking the file where it is, and write a run of pages in one call #74 replaced stream_position with arithmetic padding, worth about 14% of the write over 6,400 pages; the stride branch still asked the file where it was. Kept the arithmetic, computed against STRIDE. seek_to_page_start_relatively stays deleted, which is the whole point of "stop asking the file where it is".
  • persist_page_in_place. Stop asking the file where it is, and write a run of pages in one call #74 changed it to return bytes written. The stride branch returned (). Kept usize, added the stride parameter.
  • Error::Io. It held a raw OS code from std::io::Error. nagoya::io::Error has no OS code, so mapping it to code: None would have compiled and made every file failure identical. It carries nagoya::io::ErrorKind instead: "no such file" and "permission denied" are the two an operator most needs told apart.

That last one is where the two PRs actually meet. #73's own comment read "not gated yet: the crate still reaches the file system directly. When that moves behind a trait this impl goes with it." This is that. std::io::Error is now absent from the crate, which #73's module documentation claimed and could not deliver alone.

QA

cargo test                        73 + 2 passed
cargo check --all-targets         clean
cargo check --no-default-features clean
cargo fmt --check                 clean

75 tests, against 72 on the stride branch alone. The three extra are #74's, including a_batch_writes_what_one_at_a_time_writes, which holds that coalescing a run is byte-identical to writing page by page. That test passing is the evidence the merge kept #74's semantics rather than just its lines.

It could not build anywhere before this

nagoya = "0.1" is not on the registry, so the branch resolved nowhere: reviewer, fresh clone and CI all failed before compiling. There is now a [patch.crates-io] pinning nagoya and ps-st3 by git.

ps-st3 has to be repeated here, because [patch] only takes effect from the workspace root, so nagoya's own pin does not reach a consumer. WorkTable will need all three.

Merge order

This cannot merge until nagoya publishes 0.1.0, which cannot happen until ps-st3 publishes 0.5.1.

ps-st3 #4  ->  0.5.1  ->  nagoya #1  ->  0.1.0  ->  this  ->  publish  ->  WorkTable #102

Delete both [patch.crates-io] blocks as those land.

meh and others added 3 commits September 9, 2026 16:02
CI runs `cargo clippy --all-targets --all-features -- -D warnings`, which
is stricter than the check I verified this branch with. The lint is in the
sweep example and predates the collapse.
nagoya 0.1.0 and ps-st3 0.6.0 are on crates.io, which is what the block was
waiting for. A caret on each replaces it. [patch] only takes effect from a
workspace root, so this block never reached anyone depending on this crate; it
made the branch build in this checkout and nowhere that mattered.
Applied from `databucket-pr75-source-fixes.patch` in ~/code/patch, whose
author states that nothing was compiled or run. It is compiled and run now:
the crate builds and all 79 tests pass, including the four new layout tests
the patch brings.

Checked header subtraction, link bounds, logical-layout validation and
declared read-length checks, so an invalid stride or a logical budget that
crosses a page boundary fails before anything seeks or writes rather than
underflowing.

Page framing no longer inherits the file's mutation and durability
requirements. `AsyncFile` splits into `AsyncRead` and `AsyncWrite`, so a
decoder asks for read and seek and nothing else, and a caller can hand one a
read-only adapter. Mixed operations keep the combined bound.

Batch buffering is bounded to 8 MiB or one oversized page, computed index
slots are capped at `u16::MAX`, and an oversized direct `IndexPage`
construction is rejected before it allocates.

The capability split is a narrowing of `AsyncFile`, so code that used it as a
proxy bound for `sync_all`, `sync_data` or `set_length` has to ask for those
explicitly now. WorkTable does not: it names `nagoya::io::File` directly at
each of those call sites, so nothing there had to change.
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