Concrete errors, one write per run of pages, and the page stride as a parameter - #75
Concrete errors, one write per run of pages, and the page stride as a parameter#75pathscale wants to merge 10 commits into
Conversation
`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>
f7adec7 to
50b26dc
Compare
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
50b26dc to
ac914f2
Compare
Collapsed, 9 SeptemberDataBucket 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 Where the two lines disagreed, and how it wentThree conflicts were not symmetric, and taking either side wholesale would have silently reverted work:
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. QA75 tests, against 72 on the stride branch alone. The three extra are #74's, including It could not build anywhere before this
ps-st3 has to be repeated here, because Merge orderThis cannot merge until nagoya publishes 0.1.0, which cannot happen until ps-st3 publishes 0.5.1. Delete both |
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.
PAGE_SIZEhas carried a// TODO: Move to configsince it was written, and every offset in the crate was computed from it. This makes the stride a parameter, which is what WorkTable needs beforepage_sizecan 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_relativelyandseek_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 au32because WorkTable'sSpaceDatais already generic overconst PAGE_SIZE: u32, and converting ausizein generic argument position needs unstablegeneric_const_exprs.Three latent bugs this uncovered, all silent
parse_data_pageandparse_data_pages_batchalready took aconst PAGE_SIZEand never used it.parse_data_page_in_placeignores 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_placechecked its write againstINNER_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_utilityhad the same fault.check_value_write_boundsmeasured 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_valuehad 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_sizework for a persisted table.🤖 Generated with Claude Code