From 1ba3450ec20fd9de95cdb066485f144ebbb6d427 Mon Sep 17 00:00:00 2001 From: "@permadeath.com" Date: Tue, 22 Sep 2026 10:10:11 -0400 Subject: [PATCH] feat(pds): read the layout before this one instead of refusing it wal::reader is the table of layouts an image reads, and layout::check hands back the reader for a stamp it knows. The first boot under a moved stamp takes a checkpoint in today's shape, trims the log and restamps, so an upgrade inside the window costs a restart rather than the accounts. The window is N-1, every row has a data directory under tests/fixtures/layout/, and tests/layouts.rs opens each one and answers its expected.json. Co-Authored-By: Claude Opus 5 (1M context) Change-Id: I7dc42d7fedf9b7c067c91cd3e178be4b081e48d2 --- crates/didbot-pds/src/checkpoint.rs | 48 ++- crates/didbot-pds/src/durable.rs | 108 +++++- crates/didbot-pds/src/layout.rs | 227 ++++++++++-- crates/didbot-pds/src/wal/mod.rs | 58 +++- crates/didbot-pds/src/wal/reader.rs | 140 ++++++++ crates/didbot-pds/tests/blob_references.rs | 2 +- crates/didbot-pds/tests/durability.rs | 4 +- ...c5jfjxnswcqsp64u45l73rl5a4xqmzq3gi4qtxrtmi | Bin 0 -> 1767 bytes ...le5v67nikccodt7v2ta3xpjz7rcdb2ksr64cgzfn2q | 1 + .../tests/fixtures/layout/18/expected.json | 20 ++ .../tests/fixtures/layout/18/pds.checkpoint | Bin 0 -> 1930 bytes .../tests/fixtures/layout/18/pds.layout | 5 + .../tests/fixtures/layout/18/pds.wal.000001 | 0 .../tests/fixtures/layout/18/records/pds.heap | Bin 0 -> 456 bytes crates/didbot-pds/tests/layouts.rs | 324 ++++++++++++++++++ crates/didbot-pds/tests/upgrade.rs | 10 +- .../didbot-serve/tests/record_tombstones.rs | 7 +- docs/operations.md | 33 +- docs/running-locally.md | 34 +- plan/deploy.md | 29 +- plan/local-dev.md | 8 +- prek.toml | 12 +- 22 files changed, 972 insertions(+), 98 deletions(-) create mode 100644 crates/didbot-pds/src/wal/reader.rs create mode 100644 crates/didbot-pds/tests/fixtures/layout/18/blobs/did%3Aweb%3Ashearwater.agents.localhost/bafkreider4kujw57c5jfjxnswcqsp64u45l73rl5a4xqmzq3gi4qtxrtmi create mode 100644 crates/didbot-pds/tests/fixtures/layout/18/blobs/did%3Aweb%3Ashearwater.agents.localhost/bafkreih2fxeo453tle5v67nikccodt7v2ta3xpjz7rcdb2ksr64cgzfn2q create mode 100644 crates/didbot-pds/tests/fixtures/layout/18/expected.json create mode 100644 crates/didbot-pds/tests/fixtures/layout/18/pds.checkpoint create mode 100644 crates/didbot-pds/tests/fixtures/layout/18/pds.layout create mode 100644 crates/didbot-pds/tests/fixtures/layout/18/pds.wal.000001 create mode 100644 crates/didbot-pds/tests/fixtures/layout/18/records/pds.heap create mode 100644 crates/didbot-pds/tests/layouts.rs diff --git a/crates/didbot-pds/src/checkpoint.rs b/crates/didbot-pds/src/checkpoint.rs index f260c170..91119db4 100644 --- a/crates/didbot-pds/src/checkpoint.rs +++ b/crates/didbot-pds/src/checkpoint.rs @@ -33,6 +33,12 @@ //! - The rename itself is atomic, and the directory sync on either side of it //! is what stops a power cut undoing it while keeping the file it named. //! +//! A checkpoint written under a layout this binary reads and does not write +//! is read through that layout's reader, the same one the log is read +//! through: a checkpoint is the log's format in another file. One this +//! binary has no reader for is refused like any other unreadable checkpoint, +//! which costs the boot nothing for the reason below. +//! //! A refused checkpoint costs a boot nothing, because the journal still holds //! every entry the checkpoint covers: nothing is trimmed against a checkpoint //! that is not durable, and the journal a refusal falls back to is the whole @@ -214,7 +220,10 @@ pub fn write(dir: &Path, entries: &[Entry], mark: Mark) -> Result<(), Checkpoint /// `Ok(None)` is a directory with no checkpoint, which includes one holding a /// file of zero bytes: a file that names nothing is the same as no file, for /// the reason `Durable::open` gives about a log of zero bytes. -pub fn load(dir: &Path) -> Result, CheckpointError> { +pub fn load( + dir: &Path, + reader: &crate::wal::reader::Known, +) -> Result, CheckpointError> { let file = path(dir); let bytes = match std::fs::read(&file) { Ok(bytes) => bytes, @@ -231,7 +240,7 @@ pub fn load(dir: &Path) -> Result, CheckpointError> { while offset < bytes.len() { match frame::decode(&bytes[offset..]) { frame::Read::Frame { payload, width } => { - let entry: Entry = serde_json::from_slice(payload) + let entry: Entry = (reader.read)(payload) .map_err(|_| CheckpointError::refused(&file, Refusal::Unreadable))?; if let Entry::CheckpointSealed { segment, offset } = entry { // Recorded rather than returned, so that a trailer with @@ -311,7 +320,9 @@ mod tests { fn a_checkpoint_round_trips_with_the_mark_it_covers() { let dir = scratch("round-trip"); write(&dir, &sample(), MARK).expect("a checkpoint writes"); - let found = load(&dir).expect("it reads").expect("there is one"); + let found = load(&dir, crate::wal::reader::current_reader()) + .expect("it reads") + .expect("there is one"); assert_eq!(found.mark, MARK); assert_eq!( format!("{:?}", found.entries), @@ -324,10 +335,14 @@ mod tests { #[test] fn a_directory_with_no_checkpoint_has_none_rather_than_a_failure() { let dir = scratch("absent"); - assert!(load(&dir).expect("an absent checkpoint reads").is_none()); + assert!(load(&dir, crate::wal::reader::current_reader()) + .expect("an absent checkpoint reads") + .is_none()); // And a file of zero bytes names exactly as much as a missing one. std::fs::write(path(&dir), []).expect("an empty file"); - assert!(load(&dir).expect("an empty checkpoint reads").is_none()); + assert!(load(&dir, crate::wal::reader::current_reader()) + .expect("an empty checkpoint reads") + .is_none()); let _ = std::fs::remove_dir_all(&dir); } @@ -363,7 +378,7 @@ mod tests { continue; } std::fs::write(path(&dir), &bytes[..cut]).expect("cut the checkpoint"); - match load(&dir) { + match load(&dir, crate::wal::reader::current_reader()) { Ok(None) => assert_eq!(cut, 0, "only an empty file reads as no checkpoint"), Ok(Some(_)) => panic!("a checkpoint cut to {cut} of {} was believed", bytes.len()), Err(err) => assert!( @@ -376,7 +391,9 @@ mod tests { // And the whole file still loads, so the drill was cutting something // that worked. std::fs::write(path(&dir), &bytes).expect("restore the checkpoint"); - assert!(load(&dir).expect("it reads").is_some()); + assert!(load(&dir, crate::wal::reader::current_reader()) + .expect("it reads") + .is_some()); let _ = std::fs::remove_dir_all(&dir); } @@ -391,7 +408,9 @@ mod tests { } std::fs::write(path(&dir), &bytes).expect("a trailerless file"); assert_eq!( - load(&dir).unwrap_err().refusal(), + load(&dir, crate::wal::reader::current_reader()) + .unwrap_err() + .refusal(), Some(Refusal::Untrailed), "a file of whole frames with nothing saying what it covers is not a checkpoint" ); @@ -419,7 +438,7 @@ mod tests { let mut damaged = bytes.clone(); damaged[byte] ^= 1 << bit; std::fs::write(path(&dir), &damaged).expect("a damaged checkpoint"); - match load(&dir) { + match load(&dir, crate::wal::reader::current_reader()) { Ok(Some(_)) => panic!("bit {bit} of byte {byte} was believed"), Ok(None) => panic!("bit {bit} of byte {byte} read as no checkpoint"), Err(err) => assert!( @@ -444,7 +463,9 @@ mod tests { two.extend_from_slice(&one); std::fs::write(path(&dir), &two).expect("two checkpoints in one file"); assert_eq!( - load(&dir).unwrap_err().refusal(), + load(&dir, crate::wal::reader::current_reader()) + .unwrap_err() + .refusal(), Some(Refusal::Interrupted) ); let _ = std::fs::remove_dir_all(&dir); @@ -465,7 +486,12 @@ mod tests { .expect("serializes"), )); std::fs::write(path(&dir), &bytes).expect("a checkpoint with a stranger in it"); - assert_eq!(load(&dir).unwrap_err().refusal(), Some(Refusal::Unreadable)); + assert_eq!( + load(&dir, crate::wal::reader::current_reader()) + .unwrap_err() + .refusal(), + Some(Refusal::Unreadable) + ); let _ = std::fs::remove_dir_all(&dir); } diff --git a/crates/didbot-pds/src/durable.rs b/crates/didbot-pds/src/durable.rs index 06079a39..548f4b10 100644 --- a/crates/didbot-pds/src/durable.rs +++ b/crates/didbot-pds/src/durable.rs @@ -168,14 +168,28 @@ impl Durable { // writer that got as far as appending has already corrupted the log, // so the refusal has to come before the first append, not after. let lock = crate::lock::DirLock::acquire(dir)?; - match crate::layout::check(dir, has_state)? { + let opened = crate::layout::check(dir, has_state)?; + // A directory written under a layout this binary reads and does not + // write is rewritten before anything is served: the checkpoint below + // is taken in today's shape whatever the log's length says, the log + // is trimmed against it, and the stamp is rewritten last. + let moved_layout = matches!(opened.found, crate::layout::Found::Readable { .. }); + match opened.found { crate::layout::Found::Fresh => {} crate::layout::Found::Matched => tracing::debug!( layout = crate::layout::LAYOUT, shape = %format!("{:016x}", crate::layout::SHAPE), "the data directory agrees" ), + crate::layout::Found::Readable { from_layout } => tracing::info!( + from_layout, + layout = crate::layout::LAYOUT, + shape = %format!("{:016x}", crate::layout::SHAPE), + "read a data directory written under layout {from_layout}; \ + rewriting it in this one" + ), } + let reader = opened.reader; // Before the log is opened, because what the log is read from is what // the checkpoint says. A file at the checkpoint's name that is not a @@ -184,7 +198,7 @@ impl Durable { // can be: a log trimmed against a checkpoint has no origin on disk // and refuses, since the state it would read to is one with its // beginning missing. See [`crate::checkpoint`]. - let resumed = match crate::checkpoint::load(dir) { + let resumed = match crate::checkpoint::load(dir, reader) { Ok(found) => found, Err(error) => { tracing::warn!( @@ -196,7 +210,7 @@ impl Durable { } }; let (wal, replay, resumed) = match resumed { - Some(checkpoint) => match Wal::open_after(dir, checkpoint.mark) { + Some(checkpoint) => match Wal::open_after(dir, checkpoint.mark, reader) { Ok((wal, replay)) => (wal, replay, Some(checkpoint)), Err(error @ WalError::Unaddressed { .. }) => { tracing::warn!( @@ -342,7 +356,7 @@ impl Durable { // It is worth its cost — the whole state, written and synced — once // the log since the last one has grown past the state, which is the // point where replaying the log costs more than reading it would. - let covered = if suffix > live { + let covered = if moved_layout || suffix > live { let entries = snapshot( &accounts, &records, @@ -394,6 +408,15 @@ impl Durable { ], ))?; + // Last, and only once the checkpoint is durable and the log is + // trimmed against it: from here on this is a directory this binary + // writes rather than one it reads. A run that died before this point + // left the stamp alone, so the next boot reads the directory the same + // way this one did. + if moved_layout { + crate::layout::restamp(dir)?; + } + // The grants' own file. Read after the log rather than out of it: // a grant expires on its own and a restore does not want the // snapshot's, so it is a restart's concern and not the log's. @@ -2451,6 +2474,83 @@ mod tests { use crate::ledger::LedgerEvent; use crate::names::Reservation; + /// The first boot under a moved layout rewrites the directory. + /// + /// It reads the log through the older layout's reader, takes a + /// checkpoint in today's shape whatever the log's length says, trims + /// against it, and stamps the directory last. After that boot the + /// directory is one this binary writes, so the next upgrade is reading + /// one layout back rather than two. + #[test] + fn a_readable_directory_is_rewritten_in_the_layout_this_binary_writes() { + let dir = std::env::temp_dir().join(format!( + "didbot-durable-readable-{}-{}", + std::process::id(), + time::OffsetDateTime::now_utc().unix_timestamp_nanos() + )); + let _ = std::fs::remove_dir_all(&dir); + let did = crate::hosted::HostedDid::replayed( + didbot_identity::AccountDid::parse("did:web:shearwater.agents.localhost") + .expect("a valid did"), + ); + { + let durable = Durable::open(&dir, hold()).expect("a fresh directory opens"); + durable + .accounts() + .insert( + HostedAccount::server(did.clone(), OffsetDateTime::now_utc()), + SigningKey::generate(), + ) + .expect("the account lands"); + durable.wal().sync().expect("the log flushes"); + } + // The stamp a build one layout back would have left. + let older = crate::wal::reader::PRETENDED_SHAPE; + std::fs::write( + dir.join(crate::layout::STAMP_FILE), + serde_json::to_string(&crate::layout::Stamp { + layout: crate::layout::LAYOUT - 1, + shape: older, + names: crate::layout::names(), + }) + .expect("a stamp serializes"), + ) + .expect("the stamp writes"); + + let durable = Durable::open(&dir, hold()).expect("a layout this binary reads opens"); + assert!( + durable.accounts().get(&did).is_some(), + "the account did not come back" + ); + assert!( + dir.join(crate::wal::CHECKPOINT_FILE).exists(), + "the boot that read an older layout must take a checkpoint in today's shape" + ); + drop(durable); + + let stamp: crate::layout::Stamp = serde_json::from_str( + &std::fs::read_to_string(dir.join(crate::layout::STAMP_FILE)) + .expect("the stamp is readable"), + ) + .expect("a whole stamp"); + assert_eq!( + stamp, + crate::layout::Stamp { + layout: crate::layout::LAYOUT, + shape: crate::layout::SHAPE, + names: crate::layout::names(), + } + ); + + // And the next boot is an ordinary one. + assert_eq!( + crate::layout::check(&dir, true).expect("it opens").found, + crate::layout::Found::Matched + ); + + let _ = std::fs::remove_dir_all(&dir); + } + /// Names the generated logs draw from, small enough that a sequence of /// any length collides on every one of them and the interesting orders /// — claim after release, put after delete, upload after collect — turn diff --git a/crates/didbot-pds/src/layout.rs b/crates/didbot-pds/src/layout.rs index a45c01c7..0f0a64c1 100644 --- a/crates/didbot-pds/src/layout.rs +++ b/crates/didbot-pds/src/layout.rs @@ -460,6 +460,24 @@ pub enum Found { Fresh, /// A stamp was here and it agrees with this binary. Matched, + /// A stamp was here from a layout this binary reads and does not write. + /// + /// The log is read through that layout's reader, and the boot that did + /// so rewrites the directory in today's shape before it serves anything + /// — see [`crate::wal::reader`]. + Readable { + /// The human-legible sequence the stamp carried. + from_layout: u32, + }, +} + +/// A directory this binary can open, and the reader it opens with. +#[derive(Debug)] +pub struct Opened { + /// What the stamp said. + pub found: Found, + /// The reader every frame in this directory is read through. + pub reader: &'static crate::wal::reader::Known, } /// Which half of the stamp moved, since the two mean different things. @@ -502,8 +520,9 @@ pub enum LayoutError { "the data directory at {path} was written as version {found_layout} (entry shape \ {found_shape:016x}, on-disk names {found_names:016x}), and this binary reads version \ {expected_layout} (entry shape {expected_shape:016x}, on-disk names \ - {expected_names:016x}): {differs}. Nothing here converts one to the other: check out \ - the build that wrote it, or delete the directory and start again" + {expected_names:016x}): {differs}. This binary reads {window}. Run the image for a \ + version it does read against this directory first — that boot rewrites it in the \ + shape this one reads — or, in development, delete the directory and start again" )] Mismatch { /// The directory. @@ -522,6 +541,8 @@ pub enum LayoutError { expected_shape: u64, /// This binary's derived on-disk names. expected_names: u64, + /// Which versions this binary does read, by number. + window: String, }, /// The stamp is missing a half, so there is nothing to compare it with. #[error( @@ -563,9 +584,19 @@ pub enum LayoutError { /// /// `has_state` is whether the directory already holds something worth keeping. /// It decides only what an *absent* stamp means: on an empty directory there -/// is nothing to be wrong about, and on one with a log it means the log -/// predates stamping. -pub fn check(dir: &Path, has_state: bool) -> Result { +/// is nothing to be wrong about, and on one holding state it means nothing on +/// disk says what wrote it. +/// +/// A stamp whose shape this binary reads but does not write comes back as +/// [`Found::Readable`] with that layout's reader, rather than as a refusal. +/// The stamp itself is left alone: the boot rewrites it once the checkpoint +/// it took in today's shape is durable, so a run that died half way through +/// reads the same directory the same way next time. +/// +/// # Errors +/// +/// [`LayoutError`] for a directory this binary cannot open. +pub fn check(dir: &Path, has_state: bool) -> Result { let path = dir.join(STAMP_FILE); match std::fs::read_to_string(&path) { Ok(raw) => { @@ -587,25 +618,46 @@ pub fn check(dir: &Path, has_state: bool) -> Result { source: Box::new(source), })?; let expected_names = names(); - let differs = match (stamp.shape != SHAPE, stamp.names != expected_names) { - (false, false) => None, - (true, false) => Some(Difference::EntryShape), - (false, true) => Some(Difference::DiskNames), - (true, true) => Some(Difference::Both), + // The names half is not a shape anything reads past: a blob whose + // path moved is a file the startup sweep does not recognise, so a + // directory whose names differ is refused however well this binary + // reads its log. + let reader = if stamp.names == expected_names { + crate::wal::reader::reader_for(stamp.shape) + } else { + None }; - if let Some(differs) = differs { - return Err(LayoutError::Mismatch { - path: dir.to_path_buf(), - differs, - found_layout: stamp.layout, - found_shape: stamp.shape, - found_names: stamp.names, - expected_layout: LAYOUT, - expected_shape: SHAPE, - expected_names, + if let Some(reader) = reader { + return Ok(Opened { + found: if stamp.shape == SHAPE { + Found::Matched + } else { + Found::Readable { + from_layout: stamp.layout, + } + }, + reader, }); } - Ok(Found::Matched) + // Reaching here with the names agreeing means the entry shape is + // one no reader claims, which is the only other way to get past + // the check above. + let differs = match (stamp.shape != SHAPE, stamp.names != expected_names) { + (_, false) => Difference::EntryShape, + (false, true) => Difference::DiskNames, + (true, true) => Difference::Both, + }; + Err(LayoutError::Mismatch { + path: dir.to_path_buf(), + differs, + found_layout: stamp.layout, + found_shape: stamp.shape, + found_names: stamp.names, + expected_layout: LAYOUT, + expected_shape: SHAPE, + expected_names, + window: window(), + }) } Err(err) if err.kind() == std::io::ErrorKind::NotFound => { if has_state { @@ -615,7 +667,10 @@ pub fn check(dir: &Path, has_state: bool) -> Result { }); } write(&path)?; - Ok(Found::Fresh) + Ok(Opened { + found: Found::Fresh, + reader: crate::wal::reader::current_reader(), + }) } Err(source) => Err(LayoutError::Unreadable { path, @@ -624,6 +679,35 @@ pub fn check(dir: &Path, has_state: bool) -> Result { } } +/// The versions this binary reads, for a refusal to quote. +fn window() -> String { + let mut listed: Vec = crate::wal::reader::KNOWN + .iter() + .map(|known| format!("version {}", known.layout)) + .collect(); + listed.reverse(); + match listed.len() { + 1 => listed.remove(0), + _ => { + let last = listed.pop().unwrap_or_default(); + format!("{} and {last}", listed.join(", ")) + } + } +} + +/// Writes this binary's stamp over whatever is there. +/// +/// What a boot calls once it has taken a checkpoint in today's shape and +/// trimmed the log against it: from then on the directory is one this binary +/// writes rather than one it reads. +/// +/// # Errors +/// +/// [`LayoutError::Unreadable`] if the directory will not take it. +pub fn restamp(dir: &Path) -> Result<(), LayoutError> { + write(&dir.join(STAMP_FILE)) +} + /// Writes the stamp for this binary's layout. fn write(path: &Path) -> Result<(), LayoutError> { let rendered = serde_json::to_string_pretty(&Stamp { @@ -655,16 +739,106 @@ mod tests { #[test] fn an_empty_directory_is_stamped_and_accepted() { let dir = scratch("fresh"); - assert_eq!(check(&dir, false).expect("fresh is fine"), Found::Fresh); + assert_eq!( + check(&dir, false).expect("fresh is fine").found, + Found::Fresh + ); assert!(dir.join(STAMP_FILE).exists()); // And opening it again agrees with itself. - assert_eq!(check(&dir, true).expect("second open"), Found::Matched); + assert_eq!( + check(&dir, true).expect("second open").found, + Found::Matched + ); std::fs::remove_dir_all(&dir).ok(); } /// A directory that holds something and says nothing about what wrote /// it is refused. Stamping it would be this binary asserting a shape /// over bytes it has not read. + /// A stamp from a layout the reader table knows comes back with that + /// layout's reader rather than as a refusal, and the stamp is left alone + /// — the boot rewrites it once it has taken a checkpoint in today's + /// shape. + #[test] + fn a_stamp_this_binary_reads_comes_back_with_its_reader() { + let dir = scratch("readable"); + let older = crate::wal::reader::PRETENDED_SHAPE; + let raw = serde_json::to_string(&Stamp { + layout: LAYOUT - 1, + shape: older, + names: names(), + }) + .expect("a stamp serializes"); + std::fs::write(dir.join(STAMP_FILE), &raw).expect("write a stamp"); + + let opened = check(&dir, true).expect("a layout this binary reads"); + assert_eq!( + opened.found, + Found::Readable { + from_layout: LAYOUT - 1 + } + ); + assert_eq!(opened.reader.shape, older); + assert_eq!( + std::fs::read_to_string(dir.join(STAMP_FILE)).expect("the stamp is readable"), + raw, + "the check must not restamp; the boot does, after the checkpoint" + ); + std::fs::remove_dir_all(&dir).ok(); + } + + /// The names half is not something a reader can make up for: a blob + /// whose path moved is a file the startup sweep does not recognise. So a + /// readable entry shape over moved names is still refused. + #[test] + fn a_readable_shape_over_moved_names_is_still_refused() { + let dir = scratch("readable-moved-names"); + std::fs::write( + dir.join(STAMP_FILE), + serde_json::to_string(&Stamp { + layout: LAYOUT - 1, + shape: crate::wal::reader::PRETENDED_SHAPE, + names: names() ^ 0x5eed, + }) + .expect("a stamp serializes"), + ) + .expect("write a stamp"); + let err = check(&dir, true).expect_err("moved names are refused"); + assert!( + matches!( + err, + LayoutError::Mismatch { + differs: Difference::Both, + .. + } + ), + "{err}" + ); + std::fs::remove_dir_all(&dir).ok(); + } + + /// A refusal names the versions this binary does read, because the + /// answer to it is to run one of them. + #[test] + fn a_refusal_names_the_window() { + let dir = scratch("window"); + std::fs::write( + dir.join(STAMP_FILE), + r#"{"layout": 3, "shape": 9999, "names": 9999}"#, + ) + .expect("write a stamp"); + let rendered = check(&dir, true) + .expect_err("a shape with no reader") + .to_string(); + for known in crate::wal::reader::KNOWN { + assert!( + rendered.contains(&format!("version {}", known.layout)), + "{rendered}" + ); + } + std::fs::remove_dir_all(&dir).ok(); + } + #[test] fn a_directory_with_state_and_no_stamp_is_refused() { let dir = scratch("unstamped"); @@ -963,7 +1137,10 @@ mod tests { let rendered = err.to_string(); assert!(rendered.contains(phrase), "{rendered}"); // Whichever half moved, the message still says what to do. - for advice in ["delete the directory", "check out the build that wrote it"] { + for advice in [ + "delete the directory", + "Run the image for a version it does read", + ] { assert!(rendered.contains(advice), "{rendered}"); } std::fs::remove_dir_all(&dir).ok(); diff --git a/crates/didbot-pds/src/wal/mod.rs b/crates/didbot-pds/src/wal/mod.rs index 6d0d0282..dfeb1f73 100644 --- a/crates/didbot-pds/src/wal/mod.rs +++ b/crates/didbot-pds/src/wal/mod.rs @@ -128,6 +128,7 @@ // this module already got right. See that module's doc comment for why its // log is a second file and not a second use of this one. pub(crate) mod frame; +pub mod reader; use std::fs::{File, OpenOptions}; use std::io::{Read as _, Write}; @@ -1020,7 +1021,7 @@ impl Wal { /// the first new frame lands on a clean boundary rather than after a /// fragment that every later run would have to skip again. pub fn open(dir: &Path) -> Result<(Self, Replay), WalError> { - Self::open_after(dir, Mark::ORIGIN) + Self::open_after(dir, Mark::ORIGIN, reader::current_reader()) } /// Opens the log in `dir`, replaying only what lies at or after `from`. @@ -1047,11 +1048,19 @@ impl Wal { /// from the origin would not find the origin either. That is /// [`WalError::Discontinuous`], and so is any gap or torn frame between /// the mark's segment and the last. - pub fn open_after(dir: &Path, from: Mark) -> Result<(Self, Replay), WalError> { + /// + /// `reader` is the layout every frame here is read through — today's, or + /// an older one [`crate::layout::check`] found the stamp for. See + /// [`reader`]. + pub fn open_after( + dir: &Path, + from: Mark, + reader: &reader::Known, + ) -> Result<(Self, Replay), WalError> { create_dir(dir)?; let on_disk = segments_in(dir)?; - let (replay, discarded_from_total) = replay(dir, from, &on_disk)?; + let (replay, discarded_from_total) = replay(dir, from, &on_disk, reader)?; let (segment, segments, total) = match on_disk.last() { Some(&(highest, _)) => { let total: u64 = on_disk.iter().map(|(_, len)| len).sum(); @@ -1455,7 +1464,12 @@ pub fn holds_bytes(dir: &Path) -> Result { /// the last segment is a hole — the roll that sealed a segment synced it /// before the next existed, so nothing but damage leaves one — and a hole /// is refused rather than read around. -fn replay(dir: &Path, from: Mark, on_disk: &[(u64, u64)]) -> Result<(Replay, u64), WalError> { +fn replay( + dir: &Path, + from: Mark, + on_disk: &[(u64, u64)], + reader: &reader::Known, +) -> Result<(Replay, u64), WalError> { let empty = Replay { entries: Vec::new(), discarded: 0, @@ -1534,12 +1548,11 @@ fn replay(dir: &Path, from: Mark, on_disk: &[(u64, u64)]) -> Result<(Replay, u64 loop { match frame::decode(&bytes[offset..]) { frame::Read::Frame { payload, width } => { - let entry = - serde_json::from_slice(payload).map_err(|source| WalError::Corrupt { - path: path.clone(), - index: entries.len(), - source, - })?; + let entry = (reader.read)(payload).map_err(|source| WalError::Corrupt { + path: path.clone(), + index: entries.len(), + source, + })?; entries.push(entry); offset += width; } @@ -2559,14 +2572,16 @@ mod tests { fn a_mark_inside_a_segment_resumes_there_and_reads_the_segments_after() { let dir = scratch("resume"); let marks = segmented(&dir, 3); - let (_wal, replay) = Wal::open_after(&dir, marks[1]).expect("resume"); + let (_wal, replay) = + Wal::open_after(&dir, marks[1], reader::current_reader()).expect("resume"); assert_eq!(names_of(&replay), ["s1-2", "s2-1", "s2-2"]); // And a mark at the very end of a sealed segment reads nothing of it. let end = Mark { segment: 0, offset: length(&dir.join(segment_name(0))), }; - let (_wal, replay) = Wal::open_after(&dir, end).expect("resume at a seam"); + let (_wal, replay) = + Wal::open_after(&dir, end, reader::current_reader()).expect("resume at a seam"); assert_eq!(names_of(&replay), ["s1-1", "s1-2", "s2-1", "s2-2"]); let _ = std::fs::remove_dir_all(&dir); } @@ -2583,7 +2598,7 @@ mod tests { mark.offset > 0, "the mark should be inside its segment, not at its head" ); - let (wal, before) = Wal::open_after(&dir, mark).expect("resume"); + let (wal, before) = Wal::open_after(&dir, mark, reader::current_reader()).expect("resume"); let below: u64 = (0..3) .map(|segment| length(&dir.join(segment_name(segment)))) .sum(); @@ -2616,7 +2631,8 @@ mod tests { drop(wal); // What the mark reads is what it read before the trim. - let (_wal, after) = Wal::open_after(&dir, mark).expect("resume after the trim"); + let (_wal, after) = + Wal::open_after(&dir, mark, reader::current_reader()).expect("resume after the trim"); assert_eq!(names_of(&after), names_of(&before)); assert_eq!(names_of(&after), ["s3-2", "s4-1", "s4-2", "s5-1", "s5-2"]); let _ = std::fs::remove_dir_all(&dir); @@ -2646,12 +2662,12 @@ mod tests { fn a_trimmed_log_is_refused_from_the_origin_and_from_below_the_first_segment() { let dir = scratch("trim-origin"); let marks = segmented(&dir, 4); - let (wal, _) = Wal::open_after(&dir, marks[2]).expect("resume"); + let (wal, _) = Wal::open_after(&dir, marks[2], reader::current_reader()).expect("resume"); wal.trim(marks[2]).expect("trim"); drop(wal); for from in [Mark::ORIGIN, marks[1]] { - let refusal = Wal::open_after(&dir, from) + let refusal = Wal::open_after(&dir, from, reader::current_reader()) .expect_err("a trimmed log read below its first segment"); assert!( matches!(refusal, WalError::Discontinuous { .. }), @@ -2665,6 +2681,7 @@ mod tests { segment: 2, offset: 0, }, + reader::current_reader(), ) .expect("the first segment on disk reads from its head"); assert_eq!(names_of(&replay), ["s2-1", "s2-2", "s3-1", "s3-2"]); @@ -2682,7 +2699,8 @@ mod tests { std::fs::remove_file(dir.join(segment_name(0))).expect("the first unlink"); std::fs::remove_file(dir.join(segment_name(1))).expect("the second unlink"); - let (wal, replay) = Wal::open_after(&dir, mark).expect("resume after an interrupted trim"); + let (wal, replay) = Wal::open_after(&dir, mark, reader::current_reader()) + .expect("resume after an interrupted trim"); assert_eq!(names_of(&replay), ["s3-2", "s4-1", "s4-2"]); assert_eq!(wal.segments(), 3); assert_eq!(wal.trim(mark).expect("finish the trim").segments, 1); @@ -2699,7 +2717,8 @@ mod tests { std::fs::write(dir.join(segment_name(0)), b"not frames at all").expect("damage"); std::fs::remove_file(dir.join(segment_name(1))).expect("a gap"); - let (_wal, replay) = Wal::open_after(&dir, marks[2]).expect("resume above the damage"); + let (_wal, replay) = Wal::open_after(&dir, marks[2], reader::current_reader()) + .expect("resume above the damage"); assert_eq!(names_of(&replay), ["s2-2", "s3-1", "s3-2"]); let _ = std::fs::remove_dir_all(&dir); } @@ -2737,6 +2756,7 @@ mod tests { segment: 2, offset: 0, }, + reader::current_reader(), ) .expect("the segments after a hole read from a mark past it"); assert_eq!(names_of(&replay), ["s2-1", "s2-2"]); @@ -2772,6 +2792,7 @@ mod tests { segment: 5, offset: 0, }, + reader::current_reader(), ) .expect_err("a segment this log has not written was read"); assert!( @@ -2790,6 +2811,7 @@ mod tests { segment: 1, offset: 0, }, + reader::current_reader(), ) .expect_err("a directory with no log resumed from a mark"); assert!( diff --git a/crates/didbot-pds/src/wal/reader.rs b/crates/didbot-pds/src/wal/reader.rs new file mode 100644 index 00000000..6f13d6d9 --- /dev/null +++ b/crates/didbot-pds/src/wal/reader.rs @@ -0,0 +1,140 @@ +//! Reading an entry written under a layout this binary no longer writes. +//! +//! # Forward-only, and why there is no converter +//! +//! A reader for an old shape is a function this project can test against a +//! fixture on every run. A converter runs once, against real data, with no +//! second chance — and it would have to run between `docker stop` and +//! `docker start`, which is a window where the volume is attached to +//! something that is not the server. So an upgrade reads the old shape +//! instead of rewriting it, and the first boot under the new binary is what +//! proves the reader works. +//! +//! A new binary reads every layout in [`KNOWN`]; an old binary reads none of +//! the new ones, and [`crate::layout::check`] refuses in that direction. A +//! rollback is a restore of the snapshot taken before the upgrade. +//! +//! # The window +//! +//! **N-1.** A binary reads the layout it writes and the one before it, and a +//! release note names both. A directory older than that is refused, and the +//! answer is to run the intermediate image against it first — that boot +//! rewrites the directory in the shape this one reads. +//! +//! Two rather than one because a deployment nobody looks at for a month +//! skips a release, and the cost of the second reader is a file that never +//! changes again. Three rather than two would be a file that never changes +//! again and a fixture nobody has a reason to trust. +//! +//! # What a reader is +//! +//! One function from a frame's payload to today's [`Entry`]. An older +//! layout's lives in a module of its own beside this one, reads the payload +//! as that layout's own private types, and returns what those mean now. It +//! never changes again, because the shape it reads never changes again. + +use crate::layout::{LAYOUT, SHAPE}; +use crate::wal::Entry; + +/// A layout this binary can read. +pub struct Known { + /// The human-legible sequence, for a log line. + pub layout: u32, + /// The entry-shape hash a directory written under it carries. + pub shape: u64, + /// Reads one frame's payload as an entry of today's shape. + pub read: fn(&[u8]) -> Result, +} + +impl std::fmt::Debug for Known { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Known") + .field("layout", &self.layout) + .field("shape", &format_args!("{:016x}", self.shape)) + .finish() + } +} + +/// Reads a payload written under the layout this binary writes. +fn current(payload: &[u8]) -> Result { + serde_json::from_slice(payload) +} + +/// Every layout this binary reads, newest first. The head is the one it +/// writes. +/// +/// At most two rows; see the module docs on the window. Adding a row means +/// adding a fixture under `tests/fixtures/layout/` in the same commit, which +/// `tests/layouts.rs` is what enforces. +#[cfg(not(test))] +pub const KNOWN: &[Known] = &[Known { + layout: LAYOUT, + shape: SHAPE, + read: current, +}]; + +/// The shape this crate's own tests pretend a previous release wrote. +/// +/// There is no previous layout in the window yet, and the machinery that +/// reads one — [`reader_for`], [`crate::layout::Found::Readable`], and the +/// boot that rewrites the directory afterwards — has to be exercised by +/// something. This is that something: a row whose reader is today's, so what +/// the tests check is the path rather than a second decoder. It is compiled +/// only under `cfg(test)`, so the table a binary ships is one row. +#[cfg(test)] +pub const PRETENDED_SHAPE: u64 = SHAPE ^ 0x9e37_79b9_7f4a_7c15; + +/// Every layout this binary reads, with the stand-in above beside the real +/// one. +#[cfg(test)] +pub const KNOWN: &[Known] = &[ + Known { + layout: LAYOUT, + shape: SHAPE, + read: current, + }, + Known { + layout: LAYOUT - 1, + shape: PRETENDED_SHAPE, + read: current, + }, +]; + +/// The layout this binary writes. +#[must_use] +pub fn current_reader() -> &'static Known { + &KNOWN[0] +} + +/// The reader for `shape`, or `None` if this binary does not read it. +#[must_use] +pub fn reader_for(shape: u64) -> Option<&'static Known> { + KNOWN.iter().find(|known| known.shape == shape) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The window is a promise a release note makes, so it is a number this + /// file states rather than one a reader counts. + #[test] + fn the_window_is_at_most_two_layouts_and_starts_at_the_one_it_writes() { + assert!(!KNOWN.is_empty()); + assert!(KNOWN.len() <= 2, "the window is N-1"); + assert_eq!(KNOWN[0].shape, SHAPE); + assert_eq!(KNOWN[0].layout, LAYOUT); + // Newest first, and no two rows claiming one shape — either would + // make `reader_for` answer with a reader for something else. + for pair in KNOWN.windows(2) { + assert!(pair[0].layout > pair[1].layout); + assert_ne!(pair[0].shape, pair[1].shape); + } + } + + #[test] + fn a_shape_with_no_reader_answers_none() { + assert!(reader_for(SHAPE).is_some()); + assert!(reader_for(SHAPE ^ 0x5eed).is_none()); + } +} diff --git a/crates/didbot-pds/tests/blob_references.rs b/crates/didbot-pds/tests/blob_references.rs index 0611dbf0..0a479a73 100644 --- a/crates/didbot-pds/tests/blob_references.rs +++ b/crates/didbot-pds/tests/blob_references.rs @@ -140,7 +140,7 @@ fn a_blob_a_record_names_survives_a_restart_and_the_compaction_after_it() { let (durable, pds) = boot(&dir); assert_kept(&pds, &did, &reference.cid, "over the log as it was written"); - let kept = didbot_pds::checkpoint::load(&dir) + let kept = didbot_pds::checkpoint::load(&dir, didbot_pds::wal::reader::current_reader()) .expect("the checkpoint reads") .expect("the churn should have had this boot write a checkpoint") .entries diff --git a/crates/didbot-pds/tests/durability.rs b/crates/didbot-pds/tests/durability.rs index 3d26cb96..6afc0cb9 100644 --- a/crates/didbot-pds/tests/durability.rs +++ b/crates/didbot-pds/tests/durability.rs @@ -214,7 +214,7 @@ fn a_tombstone_survives_a_restart_and_the_checkpoint_the_restart_writes() { let durable = Durable::open(&dir, Duration::days(30)).expect("the log reopens"); assert_state(&durable, "after a restart"); - let checkpoint = didbot_pds::checkpoint::load(&dir) + let checkpoint = didbot_pds::checkpoint::load(&dir, didbot_pds::wal::reader::current_reader()) .expect("the checkpoint reads") .expect("the churn made this boot write a checkpoint"); assert!( @@ -1192,7 +1192,7 @@ fn a_log_that_is_mostly_history_is_rewritten_as_state() { // that were deleted leave nothing behind here — this server's ledger is // wired separately, and `tests/ledger.rs` is where a compaction is // asserted to keep it. - let checkpoint = didbot_pds::checkpoint::load(&dir) + let checkpoint = didbot_pds::checkpoint::load(&dir, didbot_pds::wal::reader::current_reader()) .expect("the checkpoint reads") .expect("the boot that compacted wrote one"); assert_eq!( diff --git a/crates/didbot-pds/tests/fixtures/layout/18/blobs/did%3Aweb%3Ashearwater.agents.localhost/bafkreider4kujw57c5jfjxnswcqsp64u45l73rl5a4xqmzq3gi4qtxrtmi b/crates/didbot-pds/tests/fixtures/layout/18/blobs/did%3Aweb%3Ashearwater.agents.localhost/bafkreider4kujw57c5jfjxnswcqsp64u45l73rl5a4xqmzq3gi4qtxrtmi new file mode 100644 index 0000000000000000000000000000000000000000..af3ddb4ab4d1bbf47cd6adf94d07110e49d58181 GIT binary patch literal 1767 zcmeAS@N?(olHy`uVBq!ia0y~yU}OMc4kiW$hRXu>h71gB>pWc?Ln;`Pd#=qrV8GR; zU-A9#$-Uo~bF7wL+0eORQXoi7;w;1B1efNEJO*5DCZn9uFc`tPZ{rjLyAQj7Ew(^Ktyj6w`etPBmT42<TObtxUqm*=%vNQ8Q#(`~9 z(oxDw%uNj_$}daJOUz471v$PL?&_k{lGMDC%=|o%gHsD~@+)&w^FZz@E=eo_t4%J+ zEK60Y)lte$tyHp7GBdNZNKG{~HL^%FGEX$LFg8pxNl8skvothKGBGhoGD|TrH%~G$ zNi#PwGfFW`wKPjHvoKCgF*dPGOiDF1QmWm!_`Bda26#vor6%VWr39Dc7lDJ3l(0+A z&&f$mhPpnnpg=FFIJ;6WF}WnaNUxwMKP@vSRY^yw2;%VK)SNUW9i?P&+$1HYRTiaY zrd1UdrRAlj=2avonVO^*~n6c%J>R1}ye zD(NWYr==CAmMB>n=qTl+<|$bj8CllOm{2E1179WOm*}Nrrsx%=re_wH6eWT^i`P%- z$)-i7=_#c~6@?i_1-Y3e#%AR?=_P4}d6k7lW<|LLrlnaKW~OGP6-8#|DM>j=l_j}I zeljvPhx*FU)V%he635=l)Q_v={9L`%ip1Q4oK(G%jLf`rL^K)amYbv%n&l@ISEXlQ z#8sX}NK7 zC6eDv42+nl0@T*f~+d@qU4k$qwL}$Gn3@>s1Uuj}q4oduB)0C`~VC7YzQGroj zrm1;)rD;V~WmT@Jv1wY8NkLh%QMq|miAh0nqFI`0W>RWZdVyJPT9H{n1}OcfB$gy1 z3`t4O$~G=9C^g9`Ov=nI%Fi~*PcP0fswyliC@C|_$uP-CF)2+dsHiee$t_FEH!~_V z0~t~PaU#?uDWzGaNoECkIVovn#;NA%sriK!52IzMkzVPS*e+MIi`sv zd6j8q=4ELnAP+&RF(s?$*xEjZlq&{^SWV7IP0lXJ&&(?cPEE`K$7^wFI=D8nGDIqK FYXOE+j3WR5 literal 0 HcmV?d00001 diff --git a/crates/didbot-pds/tests/fixtures/layout/18/pds.layout b/crates/didbot-pds/tests/fixtures/layout/18/pds.layout new file mode 100644 index 00000000..97c171ae --- /dev/null +++ b/crates/didbot-pds/tests/fixtures/layout/18/pds.layout @@ -0,0 +1,5 @@ +{ + "layout": 18, + "shape": 16667950273582860988, + "names": 12358634245211667440 +} diff --git a/crates/didbot-pds/tests/fixtures/layout/18/pds.wal.000001 b/crates/didbot-pds/tests/fixtures/layout/18/pds.wal.000001 new file mode 100644 index 00000000..e69de29b diff --git a/crates/didbot-pds/tests/fixtures/layout/18/records/pds.heap b/crates/didbot-pds/tests/fixtures/layout/18/records/pds.heap new file mode 100644 index 0000000000000000000000000000000000000000..a9c5eda95d106875a4bf134cb1d4b2fa07f28e63 GIT binary patch literal 456 zcmXrnx`Oj50|Ud7RF#s-g4D9af&#sy;_OPj#N?9vBE5p5{ItxR)U?F1#FE6KCCNpp zX*aYYR2djUg%ncyMO|+17Y_=#yJ^Eh_1{yThyOiV%dXFsCT(QNdCxd0r8u)HRg&#F z#N?EuocyGW+|1n6kjjG8%*@=x^i=(Vy!5o3#H7@m;zbZu6^hCExq68u1x5KK`Fda} zy_D3nV&&r0oHQS>%Cxe?oYK_d#)Zjci8;wh`6ZdjMX8A;sVR;n6>>%fMrOJOmbykp zA%-Sah6Yv!#(E|uCWdCF2BzjwJX|#va~T*ImL{iUrc@}WWTse^rzTkyXQU<;l_!>@ z7U?CXr{*eGpC+1}27nh`DXXd4(f<++?EJ?~Q(M!op(JM+#&nzw}N-W9D&&$X! zNG(b%$uFvqfSCys(MzpJ%q_@CCG1EuBU58bQOMU^1_p*jDJ7{DB^l+ZX(c(C WdFcoPN+6yz)HN^+F)*+KqbLAR5u%9z literal 0 HcmV?d00001 diff --git a/crates/didbot-pds/tests/layouts.rs b/crates/didbot-pds/tests/layouts.rs new file mode 100644 index 00000000..d9783460 --- /dev/null +++ b/crates/didbot-pds/tests/layouts.rs @@ -0,0 +1,324 @@ +//! Every layout this binary claims to read, opened against a real directory +//! written under it. +//! +//! `didbot_pds::wal::reader::KNOWN` is a table of promises: each row says +//! this binary reads a data directory carrying that shape. A table is cheap +//! to add a row to and impossible to check by reading, so every row has a +//! fixture under `tests/fixtures/layout//` — a whole data directory, and +//! an `expected.json` saying what a deployment restored from it answers. +//! +//! The fixture is the reason a shape change is a self-contained piece of +//! work: `every_known_layout_has_a_fixture` fails until the new layout has +//! one, and the fixture can only be produced by a binary that writes that +//! layout. Run `cargo test -p didbot-pds --test layouts -- --ignored +//! --nocapture` on the commit that changes the shape to write it. + +use std::collections::BTreeSet; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; + +use didbot_identity::Zone; +use didbot_pds::{ + BlobStore, Durable, FileAccountStore, ProvisionRequest, Provisioner, Registry, Swap, +}; +use serde_json::{json, Value}; +use time::Duration; + +const ZONE_HOST: &str = "agents.localhost"; +const THING: &str = "com.example.thing"; + +/// Ten bytes, so the blob it makes hashes to one fixed CID — and so a whole +/// fixture directory stays under a kilobyte. The same recipe +/// `tests/upgrade.rs` populates its directory with. +const BLOB_BYTES: &[u8] = b"quernstone"; + +fn fixtures() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/layout") +} + +fn scratch(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!( + "didbot-layouts-{name}-{}-{}", + std::process::id(), + time::OffsetDateTime::now_utc().unix_timestamp_nanos() + )); + let _ = std::fs::remove_dir_all(&dir); + dir +} + +type Pds = Provisioner>; + +fn over(dir: &Path) -> (Durable, Pds) { + let durable = Durable::open(dir, Duration::days(30)).expect("the directory opens"); + let pds = Provisioner::new( + "did:web:operator.example", + Zone::delegated("localhost", ZONE_HOST).expect("a test zone"), + "http://localhost:3600".to_string(), + durable.accounts(), + ) + .with_record_store(durable.records()) + .with_blob_store(durable.blobs()) + .with_commit_history(durable.history()); + (durable, pds) +} + +/// Copies a directory tree. +fn copy_tree(from: &Path, to: &Path) { + std::fs::create_dir_all(to).expect("a scratch directory"); + for entry in std::fs::read_dir(from).expect("the fixture is listable") { + let entry = entry.expect("a fixture entry"); + let source = entry.path(); + let target = to.join(entry.file_name()); + if source.is_dir() { + copy_tree(&source, &target); + } else { + std::fs::copy(&source, &target).expect("a fixture file copies"); + } + } +} + +/// Collects everything a `tracing` subscriber writes. +#[derive(Clone, Default)] +struct CapturedLog(Arc>>); + +impl CapturedLog { + fn contents(&self) -> String { + let bytes = match self.0.lock() { + Ok(guard) => guard.clone(), + Err(poisoned) => poisoned.into_inner().clone(), + }; + String::from_utf8_lossy(&bytes).into_owned() + } +} + +impl std::io::Write for CapturedLog { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + match self.0.lock() { + Ok(mut guard) => guard.extend_from_slice(buf), + Err(poisoned) => poisoned.into_inner().extend_from_slice(buf), + } + Ok(buf.len()) + } + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } +} + +/// The layouts a fixture exists for. +fn on_disk() -> BTreeSet { + let Ok(entries) = std::fs::read_dir(fixtures()) else { + return BTreeSet::new(); + }; + entries + .flatten() + .filter_map(|entry| entry.file_name().to_string_lossy().parse::().ok()) + .collect() +} + +/// Every row in the reader table has a directory a binary really wrote. +/// +/// A row with no fixture is a claim nothing checks. The test below is what +/// turns the fixture into a check, and this is what makes the fixture +/// compulsory. +#[test] +fn every_known_layout_has_a_fixture() { + let found = on_disk(); + let claimed: BTreeSet = didbot_pds::wal::reader::KNOWN + .iter() + .map(|known| known.layout) + .collect(); + assert_eq!( + claimed, found, + "every layout `didbot_pds::wal::reader::KNOWN` names needs a directory under \ + tests/fixtures/layout/, written by the binary that wrote that layout. Run this \ + file's ignored test on the commit that changed the shape." + ); +} + +/// Every fixture opens, answers what it held, and reopens as an ordinary +/// directory. +/// +/// Three things in one pass, because the second and third are what make the +/// first mean something. A boot that read the directory and answered nothing +/// would pass an "it opened" assertion; a boot that answered from the log and +/// left the directory in the old shape would pass that one too, and the next +/// upgrade would be reading two layouts back. +#[test] +fn every_fixture_opens_answers_and_reopens_clean() { + let captured = CapturedLog::default(); + let writer = captured.clone(); + let subscriber = tracing_subscriber::fmt() + .with_writer(move || writer.clone()) + .with_ansi(false) + .with_max_level(tracing::Level::INFO) + .finish(); + let _guard = tracing::subscriber::set_default(subscriber); + + let layouts = on_disk(); + assert!(!layouts.is_empty(), "there are no fixtures to open"); + for layout in layouts { + let fixture = fixtures().join(layout.to_string()); + let dir = scratch(&format!("open-{layout}")); + copy_tree(&fixture, &dir); + let expected: Value = serde_json::from_str( + &std::fs::read_to_string(dir.join("expected.json")).expect("an expected.json"), + ) + .expect("expected.json is JSON"); + // Not part of the data directory: it would be a file the manifest + // does not name, which is a different test's business. + std::fs::remove_file(dir.join("expected.json")).expect("the expectations move aside"); + + let before = captured.contents().len(); + { + let (durable, pds) = over(&dir); + let boot = &captured.contents()[before..]; + if layout == didbot_pds::layout::LAYOUT { + assert!( + !boot.contains("read a data directory written under layout"), + "a fixture in this binary's own layout was read as an older one: {boot}" + ); + } else { + assert!( + boot.contains(&format!( + "read a data directory written under layout {layout}" + )), + "the boot line does not say which layout it read: {boot}" + ); + } + assert!( + boot.contains("restored the deployment from its write-ahead log"), + "the boot did not report a restore: {boot}" + ); + + // What a deployment restored from this directory answers, asked + // through the public surface rather than through any store's + // internals. + let accounts: Vec = pds + .accounts() + .iter() + .map(|account| account.did.as_str().to_owned()) + .collect(); + assert_eq!( + json!(accounts), + expected["accounts"], + "layout {layout}: the accounts that came back" + ); + for record in expected["records"].as_array().expect("records is a list") { + let did = record["did"].as_str().expect("a did"); + let held = pds + .get_record( + did, + record["collection"].as_str().expect("a collection"), + record["rkey"].as_str().expect("an rkey"), + ) + .expect("the record store answers") + .expect("the record is there"); + assert_eq!( + held["text"], record["text"], + "layout {layout}: a record came back changed" + ); + } + for blob in expected["blobs"].as_array().expect("blobs is a list") { + let did = blob["did"].as_str().expect("a did"); + assert!( + durable + .blobs() + .fetch(did, blob["cid"].as_str().expect("a cid")) + .is_ok(), + "layout {layout}: a blob's bytes did not come back" + ); + } + for did in expected["accounts"].as_array().expect("accounts is a list") { + let did = did.as_str().expect("a did"); + let host = did.strip_prefix("did:web:").expect("a did:web"); + assert!( + pds.did_document(host).is_some(), + "layout {layout}: {did} does not resolve" + ); + } + drop(pds); + drop(durable); + } + + // The stamp is this binary's now, so the next boot is an ordinary + // one: it says nothing about an older layout and it does not have to + // rewrite anything. + let before = captured.contents().len(); + let (durable, _pds) = over(&dir); + let boot = &captured.contents()[before..]; + assert!( + !boot.contains("read a data directory written under layout"), + "layout {layout}: the directory was not rewritten, so the next boot read it \ + as an older one again: {boot}" + ); + drop(durable); + + let _ = std::fs::remove_dir_all(&dir); + } +} + +/// Writes the fixture for the layout this binary writes. +/// +/// Ignored, because it is a generator rather than a check. Run it on the +/// commit that changes `didbot_pds::layout::SHAPE`, and commit what it +/// leaves under `tests/fixtures/layout//`: +/// +/// ```text +/// cargo test -p didbot-pds --test layouts -- --ignored --nocapture +/// ``` +#[test] +#[ignore = "a generator: writes tests/fixtures/layout//"] +fn write_the_fixture_for_this_layout() { + let layout = didbot_pds::layout::LAYOUT; + let build = scratch("build"); + let (did, cid, rkey) = { + let (durable, pds) = over(&build); + let did = pds + .provision(ProvisionRequest::new("shearwater", None)) + .expect("provisioning should succeed") + .account + .did; + let mut upload = pds + .begin_blob(did.as_str(), "text/plain", Some(BLOB_BYTES.len() as u64)) + .expect("a blob upload should open"); + upload.write(BLOB_BYTES).expect("a chunk should write"); + let reference = upload.commit().expect("the upload should commit"); + let written = pds + .put_record( + did.as_str(), + THING, + None, + json!({"text": "weftling", "createdAt": "2026-01-01T00:00:00Z"}), + &Swap::default(), + ) + .expect("a record should write"); + durable.wal().sync().expect("the log should flush"); + (did.as_str().to_owned(), reference.cid, written.rkey) + }; + // Reopened once, so the directory carries a checkpoint and a trimmed log + // — the shape a deployment's directory is actually in. + drop(over(&build).0); + // The lock is this process's and means nothing to a later one. + let _ = std::fs::remove_file(build.join("pds.lock")); + + let target = fixtures().join(layout.to_string()); + let _ = std::fs::remove_dir_all(&target); + copy_tree(&build, &target); + let expected = json!({ + "layout": layout, + "accounts": [did], + "records": [{"did": did, "collection": THING, "rkey": rkey, "text": "weftling"}], + "blobs": [{"did": did, "cid": cid}], + }); + std::fs::write( + target.join("expected.json"), + format!( + "{}\n", + serde_json::to_string_pretty(&expected).expect("expectations serialize") + ), + ) + .expect("the expectations write"); + println!("wrote {}", target.display()); + + let _ = std::fs::remove_dir_all(&build); +} diff --git a/crates/didbot-pds/tests/upgrade.rs b/crates/didbot-pds/tests/upgrade.rs index 98366929..8251aada 100644 --- a/crates/didbot-pds/tests/upgrade.rs +++ b/crates/didbot-pds/tests/upgrade.rs @@ -236,7 +236,10 @@ fn a_stamp_from_another_binary_is_refused_before_the_directory_is_touched() { // A container that exits on boot during a deploy is an outage, so the // one line it gets has to say what is wrong and what to do about it. let rendered = err.to_string(); - for expected in ["delete the directory", "check out the build that wrote it"] { + for expected in [ + "delete the directory", + "Run the image for a version it does read", + ] { assert!(rendered.contains(expected), "{rendered}"); } @@ -607,7 +610,10 @@ fn a_directory_from_the_build_before_this_one_is_refused_before_it_is_touched() // The one line an operator gets is the whole remedy, so it has to carry // both halves of it. let rendered = err.to_string(); - for expected in ["delete the directory", "check out the build that wrote it"] { + for expected in [ + "delete the directory", + "Run the image for a version it does read", + ] { assert!(rendered.contains(expected), "{rendered}"); } diff --git a/crates/didbot-serve/tests/record_tombstones.rs b/crates/didbot-serve/tests/record_tombstones.rs index 68a556b7..f59d60e7 100644 --- a/crates/didbot-serve/tests/record_tombstones.rs +++ b/crates/didbot-serve/tests/record_tombstones.rs @@ -258,9 +258,10 @@ async fn a_recreate_over_a_tombstone_is_judged_as_an_edit_across_a_checkpoint_an // is written here, so the next boot reads the checkpoint and nothing else. { let (durable, _gate, _pds) = boot(&dir); - let checkpoint = didbot_pds::checkpoint::load(&dir) - .expect("the checkpoint reads") - .expect("the churn made this boot write a checkpoint"); + let checkpoint = + didbot_pds::checkpoint::load(&dir, didbot_pds::wal::reader::current_reader()) + .expect("the checkpoint reads") + .expect("the churn made this boot write a checkpoint"); assert!( checkpoint.entries.iter().any(|entry| matches!( entry, diff --git a/docs/operations.md b/docs/operations.md index 21ca1a0c..01421ea4 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -27,6 +27,9 @@ one data volume at `/data` with the data directory at `/data/pds`. - Watch the journal through startup. On a refusal see "Boot refusals"; otherwise run the checks under "Verifying a restore". +An image whose release note says the layout moved needs the longer procedure +below rather than this one. + ### Boot refusals Each stops the process at startup, before it serves anything, and the unit @@ -34,14 +37,42 @@ then restarts every five seconds logging the same message. | Journal message | What it means | What to do | |---|---|---| -| `the data directory at … was written as version …` | The stamp disagrees with the binary | "Rolling back" | +| `the data directory at … was written as version …` | The stamp names a version this image does not read. The message says which versions it does | Going forward, "Upgrading across a layout change": run one of those images first. Going backward, "Rolling back" | | `the data directory at … carries a stamp written before` this binary's check | Half the stamp has nothing to compare against | Run the build that wrote it, or delete the directory | | `the data directory at … holds state and carries no pds.layout` | The directory says nothing about what wrote it | "Restoring" — bring `pds.layout` back with the rest of the volume | +| `the commit history for … names …, which is not a content identifier` | A commit entry the log holds cannot be read | Run the build that wrote it. Do not edit the log by hand | | `blob bytes are present and pds.wal names none of them` | Blob files with no log to name them | "Restoring" | | `entry did not parse` | Intact bytes this binary does not read as an entry | Run the build that wrote it. Do not delete frames by hand | | Another process holds the lock | A second writer against one directory | Find and stop it; never mount one data directory twice | | A permission error writing to the data directory | Ownership, not mode | `chown` — see "Restoring" | +## Upgrading across a layout change + +A data directory carries a `pds.layout` stamp naming the layout that wrote it. +An image reads the layout it writes and the one before it; the release note +names both, by version number and entry-shape hash. Check the note before the +swap. + +- Take an on-demand snapshot of the data volume and wait for it to complete. + The rollback for this is that snapshot and nothing else. +- `systemctl stop didbot-pds`, wait for `docker ps` to show nothing, `sync`. +- `docker pull `, point `ExecStart` at it, `systemctl daemon-reload`, + `systemctl start didbot-pds`. +- Watch the journal. The boot line reads `read a data directory written under + layout ; rewriting it in this one`, and then `restored the deployment + from its write-ahead log`. +- Run the five checks under "Verifying a restore". +- If the stream position moved, announce it to the relay. + +That first boot rewrites the directory in the new layout: it takes a +checkpoint, trims the log against it, and rewrites the stamp last. From then +on the old image refuses the directory, so the rollback is the snapshot. + +An upgrade that skips more than one layout is refused by name, and the +message says which versions the image does read. Run the intermediate image +against the directory first: that boot rewrites it in a layout the new image +reads. + ## The files beside the log The write-ahead log holds the accounts. Beside it sits one small file per kind diff --git a/docs/running-locally.md b/docs/running-locally.md index d3b4aaaf..876a616d 100644 --- a/docs/running-locally.md +++ b/docs/running-locally.md @@ -164,20 +164,30 @@ the state, a restart writes `pds.checkpoint` — the state as the shortest log that reproduces it, and the position in the log it covers — at the head of a fresh segment and deletes every segment before it, so the segments on disk are the log from the checkpoint forward. Beside them is `pds.layout`, one -line saying which layout wrote the directory, and it is checked before the -log is read. A directory written by a build whose entries this one would misread is -refused by name rather than replayed into something that looks healthy: +line saying which layout wrote the directory, and it is checked before the log +is read. -```text -Error: the data directory at ./pds-data was written in layout 2, and this -binary reads layout 1. Nothing here converts one to the other: check out the -build that wrote it, or delete the directory and start again -``` +A build reads the layout it writes and the one before it, so a directory from +the release before this one opens: the log is read through that layout's +reader, and the boot rewrites the directory in today's shape before it serves +anything. A directory from further back, or from a build whose entries this one +would misread, is refused by name rather than replayed into something that +looks healthy: -Nothing converts one layout to another. In development the answer is to delete -the directory, which costs a factory reset, and it is safe to delete whenever -you want one anyway. A directory that holds something and carries no -`pds.layout` is refused too: nothing there says what wrote it. +```text +Error: the data directory at ./pds-data was written as version 16 (entry shape +…, on-disk names …), and this binary reads version 18 (entry shape …, on-disk +names …): the log's entry shape changed, so entries that build wrote would +parse under this one and mean something else. This binary reads version 18. Run +the image for a version it does read against this directory first — that boot +rewrites it in the shape this one reads — or, in development, delete the +directory and start again +``` + +In development the answer is to delete the directory, which costs a factory +reset, and it is safe to delete whenever you want one anyway. A directory that +holds something and carries no `pds.layout` is refused too: nothing there says +what wrote it. A missing log is refused too, but only when the directory is not really empty. Startup reconciles `blobs/` against what the log names, and a file the log does diff --git a/plan/deploy.md b/plan/deploy.md index a7e63c24..3c43e57f 100644 --- a/plan/deploy.md +++ b/plan/deploy.md @@ -54,24 +54,13 @@ own bucket and distribution to provision — see that epic's open items. run that drill: there is no AWS account to apply this against, and a snapshot that has never been read back is not a backup. Closing this item is the drill succeeding, not the plan existing. -- [ ] **Upgrades across a log format change.** Refusing by name has landed: - a data directory carries a `pds.layout` stamp, it is checked before the - log is read, and a mismatch stops the server with a message naming both - layouts — see [local-dev](local-dev.md). What is still open here is the - other half, which is a deployment's rather than a developer's: reading an - older log, or converting one, so that an upgrade does not mean deleting - the accounts. - [ ] **Say what a restart does** to sessions holding credentials and to a write in flight, and whether downtime is acceptable or has to be avoided. - OAuth grants are in the log and survive one; a sign-in in flight — + OAuth grants are in `pds.grants` and survive one; a sign-in in flight — a pushed authorization request, a consent reference, an authorization code — is in-process memory and does not, so a client mid-sign-in starts over. The restart-persistence issue (`3mviwkxahvt2s`) decides what else a restart keeps, and where each thing lives. -- [ ] **Rollback**, including whether an older binary can read a newer log. - Unaddressed here: the `pds.layout` stamp refuses a mismatched binary by - name rather than allowing a downgrade, so today's answer is "restore - the backup taken before the upgrade," not "run the old binary." ## Reloading, per component @@ -132,6 +121,22 @@ that had not landed as this revision was written. ## Done +- [x] **Upgrades across a log format change, and the rollback.** A data + directory carries a `pds.layout` stamp, and `didbot_pds::wal::reader` + is the table of layouts an image reads: the one it writes and the one + before it. A stamp in the window is read through that layout's reader, + and the first boot takes a checkpoint in today's shape, trims the log + against it and rewrites the stamp — so an upgrade costs a restart + rather than the accounts. One older than the window is refused with a + message naming the versions the image does read, and the answer is to + run one of those first. Every row in the table has a data directory + under `crates/didbot-pds/tests/fixtures/layout/`, and + `crates/didbot-pds/tests/layouts.rs` opens each one, answers the public + surface from its `expected.json`, and reopens it clean. Forward-only, + so the rollback is the snapshot taken before the swap: the boot that + upgraded rewrote the directory, and the old image refuses it. + [Operations](../docs/operations.md)'s "Upgrading across a layout + change" is the procedure. - [x] **Two roots, one state bucket.** `infra/pds/` is this deployment and `infra/site/` is the did.bot website; each has its own key in the did.bot account's state bucket, so applying one cannot lock or roll diff --git a/plan/local-dev.md b/plan/local-dev.md index 7d50802f..c73059c7 100644 --- a/plan/local-dev.md +++ b/plan/local-dev.md @@ -122,10 +122,10 @@ than left standing. The surviving scripts are described accurately below. field and ignores an unknown one, so a log entry can parse and mean something else, and the deployment starts up looking healthy. A `pds.layout` stamp is checked before the log is read, and a directory - this binary would misread is refused by name. Nothing converts one layout - to another, on purpose: a conversion this project has never needed is one - nobody has tested, and in development the answer is to delete the - directory. + this binary would misread is refused by name. A build reads the layout it + writes and the one before it, through that layout's reader rather than a + converter — see [deploy](deploy.md) — and refuses anything older. In + development the answer to a refusal is to delete the directory. The stamp is only worth something if somebody bumps it, so the durability suite pins the on-disk shape against the number — the variants read out diff --git a/prek.toml b/prek.toml index c6711e72..c25a56e7 100644 --- a/prek.toml +++ b/prek.toml @@ -26,6 +26,12 @@ default_install_hook_types = ["pre-commit", "commit-msg"] # people's licence text, reproduced to satisfy the terms it carries, and the # MPL-2.0 text has trailing spaces of its own. Edited, it stops matching what # `scripts/gen-notices.sh` writes and the `notices` hook below fails. +# +# crates/didbot-pds/tests/fixtures/layout/ is excluded because it is a data +# directory a released binary wrote: framed, checksummed bytes, and a blob +# whose filename is the hash of its contents. A newline appended to any of +# them is a fixture that no longer decodes, and the diff would look like +# tidying. [[repos]] repo = "https://github.com/pre-commit/pre-commit-hooks" rev = "v6.0.0" @@ -35,9 +41,9 @@ hooks = [ { id = "check-yaml", stages = ["pre-commit"] }, { id = "check-toml", stages = ["pre-commit"] }, { id = "check-json", stages = ["pre-commit"] }, - { id = "mixed-line-ending", args = ["--fix=lf"], stages = ["pre-commit"], exclude = '^(vendor/|THIRD-PARTY-NOTICES\.txt$)' }, - { id = "end-of-file-fixer", stages = ["pre-commit"], exclude = '^(vendor/|THIRD-PARTY-NOTICES\.txt$)' }, - { id = "trailing-whitespace", stages = ["pre-commit"], exclude = '^(vendor/|THIRD-PARTY-NOTICES\.txt$)' }, + { id = "mixed-line-ending", args = ["--fix=lf"], stages = ["pre-commit"], exclude = '^(vendor/|crates/didbot-pds/tests/fixtures/layout/|THIRD-PARTY-NOTICES\.txt$)' }, + { id = "end-of-file-fixer", stages = ["pre-commit"], exclude = '^(vendor/|crates/didbot-pds/tests/fixtures/layout/|THIRD-PARTY-NOTICES\.txt$)' }, + { id = "trailing-whitespace", stages = ["pre-commit"], exclude = '^(vendor/|crates/didbot-pds/tests/fixtures/layout/|THIRD-PARTY-NOTICES\.txt$)' }, ] # Run the project's own toolchain rather than a pinned copy of it. Every cargo -- 2.51.2