//! Immutable sealed segments: bounded `pread` reads, the open-FD cache, and //! integrity checks. Also the shard's on-disk layout, the seal/rotate //! sequence of scope 3.4, and the manifest install of scope 3.5. //! //! **Owned by A1 JournalWriter** (scope 2.1, 4-A1). //! //! Frames are byte-identical between a journal and the segment it seals into, //! so one golden frame corpus covers both readers: sealing appends a footer //! and renames, and never re-encodes a frame byte. use std::collections::HashMap; use std::fs::File; use std::io::IoSlice; use std::path::{Path, PathBuf}; use std::sync::Arc; use crate::format::{ CurrentPointer, Frame, FrameError, JournalHeader, Manifest, SegmentFooter, FORMAT_MARKER_LEN, JOURNAL_HEADER_LEN, SEGMENT_FOOTER_LOCATOR_LEN, STORAGE_VERSION, }; use crate::journal::{Journal, TailScan}; use crate::sys; use crate::types::{DurabilityCounters, StoreError}; /// The root layout of scope 3.1. #[derive(Clone, Debug)] pub struct RootLayout { pub root: PathBuf, } impl RootLayout { pub fn new(root: impl Into) -> Self { Self { root: root.into() } } pub fn format_path(&self) -> PathBuf { self.root.join("FORMAT") } pub fn lock_path(&self) -> PathBuf { self.root.join("LOCK") } pub fn quarantine_dir(&self) -> PathBuf { self.root.join("quarantine") } pub fn staging_dir(&self) -> PathBuf { self.root.join("staging") } pub fn shards_dir(&self) -> PathBuf { self.root.join("shards") } /// Shard directories are two-digit for the first hundred and widen after, /// so a listing sorts in shard order for the common topologies. pub fn shard(&self, shard: u16) -> ShardPaths { ShardPaths::new(self.shards_dir().join(format!("{shard:02}"))) } } /// One shard's directories and its `CURRENT` pointer. #[derive(Clone, Debug)] pub struct ShardPaths { pub dir: PathBuf, } impl ShardPaths { pub fn new(dir: impl Into) -> Self { Self { dir: dir.into() } } pub fn active(&self) -> PathBuf { self.dir.join("active") } pub fn segments(&self) -> PathBuf { self.dir.join("segments") } pub fn indexes(&self) -> PathBuf { self.dir.join("indexes") } pub fn checkpoints(&self) -> PathBuf { self.dir.join("checkpoints") } pub fn manifests(&self) -> PathBuf { self.dir.join("manifests") } pub fn current(&self) -> PathBuf { self.dir.join("CURRENT") } pub fn manifest(&self, generation: u64) -> PathBuf { self.manifests().join(manifest_filename(generation)) } pub fn segment(&self, generation: u64, first: u64, last: u64) -> PathBuf { self.segments() .join(segment_filename(generation, first, last)) } } pub fn manifest_filename(generation: u64) -> String { format!("{generation}.manifest") } pub fn segment_filename(generation: u64, first: u64, last: u64) -> String { format!("{generation}-{first}-{last}.seg") } // --------------------------------------------------------------------------- // Root initialization // --------------------------------------------------------------------------- /// Create the v2 tree and write `FORMAT`, fsyncing every new directory and the /// root's parent (scope 3.1 startup state 1). /// /// `shard_count` is frozen here for the life of the root. Opening later with a /// different configured value is a hard `FormatMismatch` and never a silent /// reroute: per-repository sequence ownership is only sound while a /// repository's shard assignment never moves. pub fn initialize_root( layout: &RootLayout, shard_count: u16, root_uuid: [u8; 16], created_at_micros: i64, counters: &DurabilityCounters, ) -> Result { if shard_count == 0 { return Err(StoreError::InvalidConfiguration( "shard_count must be nonzero".into(), )); } std::fs::create_dir_all(&layout.root)?; std::fs::create_dir_all(layout.quarantine_dir())?; std::fs::create_dir_all(layout.staging_dir())?; std::fs::create_dir_all(layout.shards_dir())?; for shard in 0..shard_count { let paths = layout.shard(shard); for dir in [ paths.dir.clone(), paths.active(), paths.segments(), paths.indexes(), paths.checkpoints(), paths.manifests(), ] { std::fs::create_dir_all(&dir)?; sys::fsync_dir(&dir, counters)?; } sys::fsync_dir(&paths.dir, counters)?; } let marker = crate::format::FormatMarker { format_version: 2, storage_version: STORAGE_VERSION, shard_count, root_uuid, created_at_micros, }; let bytes = marker.encode()?; let tmp = layout.root.join("FORMAT.tmp"); write_fenced(&tmp, &bytes, counters)?; sys::rename_noreplace(&tmp, &layout.format_path())?; sys::fsync_dir(&layout.shards_dir(), counters)?; sys::fsync_dir(&layout.quarantine_dir(), counters)?; sys::fsync_dir(&layout.root, counters)?; if let Some(parent) = layout.root.parent() { sys::fsync_dir(parent, counters)?; } Ok(marker) } /// Read and validate `FORMAT`. pub fn read_format(layout: &RootLayout) -> Result { let file = File::open(layout.format_path())?; let mut bytes = [0u8; FORMAT_MARKER_LEN]; sys::pread_exact(&file, 0, &mut bytes)?; Ok(crate::format::FormatMarker::decode(&bytes)?) } /// Take the exclusive root lock. Failure is `AlreadyLocked`, never a wait /// (scope 3.1). pub fn lock_root(layout: &RootLayout) -> Result { let file = File::options() .create(true) .read(true) .write(true) .truncate(false) .open(layout.lock_path())?; if sys::try_lock_exclusive(&file)? { Ok(file) } else { Err(StoreError::AlreadyLocked) } } /// Write a file and fence it. The name is not yet durable; the caller renames /// and syncs the directory. fn write_fenced( path: &Path, bytes: &[u8], counters: &DurabilityCounters, ) -> Result<(), StoreError> { let mut file = File::options() .create(true) .write(true) .truncate(true) .open(path)?; // Through the funnel, so the counters see every durable byte. sys::write_vectored_all(&mut file, &[IoSlice::new(bytes)], counters)?; sys::fdatasync(&file, counters)?; Ok(()) } // --------------------------------------------------------------------------- // Sealing (scope 3.4) // --------------------------------------------------------------------------- /// Seal a full journal into `segments/--.seg`. /// /// Steps 1–4 of scope 3.4 as amended, in order: /// /// 1. stop appending and validate the whole file forward; /// 2. append the segment footer; /// 3. `fdatasync` the file; /// 4. **`link`** into `segments/` and `fsync_dir` on `segments/`. /// /// Not a single frame byte is re-encoded: the forward validation reads the /// frames back and the footer records where they already are. /// /// # Why `link`, not `rename` /// /// The first draft of scope 3.4 said to *rename* here and then, in step 5, to /// "install a new `CURRENT` naming it and only then unlink the old journal /// name" — but a rename leaves no old name to unlink, and the window it opens /// is a real durability hole: a crash between the rename and the manifest /// install leaves a valid segment that no manifest generation references and /// no active journal where those frames used to be. Recovery step 2 trusts /// only manifest-referenced files, so a fenced, acknowledged prefix would be /// lost, violating plan §4 transaction invariant 3. /// /// With `link`, both names exist across the whole install, so every crash /// point leaves the frames reachable through at least one of them. The caller /// finishes with [`unlink_sealed_journal`] after the manifest is installed. /// /// The cost is a state recovery must expect: between step 4 and step 6 an /// `active/` journal exists whose `journal_id` is already covered by a /// manifest-referenced segment. That is a completed seal whose final unlink /// was lost — the stale active name is unlinked and the frames are taken from /// the manifest, never replayed twice. pub fn seal_journal( journal: &mut Journal, paths: &ShardPaths, generation: u64, counters: &DurabilityCounters, ) -> Result { // --- step 1: validate the whole file forward ------------------------ let scan = journal.scan_from_start(); if scan.stop_offset != journal.cursor() { return Err(StoreError::Corruption(format!( "sealing refused: forward validation ends at {} but the write cursor is {}", scan.stop_offset, journal.cursor() ))); } let index: Vec<(u64, u64, u64)> = scan .frames .iter() .map(|f| (f.shard_sequence, f.offset, f.len)) .collect(); if index.is_empty() { return Err(StoreError::Corruption( "sealing refused: the journal contains no complete frame".into(), )); } let first = index.first().expect("non-empty").0; let last = index.last().expect("non-empty").0; // --- steps 2 and 3: append the footer and fence --------------------- let footer = SegmentFooter { root_uuid: journal.header().root_uuid, journal_id: journal.header().journal_id, generation, first_shard_sequence: first, last_shard_sequence: last, frame_count: index.len() as u64, offsets: index, }; let bytes = footer.encode()?; journal.truncate_and_append_footer(&bytes)?; // --- step 4: publish the segment name, keeping the active one ------- let destination = paths.segment(generation, first, last); sys::link_noreplace(journal.path(), &destination)?; sys::fsync_dir(&paths.segments(), counters)?; Ok(destination) } /// Seal a recovery-validated prefix without ever modifying its source journal. /// /// The crash image is forensic evidence. Recovery therefore copies exactly /// `0..scan.stop_offset` into a uniquely named, fenced artifact, opens that /// copy through the normal journal scanner, and seals the copy through /// [`seal_journal`]. The original descriptor is read-only throughout. /// /// A crash may leave the deterministic final segment installed but not yet /// referenced by a manifest. Re-recovery accepts that artifact only after its /// footer, frame index, and every byte of the validated prefix agree with the /// source. An occupied name with different contents is corruption, never an /// overwrite. pub fn seal_recovered_prefix( source: &File, header: &JournalHeader, scan: &TailScan, paths: &ShardPaths, generation: u64, counters: &Arc, ) -> Result { if scan.frames.is_empty() { return Err(StoreError::Corruption( "recovery sealing refused: the validated prefix contains no frame".into(), )); } if scan.frames.first().map(|frame| frame.offset) != Some(JOURNAL_HEADER_LEN as u64) { return Err(StoreError::Corruption( "recovery sealing refused: the validated prefix does not start after the header".into(), )); } let last_end = scan .frames .last() .and_then(|frame| frame.offset.checked_add(frame.len)) .ok_or_else(|| { StoreError::Corruption("recovery sealing refused: invalid frame range".into()) })?; if last_end != scan.stop_offset { return Err(StoreError::Corruption(format!( "recovery sealing refused: frame prefix ends at {last_end}, scan stops at {}", scan.stop_offset ))); } let first = scan.frames.first().expect("non-empty").shard_sequence; let last = scan.frames.last().expect("non-empty").shard_sequence; let destination = paths.segment(generation, first, last); let artifact = recovery_prefix_path(paths, header, generation); if destination.exists() { validate_recovered_segment(source, header, scan, &destination, generation)?; if artifact.exists() { let artifact_file = File::open(&artifact)?; let compare_len = artifact_file.metadata()?.len().min(scan.stop_offset); compare_prefix(source, &artifact_file, compare_len, &artifact)?; sys::unlink(&artifact)?; sys::fsync_dir(&paths.segments(), counters)?; } return Ok(destination); } let mut copied = File::options() .create(true) .truncate(false) .read(true) .write(true) .open(&artifact)?; let existing_len = copied.metadata()?.len(); let compare_len = existing_len.min(scan.stop_offset); compare_prefix(source, &copied, compare_len, &artifact)?; if existing_len > scan.stop_offset { // A crash after footer append but before publishing the segment may // leave a sealed or partially sealed construction artifact. Its exact // prefix proves ownership; discard only the construction suffix and // run the normal seal again. sys::truncate(&copied, scan.stop_offset, counters)?; } else if existing_len < scan.stop_offset { copy_exact_prefix( source, &mut copied, existing_len, scan.stop_offset, counters, )?; } sys::fdatasync(&copied, counters)?; sys::fsync_dir(&paths.segments(), counters)?; drop(copied); let (mut journal, copied_scan) = Journal::open(&artifact, &header.root_uuid, Arc::clone(counters))?; if journal.header() != header || copied_scan.frames != scan.frames || copied_scan.stop_offset != scan.stop_offset { return Err(StoreError::Corruption( "the fenced recovery prefix did not reopen as the source prefix".into(), )); } let installed = seal_journal(&mut journal, paths, generation, counters)?; validate_recovered_segment(source, header, scan, &installed, generation)?; // The installed segment is a hard link to the now-sealed copy. Once its // own name is fenced the uniquely named construction artifact is dead. sys::unlink(&artifact)?; sys::fsync_dir(&paths.segments(), counters)?; Ok(installed) } fn recovery_prefix_path(paths: &ShardPaths, header: &JournalHeader, generation: u64) -> PathBuf { paths.segments().join(format!( ".recovery-{}-{generation}.prefix", hex::encode(header.journal_id) )) } fn copy_exact_prefix( source: &File, destination: &mut File, from: u64, through: u64, counters: &DurabilityCounters, ) -> Result<(), StoreError> { const COPY_CHUNK: usize = 1 << 20; let mut offset = from; sys::seek_to(destination, from)?; while offset < through { let remaining = through - offset; let len = usize::try_from(remaining.min(COPY_CHUNK as u64)) .map_err(|_| StoreError::Corruption("recovery prefix length overflow".into()))?; let mut bytes = vec![0u8; len]; sys::pread_exact(source, offset, &mut bytes)?; let end = sys::write_vectored_all(destination, &[IoSlice::new(&bytes)], counters)?; let expected = offset .checked_add(len as u64) .ok_or_else(|| StoreError::Corruption("recovery prefix length overflow".into()))?; if end != expected { return Err(StoreError::Corruption(format!( "short recovery-prefix copy: expected cursor {expected}, got {end}" ))); } offset = expected; } Ok(()) } fn compare_prefix( source: &File, artifact: &File, through: u64, artifact_path: &Path, ) -> Result<(), StoreError> { const COMPARE_CHUNK: usize = 1 << 20; let mut offset = 0u64; while offset < through { let remaining = through - offset; let len = usize::try_from(remaining.min(COMPARE_CHUNK as u64)) .map_err(|_| StoreError::Corruption("recovery prefix length overflow".into()))?; let mut original = vec![0u8; len]; let mut retained = vec![0u8; len]; sys::pread_exact(source, offset, &mut original)?; sys::pread_exact(artifact, offset, &mut retained)?; if original != retained { return Err(StoreError::Corruption(format!( "recovery construction artifact {} differs from its source at offset {offset}", artifact_path.display() ))); } offset += len as u64; } Ok(()) } fn validate_recovered_segment( source: &File, header: &JournalHeader, scan: &TailScan, destination: &Path, generation: u64, ) -> Result<(), StoreError> { let reader = SegmentReader::open(destination, &header.root_uuid)?; let footer = reader.footer(); let expected_offsets: Vec<(u64, u64, u64)> = scan .frames .iter() .map(|frame| (frame.shard_sequence, frame.offset, frame.len)) .collect(); if reader.journal_header()? != *header || footer.journal_id != header.journal_id || footer.generation != generation || footer.first_shard_sequence != scan.frames.first().expect("non-empty").shard_sequence || footer.last_shard_sequence != scan.frames.last().expect("non-empty").shard_sequence || footer.offsets != expected_offsets { return Err(StoreError::Corruption(format!( "occupied recovery segment {} does not describe the validated prefix", destination.display() ))); } const COMPARE_CHUNK: usize = 1 << 20; let segment = File::open(destination)?; let mut offset = 0u64; while offset < scan.stop_offset { let remaining = scan.stop_offset - offset; let len = usize::try_from(remaining.min(COMPARE_CHUNK as u64)) .map_err(|_| StoreError::Corruption("recovery prefix length overflow".into()))?; let mut original = vec![0u8; len]; let mut installed = vec![0u8; len]; sys::pread_exact(source, offset, &mut original)?; sys::pread_exact(&segment, offset, &mut installed)?; if original != installed { return Err(StoreError::Corruption(format!( "occupied recovery segment {} differs from the crash prefix at offset {offset}", destination.display() ))); } offset += len as u64; } Ok(()) } /// Step 6 of scope 3.4: drop the `active/` name, now that the manifest /// generation naming the segment is durable. /// /// This is the last step of a seal and it must run **after** /// [`install_manifest`], never before. Until it runs, both names point at the /// same inode and the frames are reachable either way; after it runs, they are /// reachable through the manifest. There is no point in between at which they /// are reachable through neither, which is the entire reason step 4 links /// rather than renames. pub fn unlink_sealed_journal( active_path: &Path, paths: &ShardPaths, counters: &DurabilityCounters, ) -> Result<(), StoreError> { match sys::unlink(active_path) { Ok(()) => {} // Already gone: a previous attempt completed and the crash was after // the unlink but before its directory fsync. Finishing the fsync is // still the right thing to do. Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} Err(e) => return Err(e.into()), } sys::fsync_dir(&paths.active(), counters)?; Ok(()) } // --------------------------------------------------------------------------- // Manifest install (scope 3.5) // --------------------------------------------------------------------------- /// Install a new manifest generation and make `CURRENT` point at it. /// /// The exact sequence of scope 3.5, and the only place in the crate that uses /// a replacing rename: /// /// ```text /// 1 write manifests/.manifest.tmp (never an existing name) /// 2 fdatasync it /// 3 rename_noreplace -> manifests/.manifest /// 4 fsync_dir manifests/ /// 5 write CURRENT.tmp naming ; fdatasync it /// 6 rename_replace -> CURRENT <-- the one replacing rename /// 7 fsync_dir the shard directory /// 8 only now unlink superseded names, then fsync_dir their directories /// 9 retain >= manifest_retain generations /// ``` /// /// Step 6 is the visibility boundary. Steps 1–4 are what give recovery a /// predecessor to fall back to when `CURRENT` or its referent does not /// validate. pub fn install_manifest( paths: &ShardPaths, manifest: &Manifest, manifest_retain: u32, counters: &DurabilityCounters, ) -> Result<(), StoreError> { let manifests = paths.manifests(); let final_path = paths.manifest(manifest.generation); let tmp = manifests.join(format!("{}.tmp", manifest_filename(manifest.generation))); let encoded = manifest.encode()?; // 1, 2 if tmp.exists() { let existing = std::fs::read(&tmp)?; if existing != encoded { return Err(StoreError::Corruption(format!( "manifest temp for generation {} contains different bytes", manifest.generation ))); } let file = File::open(&tmp)?; // Exact visible bytes do not prove the interrupted attempt reached its // fence. Fence them again before publishing the final name. sys::fdatasync(&file, counters)?; } else { write_fenced(&tmp, &encoded, counters)?; } // 3 — never overwrite an existing generation. if let Err(e) = sys::rename_noreplace(&tmp, &final_path) { if e.kind() != std::io::ErrorKind::AlreadyExists { let _ = sys::unlink(&tmp); return Err(e.into()); } let existing = std::fs::read(&final_path)?; let _ = sys::unlink(&tmp); if existing != encoded { return Err(StoreError::Corruption(format!( "manifest generation {} already exists with different contents", manifest.generation ))); } } // 4 sys::fsync_dir(&manifests, counters)?; // 5 let pointer = CurrentPointer { root_uuid: manifest.root_uuid, generation: manifest.generation, }; let current_tmp = paths.dir.join("CURRENT.tmp"); write_fenced(¤t_tmp, &pointer.encode()?, counters)?; // 6 — the one replacing rename in the crate. `CURRENT` by definition // replaces; every other name in the store uses `rename_noreplace`. sys::rename_replace(¤t_tmp, &paths.current())?; // 7 sys::fsync_dir(&paths.dir, counters)?; // 8, 9 — superseded manifests only, and only beyond the retention floor. prune_manifests(paths, manifest.generation, manifest_retain, counters)?; Ok(()) } /// Keep at least `manifest_retain` generations at or below the active one. fn prune_manifests( paths: &ShardPaths, active: u64, manifest_retain: u32, counters: &DurabilityCounters, ) -> Result<(), StoreError> { let mut generations = list_manifest_generations(paths)?; generations.retain(|generation| *generation <= active); generations.sort_unstable(); // `StoreOptions` already refuses a retention below 2, because recovery // needs a predecessor to fall back to; the floor is restated here so a // caller that bypasses the options cannot delete the last fallback. let retain = (manifest_retain.max(2) as usize).min(generations.len()); if generations.len() <= retain { return Ok(()); } let drop_count = generations.len() - retain; for generation in &generations[..drop_count] { sys::unlink(&paths.manifest(*generation))?; } sys::fsync_dir(&paths.manifests(), counters)?; Ok(()) } pub fn list_manifest_generations(paths: &ShardPaths) -> Result, StoreError> { let mut out = Vec::new(); let dir = match std::fs::read_dir(paths.manifests()) { Ok(dir) => dir, Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(out), Err(e) => return Err(e.into()), }; for entry in dir { let entry = entry?; let name = entry.file_name(); let Some(name) = name.to_str() else { continue }; let Some(stem) = name.strip_suffix(".manifest") else { continue; }; if let Ok(generation) = stem.parse::() { out.push(generation); } } out.sort_unstable(); Ok(out) } /// Read `CURRENT` if it exists and validates. /// /// A corrupt pointer is `Ok(None)`, not an error: it is precisely the case the /// durable manifest history exists to survive. pub fn read_current( paths: &ShardPaths, root_uuid: &[u8; 16], ) -> Result, StoreError> { let file = match File::open(paths.current()) { Ok(file) => file, Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None), Err(e) => return Err(e.into()), }; let mut bytes = vec![0u8; crate::format::CURRENT_POINTER_LEN]; if sys::pread_exact(&file, 0, &mut bytes).is_err() { return Ok(None); } match CurrentPointer::decode(&bytes) { Ok(pointer) if &pointer.root_uuid == root_uuid => Ok(Some(pointer)), Ok(_) | Err(_) => Ok(None), } } pub fn read_manifest( paths: &ShardPaths, generation: u64, root_uuid: &[u8; 16], ) -> Result { let path = paths.manifest(generation); let bytes = std::fs::read(&path)?; let manifest = Manifest::decode(&bytes)?; if &manifest.root_uuid != root_uuid { return Err(FrameError::RootUuid.into()); } if manifest.generation != generation { return Err(StoreError::Corruption(format!( "manifest {} declares generation {}", path.display(), manifest.generation ))); } Ok(manifest) } /// Whether every file a manifest references exists. /// /// Existence, not full validation: a manifest naming a missing segment is a /// different failure from one naming a corrupt segment, and scope 4-A2 keeps /// those cases distinct. pub fn manifest_referents_present(paths: &ShardPaths, manifest: &Manifest) -> bool { manifest .retained_tail_ranges .iter() .all(|range| paths.segments().join(&range.filename).exists()) && manifest .index_runs .iter() .all(|(_, name)| paths.indexes().join(name).exists()) && manifest .checkpoints .iter() .all(|(_, name)| paths.checkpoints().join(name).exists()) } /// Scope 3.8 step 2: read `CURRENT`; if it is missing or corrupt, or its /// referenced files do not validate, fall back to the newest valid manifest /// generation whose referenced files all validate. /// /// Returns the manifest and whether the fallback path was used, so a test can /// assert which branch ran rather than only that a manifest was loaded. pub fn load_manifest_with_fallback( paths: &ShardPaths, root_uuid: &[u8; 16], ) -> Result, StoreError> { if let Some(pointer) = read_current(paths, root_uuid)? { if let Ok(manifest) = read_manifest(paths, pointer.generation, root_uuid) { if manifest_referents_present(paths, &manifest) { return Ok(Some((manifest, false))); } } } // Either the pointer did not validate, or it validated and its referent // did not. Both fall back, but they are distinct branches of step 2 and // the scope forbids collapsing them — which is why the pointer read above // is a separate step from the manifest read. let mut generations = list_manifest_generations(paths)?; generations.sort_unstable_by(|a, b| b.cmp(a)); for generation in generations { if let Ok(manifest) = read_manifest(paths, generation, root_uuid) { if manifest_referents_present(paths, &manifest) { return Ok(Some((manifest, true))); } } } Ok(None) } // --------------------------------------------------------------------------- // Sealed segment reader // --------------------------------------------------------------------------- /// A sealed segment, read by offset table plus bounded `pread`. pub struct SegmentReader { file: File, path: PathBuf, footer: SegmentFooter, } impl SegmentReader { /// Open and validate a sealed segment. /// /// Footer presence is what distinguishes a sealed segment from an active /// journal, and it is located from end of file through a fixed /// self-describing locator rather than by scanning. pub fn open(path: &Path, root_uuid: &[u8; 16]) -> Result { let file = File::open(path)?; let len = file.metadata()?.len(); if len < SEGMENT_FOOTER_LOCATOR_LEN as u64 { return Err(FrameError::Length.into()); } let mut locator = [0u8; SEGMENT_FOOTER_LOCATOR_LEN]; sys::pread_exact(&file, len - SEGMENT_FOOTER_LOCATOR_LEN as u64, &mut locator)?; let footer_len = SegmentFooter::decode_locator(&locator)?; if footer_len > len { return Err(FrameError::Length.into()); } let mut bytes = vec![0u8; footer_len as usize]; sys::pread_exact(&file, len - footer_len, &mut bytes)?; let footer = SegmentFooter::decode(&bytes)?; if &footer.root_uuid != root_uuid { return Err(FrameError::RootUuid.into()); } // Every recorded frame must lie inside the frame region, which ends // where the footer begins. let frame_region_end = len - footer_len; for (_, offset, frame_len) in &footer.offsets { if offset.saturating_add(*frame_len) > frame_region_end { return Err(FrameError::Length.into()); } } Ok(Self { file, path: path.to_path_buf(), footer, }) } pub fn footer(&self) -> &SegmentFooter { &self.footer } /// The journal header the sealed file still carries at offset 0. /// /// Sealing links the journal file itself into `segments/` and appends a /// footer; it never re-encodes and never rewrites the header. The header /// is therefore still the authority on which **shard** and which store /// root the frames belong to, and it is the only place that binding /// survives — the footer carries `root_uuid` and `journal_id` but no /// `shard_index`. Recovery needs it to reject a segment that was copied /// or moved in from another shard of the same root, which no footer check /// can catch. pub fn journal_header(&self) -> Result { let mut bytes = [0u8; JOURNAL_HEADER_LEN]; sys::pread_exact(&self.file, 0, &mut bytes)?; Ok(JournalHeader::decode(&bytes)?) } pub fn path(&self) -> &Path { &self.path } pub fn contains(&self, shard_sequence: u64) -> bool { self.locate(shard_sequence).is_some() } fn locate(&self, shard_sequence: u64) -> Option<(u64, u64)> { self.footer .offsets .binary_search_by_key(&shard_sequence, |(sequence, _, _)| *sequence) .ok() .map(|at| { let (_, offset, len) = self.footer.offsets[at]; (offset, len) }) } /// Read one frame, revalidating completeness against this segment's /// `journal_id`. A frame is byte-identical to what the journal held, so /// the same completeness definition applies unchanged. pub fn read_frame(&self, shard_sequence: u64) -> Result { let (offset, len) = self.locate(shard_sequence).ok_or_else(|| { StoreError::Corruption(format!( "segment {} does not contain shard_sequence {shard_sequence}", self.path.display() )) })?; let mut bytes = vec![0u8; len as usize]; sys::pread_exact(&self.file, offset, &mut bytes)?; Ok(Frame::decode(&bytes, &self.footer.journal_id)?) } } /// Bounded open-FD cache over sealed segments. /// /// The ceiling comes from `StoreOptions::open_segment_fd_cache`. Plan §4's /// resource invariants require the descriptor count be bounded, so eviction is /// mandatory rather than an optimization. pub struct SegmentCache { limit: usize, root_uuid: [u8; 16], open: HashMap)>, clock: u64, } impl SegmentCache { pub fn new(limit: u32, root_uuid: [u8; 16]) -> Self { Self { limit: (limit as usize).max(1), root_uuid, open: HashMap::new(), clock: 0, } } pub fn len(&self) -> usize { self.open.len() } pub fn is_empty(&self) -> bool { self.open.is_empty() } /// Least-recently-used eviction at the configured ceiling. pub fn get(&mut self, path: &Path) -> Result, StoreError> { self.clock += 1; if let Some((used, reader)) = self.open.get_mut(path) { *used = self.clock; return Ok(Arc::clone(reader)); } let reader = Arc::new(SegmentReader::open(path, &self.root_uuid)?); while self.open.len() >= self.limit { let victim = self .open .iter() .min_by_key(|(_, (used, _))| *used) .map(|(path, _)| path.clone()); match victim { Some(victim) => { self.open.remove(&victim); } None => break, } } self.open .insert(path.to_path_buf(), (self.clock, Arc::clone(&reader))); Ok(reader) } }