store: a chain — the arrangement vocabulary's first producer and first consumer
`WordFlags`' arrangement bits (START_RECORD, CONTINUATION, END_RECORD) have been allocated
and unread for as long as they have existed. This is the smallest closed loop that makes
them real, as `DESIGN-flag-vocabularies.md` §7 designed it: a value split across records at
consecutive `z`, and put back together.
The caller splits, because only the writer knows what its bytes are and where the natural
boundaries lie; this module joins, because that is the half that needs the store's own
addressing to do cheaply. It is a convention over the `Store` trait rather than a new store:
the slices are ordinary records and nothing is added to the format. Consecutive `z` rather
than a pointer or a manifest, for the reason the whole substrate rests on — a coordinate is
an address, not a path, so the reader computes `z+1` and looks it up, and there is no second
thing to keep consistent.
The failure modes are the point, and they are gated rather than asserted:
* a chain that does not terminate fails at MAX_SLICES instead of reading a device to its
end — the walk's old lesson applied before the mistake instead of after it;
* a hole fails the WHOLE read, because a chain with a missing slice is a corrupt value and
not a shorter one, and a prefix would hand a caller something that looks smaller rather
than broken;
* a slice inside a chain is refused as an address, because the chain has one address;
* a zero-length chunk is refused rather than looping;
* an absent chain is `None`, which is an answer, and not an error.
Seven tests, all against MemStore: a round trip marked byte-exact at every slice, a
single-slice value carrying START|END (which is what a self-contained record is), an empty
value as one slice, and the four refusals above.
`DEFAULT_CHUNK` is a megabyte, chosen against the WRITE cost rather than the read one: a
durable write here is 8.2 ms and almost all of it is fsync, so 4 KiB slices would turn a
15 MB image into 3,700 durability boundaries, about thirty seconds, where 1 MiB slices are
15 writes and about 120 ms. The addressing is what makes large slices possible; the
durability boundary is what makes them necessary.
This commit is contained in:
@@ -0,0 +1,242 @@
|
||||
//! A value split across records, and put back together.
|
||||
//!
|
||||
//! This is the arrangement vocabulary's first producer and first consumer — and the first use of
|
||||
//! `WordFlags`' arrangement bits (`START_RECORD`, `CONTINUATION`, `END_RECORD`) anywhere in the tree,
|
||||
//! after a long time in which they were allocated and unread. The design is
|
||||
//! `DESIGN-flag-vocabularies.md` §7; this module is the smallest closed loop that makes it real.
|
||||
//!
|
||||
//! **Why it exists.** A record's value is bounded by what a caller hands the syscall in one go, and
|
||||
//! some things are larger — an initramfs, an image, a database file. The alternative to "they do not
|
||||
//! live in the store" is a *chain*: the value is written as N records, each carrying its place in the
|
||||
//! chain, and a reader reassembles them.
|
||||
//!
|
||||
//! **Who splits, and who joins.** The caller splits: only the writer knows what its bytes are and
|
||||
//! where the natural boundaries lie, and a layer that chose them would be inventing a chunking for
|
||||
//! data it cannot see. This module is therefore a *convention over the `Store` trait* rather than a
|
||||
//! new store: it reads and writes ordinary records and adds nothing to the format.
|
||||
//!
|
||||
//! **Where the next slice is: consecutive `z`, at the same `(space, x, y)`.** Not a pointer inside
|
||||
//! the value and not a manifest record — arithmetic, because *a coordinate is an address, not a
|
||||
//! path*. A reader computes the next coordinate and looks it up; nothing has to be stored to find it,
|
||||
//! and nothing becomes inconsistent if a slice is moved, because slices are addressed rather than
|
||||
//! linked.
|
||||
//!
|
||||
//! **What a reader can rely on, and what it does when it cannot.** `START_RECORD` on the first
|
||||
//! slice, `CONTINUATION` on every slice that is followed by another, `END_RECORD` on the last — so a
|
||||
//! single-slice value carries `START_RECORD | END_RECORD`, which is what a self-contained record is.
|
||||
//! A chain that does not terminate is an **error**, not a read to the end of the device: the bound is
|
||||
//! [`MAX_SLICES`], and exceeding it fails loudly. A missing slice fails the **whole** read, because a
|
||||
//! chain with a hole is not a shorter value, it is a corrupt one, and returning the prefix would hand
|
||||
//! a caller something that looks smaller rather than broken.
|
||||
//!
|
||||
//! **One slice size that matters.** A durable write is fsync-bound — 8.2 ms measured on the box — so
|
||||
//! a chain costs one fsync *per slice*. That is why [`DEFAULT_CHUNK`] is a megabyte and not a page:
|
||||
//! at 4 KiB a 15 MB image is 3,700 fsyncs, about thirty seconds, while at 1 MiB it is 15 writes and
|
||||
//! about 120 ms. The addressing is what makes large slices possible; the durability boundary is what
|
||||
//! makes them necessary.
|
||||
|
||||
use crate::{Coord, Store, StoreError};
|
||||
use cube_core::{Point, WordFlags};
|
||||
|
||||
fn io(what: String) -> StoreError {
|
||||
StoreError::Io(std::io::Error::other(what))
|
||||
}
|
||||
|
||||
/// The most slices one chain may hold.
|
||||
///
|
||||
/// The failure this bounds is a chain that never ends: a reader that follows `CONTINUATION` without
|
||||
/// a ceiling is a read that walks a device to its end, which is the same class of mistake as a walk
|
||||
/// with no end signal. It is generous — at the default chunk it is 64 GiB — because it exists to
|
||||
/// stop a runaway, not to express a policy about size.
|
||||
pub const MAX_SLICES: usize = 1 << 16;
|
||||
|
||||
/// The default slice size: a megabyte.
|
||||
///
|
||||
/// Chosen against the *write* cost rather than the read one. A durable write on this machine is
|
||||
/// 8.2 ms and is almost all `fsync`, so slices that are too small turn one artefact into thousands
|
||||
/// of durability boundaries. See the module note.
|
||||
pub const DEFAULT_CHUNK: usize = 1 << 20;
|
||||
|
||||
/// Write `value` as a chain starting at `at`, slicing every `chunk` bytes.
|
||||
///
|
||||
/// Slices go to consecutive `z` at `at`'s `(space, x, y)`, so the chain has exactly one address:
|
||||
/// `at`. Returns the number of slices written.
|
||||
pub fn put_chain<S: Store>(
|
||||
store: &mut S,
|
||||
at: &Coord,
|
||||
value: &[u8],
|
||||
chunk: usize,
|
||||
) -> Result<usize, StoreError> {
|
||||
if chunk == 0 {
|
||||
return Err(io("a chunk of zero bytes cannot make progress".into()));
|
||||
}
|
||||
// An empty value is a value: one slice, carrying nothing, marked as a whole record.
|
||||
let slices: Vec<&[u8]> = if value.is_empty() {
|
||||
vec![value]
|
||||
} else {
|
||||
value.chunks(chunk).collect()
|
||||
};
|
||||
if slices.len() > MAX_SLICES {
|
||||
return Err(io(format!(
|
||||
"a chain of {} slices exceeds the {MAX_SLICES}-slice ceiling; raise the chunk size",
|
||||
slices.len()
|
||||
)));
|
||||
}
|
||||
|
||||
let count = slices.len();
|
||||
for (i, slice) in slices.iter().enumerate() {
|
||||
let mut flags = 0u16;
|
||||
if i == 0 {
|
||||
flags |= WordFlags::START_RECORD;
|
||||
}
|
||||
if i + 1 < count {
|
||||
flags |= WordFlags::CONTINUATION;
|
||||
}
|
||||
if i + 1 == count {
|
||||
flags |= WordFlags::END_RECORD;
|
||||
}
|
||||
let coord = Coord {
|
||||
space: at.space,
|
||||
point: Point::new(at.point.x, at.point.y, at.point.z + i as u64),
|
||||
};
|
||||
store.put_flagged(coord, slice.to_vec(), flags)?;
|
||||
}
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
/// Read the chain that starts at `at`, reassembling it in `z` order.
|
||||
///
|
||||
/// `Ok(None)` means there is no record at `at` at all. A coordinate that holds a slice *inside* a
|
||||
/// chain is an error rather than an empty answer: the chain has one address, and asking a different
|
||||
/// question of a slice is a caller's mistake worth naming.
|
||||
pub fn get_chain<S: Store>(store: &S, at: &Coord) -> Result<Option<Vec<u8>>, StoreError> {
|
||||
let first = match store.get_flagged(at)? {
|
||||
Some(record) => record,
|
||||
None => return Ok(None),
|
||||
};
|
||||
if first.flags & WordFlags::START_RECORD == 0 {
|
||||
return Err(io(format!(
|
||||
"{at:?} carries CONTINUATION without START_RECORD: it is a slice inside a chain, and a \
|
||||
chain has one address — its first slice's"
|
||||
)));
|
||||
}
|
||||
|
||||
let mut out = first.value;
|
||||
let mut flags = first.flags;
|
||||
let mut z = at.point.z;
|
||||
let mut count = 1usize;
|
||||
|
||||
while flags & WordFlags::END_RECORD == 0 {
|
||||
if count >= MAX_SLICES {
|
||||
return Err(io(format!(
|
||||
"the chain starting at {at:?} passed the {MAX_SLICES}-slice ceiling without an \
|
||||
END_RECORD: refusing to read on, because a chain with no end is a read with no end"
|
||||
)));
|
||||
}
|
||||
z += 1;
|
||||
count += 1;
|
||||
let next = store
|
||||
.get_flagged(&Coord {
|
||||
space: at.space,
|
||||
point: Point::new(at.point.x, at.point.y, z),
|
||||
})?
|
||||
.ok_or_else(|| {
|
||||
io(format!(
|
||||
"the chain starting at {at:?} has a hole: slice {count} is missing from z={z}, \
|
||||
and a chain with a hole is a corrupt value rather than a shorter one"
|
||||
))
|
||||
})?;
|
||||
if next.flags & WordFlags::CONTINUATION == 0 && next.flags & WordFlags::END_RECORD == 0 {
|
||||
return Err(io(format!(
|
||||
"the chain starting at {at:?} runs into a record at z={z} that belongs to no chain \
|
||||
(neither CONTINUATION nor END_RECORD): refusing to guess where the value ends"
|
||||
)));
|
||||
}
|
||||
out.extend_from_slice(&next.value);
|
||||
flags = next.flags;
|
||||
}
|
||||
Ok(Some(out))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::MemStore;
|
||||
use cube_core::SpaceId;
|
||||
|
||||
fn at(z: u64) -> Coord {
|
||||
Coord {
|
||||
space: SpaceId::ROOT,
|
||||
point: Point::new(7, 9, z),
|
||||
}
|
||||
}
|
||||
|
||||
fn slice<S: Store>(store: &S, z: u64) -> (u16, Vec<u8>) {
|
||||
let r = store.get_flagged(&at(z)).expect("read").expect("present");
|
||||
(r.flags, r.value)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_chain_round_trips_byte_exact_and_marks_every_slice() {
|
||||
let mut store: MemStore = MemStore::new();
|
||||
let value: Vec<u8> = (0..32u8).collect();
|
||||
let n = put_chain(&mut store, &at(0), &value, 8).expect("put");
|
||||
assert_eq!(n, 4);
|
||||
|
||||
let (f0, s0) = slice(&store, 0);
|
||||
let (f1, _) = slice(&store, 1);
|
||||
let (f3, s3) = slice(&store, 3);
|
||||
assert_eq!(f0, WordFlags::START_RECORD | WordFlags::CONTINUATION);
|
||||
assert_eq!(f1, WordFlags::CONTINUATION);
|
||||
assert_eq!(f3, WordFlags::END_RECORD);
|
||||
assert_eq!(s0, &value[..8]);
|
||||
assert_eq!(s3, &value[24..]);
|
||||
|
||||
assert_eq!(get_chain(&store, &at(0)).unwrap().unwrap(), value);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_single_slice_is_a_whole_record() {
|
||||
let mut store: MemStore = MemStore::new();
|
||||
put_chain(&mut store, &at(0), b"small", DEFAULT_CHUNK).expect("put");
|
||||
let (flags, _) = slice(&store, 0);
|
||||
assert_eq!(flags, WordFlags::START_RECORD | WordFlags::END_RECORD);
|
||||
assert_eq!(get_chain(&store, &at(0)).unwrap().unwrap(), b"small");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_value_is_one_slice_that_reads_back_empty() {
|
||||
let mut store: MemStore = MemStore::new();
|
||||
assert_eq!(put_chain(&mut store, &at(0), b"", 8).unwrap(), 1);
|
||||
assert_eq!(get_chain(&store, &at(0)).unwrap().unwrap(), Vec::<u8>::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hole_fails_the_whole_read() {
|
||||
let mut store: MemStore = MemStore::new();
|
||||
put_chain(&mut store, &at(0), &[1u8; 32], 8).expect("put");
|
||||
store.delete(&at(1)).expect("delete");
|
||||
let err = get_chain(&store, &at(0)).expect_err("a holed chain must not answer");
|
||||
assert!(format!("{err}").contains("hole"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn asking_a_slice_for_the_chain_is_refused() {
|
||||
let mut store: MemStore = MemStore::new();
|
||||
put_chain(&mut store, &at(0), &[2u8; 32], 8).expect("put");
|
||||
let err = get_chain(&store, &at(1)).expect_err("a middle slice is not an address");
|
||||
assert!(format!("{err}").contains("one address"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_zero_chunk_is_refused_rather_than_looping() {
|
||||
let mut store: MemStore = MemStore::new();
|
||||
assert!(put_chain(&mut store, &at(0), b"x", 0).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_absent_chain_is_none_and_not_an_error() {
|
||||
let store: MemStore = MemStore::new();
|
||||
assert!(get_chain(&store, &at(0)).unwrap().is_none());
|
||||
}
|
||||
}
|
||||
@@ -63,6 +63,7 @@
|
||||
#[cfg(feature = "debug")]
|
||||
pub mod debug;
|
||||
pub mod flagged;
|
||||
pub mod chain;
|
||||
pub mod wal;
|
||||
|
||||
use cube_core::{Coord, Curve, Morton, Point, SpaceId};
|
||||
|
||||
Reference in New Issue
Block a user