| // Licensed to the Apache Software Foundation (ASF) under one |
| // or more contributor license agreements. See the NOTICE file |
| // distributed with this work for additional information |
| // regarding copyright ownership. The ASF licenses this file |
| // to you under the Apache License, Version 2.0 (the |
| // "License"); you may not use this file except in compliance |
| // with the License. You may obtain a copy of the License at |
| // |
| // http://www.apache.org/licenses/LICENSE-2.0 |
| // |
| // Unless required by applicable law or agreed to in writing, |
| // software distributed under the License is distributed on an |
| // "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY |
| // KIND, either express or implied. See the License for the |
| // specific language governing permissions and limitations |
| // under the License. |
| |
| //! Durable superblock: a small record that survives a crash intact. |
| //! |
| //! Persists the VSR state consensus cannot recover from its own disk |
| //! (`view`, `log_view`, `commit`). The payload is opaque here: this layer |
| //! makes N bytes durable and picks the latest good copy, nothing more. |
| //! Consensus owns their meaning. |
| //! |
| //! [`PingPongSuperblock`] writes two files alternately, higher valid |
| //! `sequence` wins, each replaced by writing a temp, fsyncing it, renaming it |
| //! over the target, then fsyncing the directory (as `PrepareJournal` does for |
| //! WAL compaction). Rename is atomic, so a torn write dies in the temp while |
| //! the other file stays an intact prior generation: an update can never destroy |
| //! the last good record. |
| //! |
| //! By that same atomicity, a slot holding bytes that fail verification is bit-rot |
| //! of a once-durable record, not an interrupted write. Its `sequence` is |
| //! unreadable, so it may be the NEWER generation, and reading through it would |
| //! hand consensus an older `view` / `log_view` than it already acted on. Such a |
| //! slot yields [`SuperblockContents::Unreadable`] and blocks writes, so the next |
| //! `write` cannot stamp a fresh sequence over the evidence. |
| //! |
| //! The record already carries the `version`, `sequence`, and |
| //! `parent_checksum` an N-copy in-place quorum variant would need, so that |
| //! migration stays contained to a second `impl SuperblockStore`. |
| |
| // Every future here drives compio single-threaded file I/O and is `!Send` by |
| // construction, like the sibling `FileStorage`/`PrepareJournal`. |
| #![allow(clippy::future_not_send)] |
| |
| use std::cell::Cell; |
| use std::hash::Hasher; |
| use std::io; |
| use std::path::{Path, PathBuf}; |
| |
| use compio::io::{AsyncReadAtExt, AsyncWriteAtExt}; |
| |
| use crate::prepare_journal::TmpFileGuard; |
| use twox_hash::XxHash3_64; |
| |
| /// Identifies a superblock file and rejects a foreign or zeroed one. "SBLK". |
| const SUPERBLOCK_MAGIC: u32 = 0x5342_4C4B; |
| /// On-disk record format version. Bump when the framing or payload contract |
| /// changes; `read` rejects an unknown version rather than misparsing it. |
| const SUPERBLOCK_VERSION: u16 = 1; |
| |
| /// Fixed framing ahead of the payload (28 bytes): magic, version, a reserved |
| /// half-word, sequence, parent checksum, and payload length. |
| const HEADER_LEN: usize = 28; |
| /// Trailing `XxHash3_64` over every preceding byte. |
| const CHECKSUM_LEN: usize = 8; |
| /// Smallest well-formed record (empty payload). |
| const MIN_RECORD_LEN: usize = HEADER_LEN + CHECKSUM_LEN; |
| |
| /// Ceiling on a record's payload, bounding every allocation this module makes from |
| /// a length it read off disk (`PrepareJournal::MAX_ENTRY_SIZE` bounds the WAL for the |
| /// same reason). The only payload today is a [`consensus::VsrState`], 74 bytes now |
| /// that it carries the offset reservation (66 before it, a length its decode still |
| /// accepts); the headroom is for a payload that grows fields, not for bulk data. |
| /// `read_slot` treats a longer file as corrupt WITHOUT reading it, and |
| /// `build_record` refuses to write one, so a length this store could have |
| /// produced is always in bounds. |
| const MAX_PAYLOAD_LEN: usize = 4096; |
| /// Largest record `read_slot` will read into memory. |
| const MAX_RECORD_LEN: usize = HEADER_LEN + MAX_PAYLOAD_LEN + CHECKSUM_LEN; |
| |
| /// First retry delay after a failed superblock write, doubling per failure. |
| /// |
| /// Starts near the 10 ms consensus tick, so a one-off failure costs no |
| /// latency, and a persistent one stops re-running `atomic_replace` per tick. |
| pub const SUPERBLOCK_RETRY_BACKOFF_BASE_MICROS: u64 = 10_000; |
| /// Ceiling on the retry delay. A replica fenced this long is not coming back without |
| /// operator action, but the retry must stay frequent enough to recover on its own the |
| /// moment the disk does. |
| pub const SUPERBLOCK_RETRY_BACKOFF_MAX_MICROS: u64 = 1_000_000; |
| /// Caps the doubling so the shift cannot overflow before the ceiling clamps it. |
| pub const SUPERBLOCK_RETRY_BACKOFF_MAX_SHIFT: u64 = 8; |
| |
| const FILE_A: &str = "superblock.a"; |
| const FILE_B: &str = "superblock.b"; |
| |
| /// The two ping-pong slot file names under a superblock directory. |
| /// |
| /// Newest wins by `sequence`. Exposed so an off-runtime reader (tests, tooling) |
| /// can read the slots with blocking I/O and decode them via [`decode_slots`]. |
| pub const SLOT_FILE_NAMES: [&str; 2] = [FILE_A, FILE_B]; |
| |
| /// Outcome of reading the latest record from a [`SuperblockStore`]. |
| /// |
| /// The three states stay distinct because "never written" and "written, now |
| /// unreadable" demand opposite recovery responses. Collapsing them (as an |
| /// `Option` would) lets a lost or corrupt superblock masquerade as a fresh |
| /// deployment: the split-brain footgun this type exists to remove. |
| #[derive(Debug, Clone, PartialEq, Eq)] |
| pub enum SuperblockContents { |
| /// Every slot absent or zero-length. The only state a genuinely fresh |
| /// deployment produces. |
| Empty, |
| /// The newest checksum-clean record of a supported version. |
| Present(Vec<u8>), |
| /// No slot pair this build can trust: bytes that fail verification on every |
| /// copy, or one clean record beside a copy that does not verify, whose lost |
| /// `sequence` may have been the newer. `version` names an unrecognized format |
| /// version when that was the cause (a downgrade). Never "fresh". |
| Unreadable { version: Option<u16> }, |
| } |
| |
| /// A durable store for a single small record. |
| /// |
| /// `write` returns only once the record is durable, and a crash during it must |
| /// never destroy the record a prior `write` made durable. |
| pub trait SuperblockStore { |
| /// Persist `payload` as the newest record. Durable on return. |
| /// |
| /// # Errors |
| /// I/O error if the record cannot be made durable, or `InvalidInput` if |
| /// `payload` exceeds `u32::MAX`. |
| fn write(&self, payload: &[u8]) -> impl Future<Output = io::Result<()>>; |
| |
| /// Read the latest record. See [`SuperblockContents`]. |
| /// |
| /// # Errors |
| /// I/O error if a slot exists but cannot be read. A checksum, magic, or |
| /// version failure is NOT an I/O error: it surfaces as |
| /// [`SuperblockContents::Unreadable`] so the caller decides how to respond. |
| fn read_latest(&self) -> impl Future<Output = io::Result<SuperblockContents>>; |
| } |
| |
| #[derive(Clone, Copy)] |
| enum Slot { |
| A, |
| B, |
| } |
| |
| impl Slot { |
| const fn other(self) -> Self { |
| match self { |
| Self::A => Self::B, |
| Self::B => Self::A, |
| } |
| } |
| |
| const fn file_name(self) -> &'static str { |
| match self { |
| Self::A => FILE_A, |
| Self::B => FILE_B, |
| } |
| } |
| } |
| |
| /// Two-file ping-pong superblock. See the module docs for the durability |
| /// argument. |
| pub struct PingPongSuperblock { |
| dir: PathBuf, |
| /// Sequence to stamp on the next `write`. Monotonic; selects the latest |
| /// record on read. |
| next_sequence: Cell<u64>, |
| /// Slot the next `write` targets: always the one NOT holding the latest |
| /// record, so an interrupted write cannot corrupt the newest good copy. |
| next_slot: Cell<Slot>, |
| /// Set at `open` when a slot held bytes that do not verify, which `write` then |
| /// refuses to overwrite (see the `write` impl). Fixed for the store's |
| /// lifetime: repair happens out of band. |
| degraded: bool, |
| /// Debug tripwire for the single-writer contract (see the `write` impl). |
| /// Set across the `.await`, so an overlapping second writer trips the assert |
| /// instead of silently tearing a slot. Absent in release builds. |
| #[cfg(debug_assertions)] |
| writing: Cell<bool>, |
| } |
| |
| impl PingPongSuperblock { |
| /// Open the superblock rooted at `dir`, which must already exist. Classifies |
| /// both slots to resume the sequence counter and aim the next write at the |
| /// staler slot. Missing files mean a fresh store. |
| /// |
| /// Succeeds even when a slot is unusable, so the caller reads the typed verdict |
| /// from [`SuperblockStore::read_latest`] rather than a bare I/O error. Writes |
| /// are refused in that state. |
| /// |
| /// # Errors |
| /// I/O error if a slot file exists but cannot be read. |
| pub async fn open(dir: impl Into<PathBuf>) -> io::Result<Self> { |
| Ok(Self::open_with_latest(dir).await?.0) |
| } |
| |
| /// [`Self::open`] plus the latest recorded contents, from the SAME slot |
| /// reads `open` already performs -- boot paths that would otherwise call |
| /// `read_latest` right after `open` pay four slot reads per group |
| /// instead of two. |
| /// |
| /// # Errors |
| /// Same as [`Self::open`]: any slot read failing at the io layer. |
| pub async fn open_with_latest( |
| dir: impl Into<PathBuf>, |
| ) -> io::Result<(Self, SuperblockContents)> { |
| let dir = dir.into(); |
| let slot_a = read_slot(&dir.join(FILE_A)).await?; |
| let slot_b = read_slot(&dir.join(FILE_B)).await?; |
| let seq_a = sequence_of(&slot_a); |
| let seq_b = sequence_of(&slot_b); |
| |
| // Latest sequence across both slots; a slot with no readable sequence counts |
| // as 0. Sound only because `degraded` then blocks every write: such a slot |
| // may hold a higher sequence, and reusing it would break the monotonicity |
| // that selects the latest record. |
| let latest = seq_a.unwrap_or(0).max(seq_b.unwrap_or(0)); |
| // Aim the next write at the slot that does NOT hold the strictly-newest |
| // record, so an interrupted write cannot clobber it. A tie or a fresh |
| // store targets A. |
| let a_is_newest = match (seq_a, seq_b) { |
| (Some(a), Some(b)) => a >= b, |
| (Some(_), None) => true, |
| (None, _) => false, |
| }; |
| let next_slot = if a_is_newest { Slot::B } else { Slot::A }; |
| |
| let store = Self { |
| dir, |
| next_sequence: Cell::new(latest + 1), |
| next_slot: Cell::new(next_slot), |
| degraded: has_unreadable_sequence(&slot_a) || has_unreadable_sequence(&slot_b), |
| #[cfg(debug_assertions)] |
| writing: Cell::new(false), |
| }; |
| Ok((store, combine_slots(slot_a, slot_b))) |
| } |
| } |
| |
| impl SuperblockStore for PingPongSuperblock { |
| async fn write(&self, payload: &[u8]) -> io::Result<()> { |
| // An unverifiable slot looks stale to the aiming logic in `open`, so this |
| // write would land on it and stamp `sequence = latest_valid + 1` over the |
| // only evidence, leaving two clean slots whose "newest" carries the OLDER |
| // payload: the regression `read_latest` refuses, laundered into durable |
| // state. Refuse until the slot is repaired out of band. |
| if self.degraded { |
| return Err(io::Error::new( |
| io::ErrorKind::InvalidData, |
| "superblock slot holds bytes that do not verify; refusing to write over it", |
| )); |
| } |
| // Single-writer contract: callers MUST serialize `write` (the metadata |
| // superblock lock does). `sequence`/`slot` are read before the |
| // `atomic_replace` await and committed after it, so two overlapping |
| // writers would target the same slot and could tear it while both return |
| // `Ok`. The ping-pong guarantee, that the non-written slot is always an |
| // intact prior generation, holds only with one write in flight. |
| let sequence = self.next_sequence.get(); |
| let slot = self.next_slot.get(); |
| // Built before the first await so the `payload` borrow does not span it. |
| let record = build_record(sequence, payload)?; |
| // RAII, not a manual clear: a dropped write future would otherwise leave the |
| // flag latched for the store's lifetime and make every later write panic on a |
| // contract nobody violated, sending whoever debugs it to audit the lock. |
| #[cfg(debug_assertions)] |
| let _writing = WritingGuard::acquire(&self.writing); |
| atomic_replace(&self.dir, slot.file_name(), record).await?; |
| self.next_sequence.set(sequence + 1); |
| self.next_slot.set(slot.other()); |
| Ok(()) |
| } |
| |
| async fn read_latest(&self) -> io::Result<SuperblockContents> { |
| let a = read_slot(&self.dir.join(FILE_A)).await?; |
| let b = read_slot(&self.dir.join(FILE_B)).await?; |
| Ok(combine_slots(a, b)) |
| } |
| } |
| |
| /// Holds the single-writer tripwire for one `write`, clearing it on drop so a |
| /// cancelled write future cannot latch it. Debug builds only. |
| #[cfg(debug_assertions)] |
| struct WritingGuard<'a>(&'a Cell<bool>); |
| |
| #[cfg(debug_assertions)] |
| impl<'a> WritingGuard<'a> { |
| fn acquire(writing: &'a Cell<bool>) -> Self { |
| assert!( |
| !writing.replace(true), |
| "PingPongSuperblock::write called concurrently; writes must be externally serialized" |
| ); |
| Self(writing) |
| } |
| } |
| |
| #[cfg(debug_assertions)] |
| impl Drop for WritingGuard<'_> { |
| fn drop(&mut self) { |
| self.0.set(false); |
| } |
| } |
| |
| fn checksum(bytes: &[u8]) -> u64 { |
| let mut hasher = XxHash3_64::new(); |
| hasher.write(bytes); |
| hasher.finish() |
| } |
| |
| fn build_record(sequence: u64, payload: &[u8]) -> io::Result<Vec<u8>> { |
| // Refuse here rather than at the next boot: a record over the read ceiling would |
| // be written durably and then classified corrupt, which blocks writes and refuses |
| // boot over a payload the caller could have been told about synchronously. |
| if payload.len() > MAX_PAYLOAD_LEN { |
| return Err(io::Error::new( |
| io::ErrorKind::InvalidInput, |
| format!( |
| "superblock payload is {} bytes, over the {MAX_PAYLOAD_LEN}-byte ceiling", |
| payload.len() |
| ), |
| )); |
| } |
| let payload_len = u32::try_from(payload.len()) |
| .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "superblock payload too large"))?; |
| |
| let mut record = Vec::with_capacity(MIN_RECORD_LEN + payload.len()); |
| record.extend_from_slice(&SUPERBLOCK_MAGIC.to_le_bytes()); |
| record.extend_from_slice(&SUPERBLOCK_VERSION.to_le_bytes()); |
| record.extend_from_slice(&0u16.to_le_bytes()); // reserved |
| record.extend_from_slice(&sequence.to_le_bytes()); |
| record.extend_from_slice(&0u64.to_le_bytes()); // parent_checksum, unused by ping-pong |
| record.extend_from_slice(&payload_len.to_le_bytes()); |
| record.extend_from_slice(payload); |
| let ck = checksum(&record); |
| record.extend_from_slice(&ck.to_le_bytes()); |
| Ok(record) |
| } |
| |
| /// Reach the same verdict as [`PingPongSuperblock::read_latest`] from the two |
| /// slots' raw bytes, without a runtime. |
| /// |
| /// `slot_a` / `slot_b` are the file contents read from [`SLOT_FILE_NAMES`], or |
| /// `None` for a missing slot. Shares the classification and selection rules with |
| /// the async path, so an off-runtime reader (tests, tooling) cannot conclude |
| /// `Present` where the server would refuse to boot. |
| #[must_use] |
| pub fn decode_slots(slot_a: Option<&[u8]>, slot_b: Option<&[u8]>) -> SuperblockContents { |
| combine_slots(classify_bytes(slot_a), classify_bytes(slot_b)) |
| } |
| |
| /// One slot's contents, classified. Separates "nothing here" from "something |
| /// here but unusable" so [`combine_slots`] can tell `Empty` from `Unreadable`. |
| enum SlotClass { |
| /// File missing or zero-length. |
| Absent, |
| /// Bytes present but unusable: too short, foreign magic, or a length/checksum |
| /// failure. `version` is `Some` when the magic matched but the format version is |
| /// unknown to this build, so nothing past that field can be trusted or |
| /// checksum-verified; [`combine_slots`] reports that case over generic corruption, |
| /// since it names a downgrade. |
| Corrupt { version: Option<u16> }, |
| /// A checksum-clean record of the current version. |
| Valid { sequence: u64, payload: Vec<u8> }, |
| } |
| |
| /// Classify one slot from raw bytes, `Absent` for a missing or empty slot. |
| /// Mirrors [`read_slot`]'s treatment of a zero-length file. |
| fn classify_bytes(bytes: Option<&[u8]>) -> SlotClass { |
| match bytes { |
| None | Some([]) => SlotClass::Absent, |
| Some(bytes) => classify(bytes), |
| } |
| } |
| |
| /// Classify one slot's raw, non-empty bytes: framing, version, payload length and |
| /// checksum in one pass, so no slot is parsed twice. |
| fn classify(bytes: &[u8]) -> SlotClass { |
| let corrupt = SlotClass::Corrupt { version: None }; |
| // Too short to hold even the framing, or a foreign file: unusable bytes. |
| if bytes.len() < MIN_RECORD_LEN { |
| return corrupt; |
| } |
| if u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]) != SUPERBLOCK_MAGIC { |
| return corrupt; |
| } |
| let version = u16::from_le_bytes([bytes[4], bytes[5]]); |
| if version != SUPERBLOCK_VERSION { |
| // Magic matched, so a superblock writer produced this, but the framing past |
| // the version field may differ, so neither the payload length nor the checksum |
| // can validate it. |
| return SlotClass::Corrupt { |
| version: Some(version), |
| }; |
| } |
| |
| let payload_len = u32::from_le_bytes([bytes[24], bytes[25], bytes[26], bytes[27]]) as usize; |
| // `decode_slots` is public and takes bytes straight from a caller, so bound the |
| // length field here too, not only via `read_slot`'s file-size ceiling. |
| if payload_len > MAX_PAYLOAD_LEN { |
| return corrupt; |
| } |
| let checksum_start = HEADER_LEN + payload_len; |
| if bytes.len() != checksum_start + CHECKSUM_LEN { |
| return corrupt; |
| } |
| let Ok(stored) = bytes[checksum_start..checksum_start + CHECKSUM_LEN].try_into() else { |
| return corrupt; |
| }; |
| if checksum(&bytes[..checksum_start]) != u64::from_le_bytes(stored) { |
| return corrupt; |
| } |
| |
| SlotClass::Valid { |
| sequence: u64::from_le_bytes([ |
| bytes[8], bytes[9], bytes[10], bytes[11], bytes[12], bytes[13], bytes[14], bytes[15], |
| ]), |
| payload: bytes[HEADER_LEN..checksum_start].to_vec(), |
| } |
| } |
| |
| /// Combine the two slots into one outcome. |
| /// |
| /// Two valid records: higher `sequence` wins. One valid record beside an absent |
| /// slot: that record, what a single write leaves behind. `Empty` only when both |
| /// slots are absent, the one state a fresh deployment produces. |
| /// |
| /// Everything else refuses boot with `Unreadable`, including a valid record beside |
| /// a slot that holds bytes but does not verify. Such a slot is bit-rot, not a torn |
| /// write (rename is atomic), and its `sequence` is gone, so it cannot be shown to |
| /// be the older of the two. Falling back would walk `view` / `log_view` backwards |
| /// and let this replica act twice in one view. An unrecognized version is reported |
| /// over generic corruption, since it names a downgrade. |
| /// |
| /// Two copies cannot do better: with no readable sequence the choice is refuse or |
| /// risk regression. An N-copy quorum could repair in place instead, which the |
| /// record's `sequence` and `parent_checksum` framing already allows for. |
| fn combine_slots(a: SlotClass, b: SlotClass) -> SuperblockContents { |
| match (a, b) { |
| ( |
| SlotClass::Valid { |
| sequence: seq_a, |
| payload: payload_a, |
| }, |
| SlotClass::Valid { |
| sequence: seq_b, |
| payload: payload_b, |
| }, |
| ) => SuperblockContents::Present(if seq_a >= seq_b { payload_a } else { payload_b }), |
| (SlotClass::Valid { payload, .. }, SlotClass::Absent) |
| | (SlotClass::Absent, SlotClass::Valid { payload, .. }) => { |
| SuperblockContents::Present(payload) |
| } |
| (SlotClass::Absent, SlotClass::Absent) => SuperblockContents::Empty, |
| ( |
| SlotClass::Corrupt { |
| version: Some(version), |
| }, |
| _, |
| ) |
| | ( |
| _, |
| SlotClass::Corrupt { |
| version: Some(version), |
| }, |
| ) => SuperblockContents::Unreadable { |
| version: Some(version), |
| }, |
| _ => SuperblockContents::Unreadable { version: None }, |
| } |
| } |
| |
| /// The slot's `sequence`, or `None` when it holds no record this build can read. |
| const fn sequence_of(slot: &SlotClass) -> Option<u64> { |
| match slot { |
| SlotClass::Valid { sequence, .. } => Some(*sequence), |
| SlotClass::Absent | SlotClass::Corrupt { .. } => None, |
| } |
| } |
| |
| /// Whether the slot holds bytes whose generation is unknowable, so it cannot be |
| /// ruled out as the newer. An absent slot never held a record to lose, so it is |
| /// not one of these. |
| const fn has_unreadable_sequence(slot: &SlotClass) -> bool { |
| matches!(slot, SlotClass::Corrupt { .. }) |
| } |
| |
| async fn read_slot(path: &Path) -> io::Result<SlotClass> { |
| let file = match compio::fs::File::open(path).await { |
| Ok(file) => file, |
| Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(SlotClass::Absent), |
| Err(e) => return Err(e), |
| }; |
| let len = file.metadata().await?.len(); |
| if len == 0 { |
| return Ok(SlotClass::Absent); |
| } |
| // Bound the allocation on `st_size` before making it. A file longer than a |
| // well-formed record cannot be one: `classify` demands an exact length, so |
| // reading it would spend the allocation only to conclude "corrupt". The realistic |
| // producer is a foreign or leftover file, not bit-rot, since the value is the |
| // file's size rather than a field inside it. |
| let len = match usize::try_from(len) { |
| Ok(len) if len <= MAX_RECORD_LEN => len, |
| _ => return Ok(SlotClass::Corrupt { version: None }), |
| }; |
| let buf = vec![0u8; len]; |
| let (result, buf) = file.read_exact_at(buf, 0).await.into(); |
| result?; |
| // A checksum/magic/version failure is not an I/O error: the other slot may |
| // still be good, so classify the bytes and let `combine_slots` decide. |
| Ok(classify(&buf)) |
| } |
| |
| /// Replace `dir/file_name` atomically: write a temp, fsync it, rename over the |
| /// target, then fsync the directory so the rename itself is durable. |
| async fn atomic_replace(dir: &Path, file_name: &str, bytes: Vec<u8>) -> io::Result<()> { |
| let tmp_path = dir.join(format!("{file_name}.tmp")); |
| let final_path = dir.join(file_name); |
| |
| // Unlink the temp on any failure before the rename. Not a correctness matter, |
| // since `File::create` truncates and `next_sequence` / `next_slot` stay |
| // un-advanced so a retry re-targets the same slot; it keeps a failing disk from |
| // littering `superblock.{a,b}.tmp` next to the slots an operator is inspecting. |
| let guard = TmpFileGuard::new(tmp_path.clone()); |
| let mut tmp = compio::fs::File::create(&tmp_path).await?; |
| let (result, _buf) = tmp.write_all_at(bytes, 0).await.into(); |
| result?; |
| tmp.sync_all().await?; |
| |
| compio::fs::rename(&tmp_path, &final_path).await?; |
| guard.defuse(); |
| |
| let dir_file = compio::fs::File::open(dir).await?; |
| dir_file.sync_all().await?; |
| Ok(()) |
| } |
| |
| #[cfg(test)] |
| mod tests { |
| use super::*; |
| use tempfile::tempdir; |
| |
| /// The payload when present, else `None`, so round-trip assertions can |
| /// compare against the written bytes. |
| fn present(contents: SuperblockContents) -> Option<Vec<u8>> { |
| match contents { |
| SuperblockContents::Present(payload) => Some(payload), |
| SuperblockContents::Empty | SuperblockContents::Unreadable { .. } => None, |
| } |
| } |
| |
| /// Fresh store, ping-pong alternation, and sequence resume across a reopen, in |
| /// one pass: `Empty` before any write, the newer payload winning after each, |
| /// and both slot files present once two writes have landed. |
| #[compio::test] |
| async fn given_reopen_when_write_should_alternate_slots_and_advance() { |
| let dir = tempdir().unwrap(); |
| { |
| let sb = PingPongSuperblock::open(dir.path()).await.unwrap(); |
| assert_eq!(sb.read_latest().await.unwrap(), SuperblockContents::Empty); |
| sb.write(b"first").await.unwrap(); |
| } |
| // Reopen: sequence resumes and the next write targets the staler slot. |
| let sb = PingPongSuperblock::open(dir.path()).await.unwrap(); |
| assert_eq!( |
| present(sb.read_latest().await.unwrap()).as_deref(), |
| Some(&b"first"[..]) |
| ); |
| sb.write(b"second").await.unwrap(); |
| assert_eq!( |
| present(sb.read_latest().await.unwrap()).as_deref(), |
| Some(&b"second"[..]) |
| ); |
| sb.write(b"third").await.unwrap(); |
| assert_eq!( |
| present(sb.read_latest().await.unwrap()).as_deref(), |
| Some(&b"third"[..]) |
| ); |
| |
| // Writes alternate slots, so both files exist. |
| assert!(dir.path().join(FILE_A).exists()); |
| assert!(dir.path().join(FILE_B).exists()); |
| } |
| |
| #[compio::test] |
| async fn given_one_slot_corrupt_when_read_latest_should_refuse_boot() { |
| let dir = tempdir().unwrap(); |
| let sb = PingPongSuperblock::open(dir.path()).await.unwrap(); |
| sb.write(b"first").await.unwrap(); // slot A, seq 1 |
| sb.write(b"second").await.unwrap(); // slot B, seq 2 (newer) |
| |
| // Corrupt the newer slot's payload byte so its checksum fails. |
| let path = dir.path().join(FILE_B); |
| let mut bytes = std::fs::read(&path).unwrap(); |
| bytes[HEADER_LEN] ^= 0xFF; |
| std::fs::write(&path, &bytes).unwrap(); |
| |
| // Falling back to slot A would serve `first`, a generation consensus already |
| // moved past. Nothing proves the corrupt slot was the older one, so refuse. |
| assert_eq!( |
| sb.read_latest().await.unwrap(), |
| SuperblockContents::Unreadable { version: None } |
| ); |
| } |
| |
| #[compio::test] |
| async fn given_corrupt_slot_when_reopen_should_refuse_write_and_keep_both_slots() { |
| // `open` aims the next write at the slot that looks stale, which is the |
| // corrupt one. Writing there would stamp `sequence = 2` over it, leaving two |
| // clean slots whose newest carries `first` and laundering the regression |
| // `read_latest` just refused. Writes stay blocked instead. |
| let dir = tempdir().unwrap(); |
| { |
| let sb = PingPongSuperblock::open(dir.path()).await.unwrap(); |
| sb.write(b"first").await.unwrap(); // slot A, seq 1 |
| sb.write(b"second").await.unwrap(); // slot B, seq 2 |
| } |
| let path_b = dir.path().join(FILE_B); |
| let mut bytes = std::fs::read(&path_b).unwrap(); |
| bytes[HEADER_LEN] ^= 0xFF; |
| std::fs::write(&path_b, &bytes).unwrap(); |
| let before_a = std::fs::read(dir.path().join(FILE_A)).unwrap(); |
| let before_b = std::fs::read(&path_b).unwrap(); |
| |
| let sb = PingPongSuperblock::open(dir.path()).await.unwrap(); |
| assert_eq!( |
| sb.read_latest().await.unwrap(), |
| SuperblockContents::Unreadable { version: None } |
| ); |
| let error = sb.write(b"third").await.unwrap_err(); |
| assert_eq!(error.kind(), io::ErrorKind::InvalidData); |
| |
| assert_eq!( |
| std::fs::read(dir.path().join(FILE_A)).unwrap(), |
| before_a, |
| "the surviving record must be left untouched for out-of-band repair" |
| ); |
| assert_eq!( |
| std::fs::read(&path_b).unwrap(), |
| before_b, |
| "the corrupt slot is the only evidence of the lost generation" |
| ); |
| } |
| |
| #[compio::test] |
| async fn given_both_slots_corrupt_when_read_latest_should_return_unreadable() { |
| let dir = tempdir().unwrap(); |
| let sb = PingPongSuperblock::open(dir.path()).await.unwrap(); |
| sb.write(b"first").await.unwrap(); |
| sb.write(b"second").await.unwrap(); |
| |
| for name in [FILE_A, FILE_B] { |
| let path = dir.path().join(name); |
| let mut bytes = std::fs::read(&path).unwrap(); |
| bytes[HEADER_LEN] ^= 0xFF; |
| std::fs::write(&path, &bytes).unwrap(); |
| } |
| |
| // Bytes on both slots, neither verifies: a torn superblock, NOT a fresh |
| // deployment. Recovery must refuse boot. |
| assert_eq!( |
| sb.read_latest().await.unwrap(), |
| SuperblockContents::Unreadable { version: None } |
| ); |
| } |
| |
| #[compio::test] |
| async fn given_unsupported_version_when_read_latest_should_report_version() { |
| // Magic matches but the format version is unknown: a downgrade, or a |
| // corrupt version field. Version is checked before the checksum, so |
| // patching it alone suffices. |
| let dir = tempdir().unwrap(); |
| let sb = PingPongSuperblock::open(dir.path()).await.unwrap(); |
| sb.write(b"payload").await.unwrap(); // slot A, current version |
| |
| let path = dir.path().join(FILE_A); |
| let mut bytes = std::fs::read(&path).unwrap(); |
| let bogus = SUPERBLOCK_VERSION + 1; |
| bytes[4..6].copy_from_slice(&bogus.to_le_bytes()); |
| std::fs::write(&path, &bytes).unwrap(); |
| |
| assert_eq!( |
| sb.read_latest().await.unwrap(), |
| SuperblockContents::Unreadable { |
| version: Some(bogus) |
| } |
| ); |
| |
| // A clean record beside it does not rescue the read: this build cannot |
| // sequence the unrecognized slot, so that slot may be the newer generation. |
| sb.write(b"newer").await.unwrap(); // slot B, current version |
| assert_eq!( |
| sb.read_latest().await.unwrap(), |
| SuperblockContents::Unreadable { |
| version: Some(bogus) |
| } |
| ); |
| } |
| |
| #[compio::test] |
| async fn given_two_writes_when_decode_slots_from_disk_should_match_read_latest() { |
| // The off-runtime decoder must reach the same verdict the async read path |
| // does: newer sequence wins across the two slots. |
| let dir = tempdir().unwrap(); |
| let sb = PingPongSuperblock::open(dir.path()).await.unwrap(); |
| sb.write(b"first").await.unwrap(); |
| sb.write(b"second").await.unwrap(); |
| |
| let read_slots = || { |
| let slot_a = std::fs::read(dir.path().join(SLOT_FILE_NAMES[0])).ok(); |
| let slot_b = std::fs::read(dir.path().join(SLOT_FILE_NAMES[1])).ok(); |
| decode_slots(slot_a.as_deref(), slot_b.as_deref()) |
| }; |
| assert_eq!(read_slots(), sb.read_latest().await.unwrap()); |
| assert_eq!( |
| read_slots(), |
| SuperblockContents::Present(b"second".to_vec()) |
| ); |
| |
| // And it must refuse where the server would, not read through a corrupt slot |
| // to the older record. |
| let path = dir.path().join(SLOT_FILE_NAMES[1]); |
| let mut bytes = std::fs::read(&path).unwrap(); |
| bytes[HEADER_LEN] ^= 0xFF; |
| std::fs::write(&path, &bytes).unwrap(); |
| |
| assert_eq!(read_slots(), sb.read_latest().await.unwrap()); |
| assert_eq!( |
| read_slots(), |
| SuperblockContents::Unreadable { version: None } |
| ); |
| } |
| |
| // Two `write`s interleaving across the `atomic_replace` await read the same |
| // `(sequence, slot)` and would tear that slot while both report success. |
| // Production serializes writes behind the metadata superblock lock; this |
| // proves the debug tripwire catches a bypass instead of corrupting a slot. |
| // Compiled out of release builds. |
| #[cfg(debug_assertions)] |
| #[compio::test] |
| #[should_panic(expected = "called concurrently")] |
| async fn given_overlapping_writes_when_write_should_trip_single_writer_guard() { |
| let dir = tempdir().unwrap(); |
| let sb = PingPongSuperblock::open(dir.path()).await.unwrap(); |
| // Polling both together parks the first on its `atomic_replace` await |
| // holding the writer flag, so the second trips the assert on the same poll. |
| let (first, second) = futures::future::join(sb.write(b"first"), sb.write(b"second")).await; |
| let _ = (first, second); |
| } |
| |
| // A dropped write future must not latch the tripwire: the flag is a |
| // single-writer contract check, and leaving it set would make every later write |
| // panic on a violation that never happened. |
| #[cfg(debug_assertions)] |
| #[compio::test] |
| async fn given_dropped_write_future_when_writing_again_should_not_trip_guard() { |
| let dir = tempdir().unwrap(); |
| let sb = PingPongSuperblock::open(dir.path()).await.unwrap(); |
| { |
| let mut write = Box::pin(sb.write(b"cancelled")); |
| // One poll parks it inside `atomic_replace` with the flag held, then the |
| // future is dropped without ever completing. |
| assert!( |
| futures::poll!(&mut write).is_pending(), |
| "the write must park on file I/O for this to model a cancellation" |
| ); |
| } |
| |
| sb.write(b"after").await.unwrap(); |
| assert_eq!( |
| sb.read_latest().await.unwrap(), |
| SuperblockContents::Present(b"after".to_vec()) |
| ); |
| } |
| |
| #[compio::test] |
| async fn given_oversized_payload_when_write_should_refuse() { |
| // Refused synchronously rather than written and then classified corrupt on the |
| // next boot, which would block writes and refuse boot over a payload the |
| // caller could have been told about here. |
| let dir = tempdir().unwrap(); |
| let sb = PingPongSuperblock::open(dir.path()).await.unwrap(); |
| let error = sb |
| .write(&vec![0u8; MAX_PAYLOAD_LEN + 1]) |
| .await |
| .expect_err("a payload over the ceiling must be refused"); |
| assert_eq!(error.kind(), io::ErrorKind::InvalidInput); |
| assert_eq!( |
| sb.read_latest().await.unwrap(), |
| SuperblockContents::Empty, |
| "a refused write must leave the store untouched" |
| ); |
| } |
| |
| #[compio::test] |
| async fn given_oversized_slot_file_when_read_latest_should_refuse_without_reading_it() { |
| // A file longer than a well-formed record cannot be one (`classify` demands |
| // an exact length), so it is classified from its size alone. The verdict must |
| // still be the fail-closed one: a foreign file in a slot is not a fresh |
| // deployment. |
| let dir = tempdir().unwrap(); |
| let path = dir.path().join(SLOT_FILE_NAMES[0]); |
| let mut bytes = Vec::with_capacity(MAX_RECORD_LEN + 1); |
| bytes.extend_from_slice(&SUPERBLOCK_MAGIC.to_le_bytes()); |
| bytes.resize(MAX_RECORD_LEN + 1, 0); |
| std::fs::write(&path, &bytes).unwrap(); |
| |
| let sb = PingPongSuperblock::open(dir.path()).await.unwrap(); |
| assert_eq!( |
| sb.read_latest().await.unwrap(), |
| SuperblockContents::Unreadable { version: None } |
| ); |
| assert!( |
| sb.write(b"payload").await.is_err(), |
| "a slot whose generation is unknowable must block writes" |
| ); |
| } |
| } |