1535 lines
58 KiB
Rust
1535 lines
58 KiB
Rust
//! Rebuildable object, ref, and receipt checkpoints, and their atomic
|
|
//! installation.
|
|
//!
|
|
//! **Owned by A2 RecoveryIndex** (scope 2.1, 4-A2).
|
|
//!
|
|
//! At least `checkpoint_retain` independently validated generations are kept.
|
|
//! If all of them fail validation, startup enters explicit offline rebuild
|
|
//! mode rather than an unbounded normal-readiness scan (plan §5.3).
|
|
//!
|
|
//! # Derived, never authoritative
|
|
//!
|
|
//! Plan §5.3: "Ref state and namespace metadata are derived/checkpointed;
|
|
//! journal/segment frames remain authoritative." A checkpoint is therefore only
|
|
//! ever a *replay start point*. Losing every generation costs replay time, not
|
|
//! data — which is exactly why the correct response to "all generations are
|
|
//! corrupt" is an explicit offline rebuild rather than an error, and equally
|
|
//! why it may never be a silent unbounded scan during normal readiness.
|
|
//!
|
|
//! # Where the codec lives
|
|
//!
|
|
//! `format.rs`'s doc header lists checkpoints among the things it covers, but
|
|
//! D0 landed only `CHECKPOINT_MAGIC` and `CHECKPOINT_DIGEST_DOMAIN` there — no
|
|
//! checkpoint type and no signature. The checkpoint body is entirely A2-owned
|
|
//! derived state, so its codec lives here and reuses the lead's frozen magic
|
|
//! and digest domain. This is recorded as an interface note rather than worked
|
|
//! around silently.
|
|
|
|
use std::fs::File;
|
|
use std::io::IoSlice;
|
|
use std::path::{Path, PathBuf};
|
|
|
|
use levcs_core::ObjectId;
|
|
use levcs_protocol::v2::{RefTarget, MAX_REF_UPDATES};
|
|
|
|
use crate::format::{CHECKPOINT_DIGEST_DOMAIN, CHECKPOINT_MAGIC, STORAGE_VERSION};
|
|
use crate::index::{NamespaceCatalog, NamespaceLifecycle, NamespaceRecord, NamespaceStorageMode};
|
|
use crate::types::{AppliedRef, DurabilityCounters, NamespaceId, OperationId, StoreError};
|
|
|
|
/// 88 bytes of named fields, zero padding to 96, then a 32-byte header digest.
|
|
/// Every byte that is not a named field must be zero, for the same reason
|
|
/// scope 3.3 pins the frame's padding.
|
|
pub const CHECKPOINT_HEADER_LEN: usize = 128;
|
|
const CHECKPOINT_HEADER_FIELDS_LEN: usize = 88;
|
|
pub const CHECKPOINT_TRAILER_LEN: usize = 48;
|
|
pub const CHECKPOINT_EXTENSION: &str = "checkpoint";
|
|
|
|
/// Decode-side ceilings. A checkpoint is derived state read back from disk, so
|
|
/// its counts are attacker-adjacent in exactly the sense Phase 0's checked
|
|
/// readers were: `with_capacity` is never called on an unvalidated count.
|
|
const MAX_CHECKPOINT_NAMESPACES: u32 = 4_000_000;
|
|
const MAX_CHECKPOINT_REFS: u32 = 64_000_000;
|
|
const MAX_CHECKPOINT_RECEIPTS: u32 = 64_000_000;
|
|
const MAX_REF_NAME_LEN: u16 = 1024;
|
|
|
|
/// Checkpoint-body capability: every retained receipt carries its complete
|
|
/// applied-ref result.
|
|
///
|
|
/// Checkpoints are derived and independently versioned by their flags within
|
|
/// storage version 1. A version-1 checkpoint with flags zero predates this
|
|
/// capability. It remains readable only when it has no retained receipts;
|
|
/// otherwise accepting it would publish a receipt with an invented empty ref
|
|
/// result. Such generations are rejected and recovery enters its existing
|
|
/// explicit offline-rebuild path when no newer complete generation exists.
|
|
const CHECKPOINT_FLAG_RECEIPT_REFS: u32 = 1 << 0;
|
|
const CHECKPOINT_KNOWN_FLAGS: u32 = CHECKPOINT_FLAG_RECEIPT_REFS;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Errors
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Why a checkpoint generation is not usable.
|
|
///
|
|
/// Individually asserted, never collapsed: "the checkpoint did not load" is
|
|
/// not a finding, and a reviewer cannot tell a truncated file from a file that
|
|
/// belongs to a different store root if both arrive as one variant.
|
|
#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
|
|
pub enum CheckpointError {
|
|
#[error("checkpoint is shorter than its fixed header and trailer")]
|
|
Truncated,
|
|
#[error("checkpoint magic does not match")]
|
|
Magic,
|
|
#[error("checkpoint storage version {0} is not readable")]
|
|
StorageVersion(u16),
|
|
#[error("checkpoint has unknown flags or non-zero reserved fields")]
|
|
Flags,
|
|
#[error("checkpoint predates complete applied-ref receipt retention")]
|
|
ReceiptRefsUnavailable,
|
|
#[error("checkpoint header digest does not recompute")]
|
|
HeaderDigest,
|
|
#[error("checkpoint trailer magic or repeated total_len does not match")]
|
|
Trailer,
|
|
#[error("checkpoint body digest does not recompute")]
|
|
BodyDigest,
|
|
#[error("checkpoint root uuid does not match this store root")]
|
|
RootUuid,
|
|
#[error("checkpoint belongs to shard {found}, not shard {expected}")]
|
|
ShardIndex { expected: u16, found: u16 },
|
|
#[error("checkpoint declares a count beyond its decode ceiling: {0}")]
|
|
CountCeiling(&'static str),
|
|
#[error("checkpoint body is malformed: {0}")]
|
|
Body(&'static str),
|
|
/// Named separately from [`CheckpointError::Body`] because the offending
|
|
/// value is the whole diagnosis: a ref kind is one byte, and "which byte"
|
|
/// is the difference between a torn write and a reader that never learned
|
|
/// about a kind a newer writer emits.
|
|
#[error("checkpoint carries unknown ref kind {0}")]
|
|
RefKind(u8),
|
|
#[error("checkpoint has trailing bytes after its declared body")]
|
|
TrailingBytes,
|
|
#[error("checkpoint file name is not <shard_sequence>.checkpoint")]
|
|
FileName,
|
|
#[error("checkpoint file could not be read: {0}")]
|
|
Unreadable(String),
|
|
}
|
|
|
|
impl From<CheckpointError> for StoreError {
|
|
fn from(e: CheckpointError) -> Self {
|
|
StoreError::Corruption(format!("checkpoint: {e}"))
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Content
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// One typed ref binding, as of the checkpoint's `shard_committed_sequence`.
|
|
///
|
|
/// The physical `(ref_kind, name)` pair stays public because the checkpoint
|
|
/// body encodes it directly, but nothing outside this module should ever
|
|
/// interpret it: use [`RefRecord::from_target`] and [`RefRecord::target`].
|
|
/// Every consumer that decodes `ref_kind` itself is restating a table this
|
|
/// module owns, and the copy is only ever discovered when the two disagree —
|
|
/// which, for a derived-but-authoritative ref table, means a reopened store
|
|
/// silently resolving a branch as a release.
|
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
pub struct RefRecord {
|
|
pub namespace: NamespaceId,
|
|
pub ref_kind: u8,
|
|
pub name: Vec<u8>,
|
|
pub target: ObjectId,
|
|
}
|
|
|
|
impl RefRecord {
|
|
/// Builds the physical record for a typed ref binding.
|
|
pub fn from_target(namespace: NamespaceId, target: &RefTarget, object: ObjectId) -> Self {
|
|
let (ref_kind, name) = ref_target_parts(target);
|
|
Self {
|
|
namespace,
|
|
ref_kind,
|
|
name: name.as_bytes().to_vec(),
|
|
target: object,
|
|
}
|
|
}
|
|
|
|
/// The typed binding this record encodes.
|
|
///
|
|
/// Fallible on purpose. A checkpoint is derived state read back from disk,
|
|
/// so `ref_kind` and `name` are attacker-adjacent in exactly the sense the
|
|
/// rest of this module's readers are: an unknown kind is refused by name,
|
|
/// never defaulted to `Branch` and never dropped. Defaulting would turn a
|
|
/// corrupt byte into a plausible ref that the next checkpoint would then
|
|
/// write back as if it had always been there.
|
|
pub fn target(&self) -> Result<RefTarget, CheckpointError> {
|
|
let name = std::str::from_utf8(&self.name)
|
|
.map_err(|_| CheckpointError::Body("ref record name utf-8"))?;
|
|
ref_target_from_kind_code(self.ref_kind, name)
|
|
}
|
|
}
|
|
|
|
/// A retained operation/receipt row.
|
|
///
|
|
/// `first_receipt_visibility_micros` is the field recovery step 10 promotes.
|
|
/// It is `Option` on purpose: "not yet durably captured" and "captured as zero"
|
|
/// are different facts, and conflating them is exactly how a retention window
|
|
/// gets shortened by a crash.
|
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
pub struct ReceiptRecord {
|
|
pub namespace: NamespaceId,
|
|
pub operation_id: OperationId,
|
|
pub operation_digest: ObjectId,
|
|
pub repo_sequence: u64,
|
|
pub shard_sequence: u64,
|
|
pub current_authority: ObjectId,
|
|
/// The exact per-transaction result, including deletions and force.
|
|
///
|
|
/// This cannot be reconstructed from the checkpoint's current ref table:
|
|
/// that table has neither the old value nor transaction membership, and a
|
|
/// deleted ref is absent from it entirely.
|
|
pub refs: Vec<AppliedRef>,
|
|
pub objects_new: u64,
|
|
pub retry_until_micros: i64,
|
|
/// `None` when the crash preceded a durable capture of first visibility.
|
|
pub first_receipt_visibility_micros: Option<i64>,
|
|
pub receipt_visible_until_micros: i64,
|
|
}
|
|
|
|
/// Derived shard state through `shard_committed_sequence`.
|
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
pub struct Checkpoint {
|
|
pub root_uuid: [u8; 16],
|
|
pub shard_index: u16,
|
|
pub shard_committed_sequence: u64,
|
|
/// The active journal at checkpoint time, and how far into it this
|
|
/// checkpoint already accounts for. Recovery resumes its forward scan
|
|
/// here (scope 3.8 step 5) rather than from the file header, which is
|
|
/// what keeps replay bounded.
|
|
pub active_journal_id: [u8; 16],
|
|
pub active_journal_offset: u64,
|
|
pub created_at_micros: i64,
|
|
pub catalog: NamespaceCatalog,
|
|
pub refs: Vec<RefRecord>,
|
|
pub receipts: Vec<ReceiptRecord>,
|
|
}
|
|
|
|
impl Checkpoint {
|
|
pub fn empty(root_uuid: [u8; 16], shard_index: u16) -> Self {
|
|
Self {
|
|
root_uuid,
|
|
shard_index,
|
|
shard_committed_sequence: 0,
|
|
active_journal_id: [0u8; 16],
|
|
active_journal_offset: 0,
|
|
created_at_micros: 0,
|
|
catalog: NamespaceCatalog::new(),
|
|
refs: Vec::new(),
|
|
receipts: Vec::new(),
|
|
}
|
|
}
|
|
|
|
pub fn file_name(&self) -> String {
|
|
format!("{}.{}", self.shard_committed_sequence, CHECKPOINT_EXTENSION)
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Codec
|
|
// ---------------------------------------------------------------------------
|
|
|
|
fn put_u16(out: &mut Vec<u8>, v: u16) {
|
|
out.extend_from_slice(&v.to_le_bytes());
|
|
}
|
|
|
|
fn put_u32(out: &mut Vec<u8>, v: u32) {
|
|
out.extend_from_slice(&v.to_le_bytes());
|
|
}
|
|
|
|
fn put_u64(out: &mut Vec<u8>, v: u64) {
|
|
out.extend_from_slice(&v.to_le_bytes());
|
|
}
|
|
|
|
fn put_i64(out: &mut Vec<u8>, v: i64) {
|
|
out.extend_from_slice(&v.to_le_bytes());
|
|
}
|
|
|
|
/// A bounds-checked forward reader. Every read is fallible and every length is
|
|
/// validated before it is used, in the Phase 0 checked-reader style.
|
|
struct Reader<'a> {
|
|
bytes: &'a [u8],
|
|
at: usize,
|
|
}
|
|
|
|
impl<'a> Reader<'a> {
|
|
fn new(bytes: &'a [u8]) -> Self {
|
|
Self { bytes, at: 0 }
|
|
}
|
|
|
|
fn take(&mut self, n: usize, what: &'static str) -> Result<&'a [u8], CheckpointError> {
|
|
let end = self
|
|
.at
|
|
.checked_add(n)
|
|
.ok_or(CheckpointError::Body("length overflow"))?;
|
|
if end > self.bytes.len() {
|
|
return Err(CheckpointError::Body(what));
|
|
}
|
|
let slice = &self.bytes[self.at..end];
|
|
self.at = end;
|
|
Ok(slice)
|
|
}
|
|
|
|
fn u8(&mut self, what: &'static str) -> Result<u8, CheckpointError> {
|
|
Ok(self.take(1, what)?[0])
|
|
}
|
|
|
|
fn u16(&mut self, what: &'static str) -> Result<u16, CheckpointError> {
|
|
Ok(u16::from_le_bytes(
|
|
self.take(2, what)?.try_into().expect("2 bytes"),
|
|
))
|
|
}
|
|
|
|
fn u32(&mut self, what: &'static str) -> Result<u32, CheckpointError> {
|
|
Ok(u32::from_le_bytes(
|
|
self.take(4, what)?.try_into().expect("4 bytes"),
|
|
))
|
|
}
|
|
|
|
fn u64(&mut self, what: &'static str) -> Result<u64, CheckpointError> {
|
|
Ok(u64::from_le_bytes(
|
|
self.take(8, what)?.try_into().expect("8 bytes"),
|
|
))
|
|
}
|
|
|
|
fn i64(&mut self, what: &'static str) -> Result<i64, CheckpointError> {
|
|
Ok(i64::from_le_bytes(
|
|
self.take(8, what)?.try_into().expect("8 bytes"),
|
|
))
|
|
}
|
|
|
|
fn bytes32(&mut self, what: &'static str) -> Result<[u8; 32], CheckpointError> {
|
|
Ok(self.take(32, what)?.try_into().expect("32 bytes"))
|
|
}
|
|
|
|
fn bytes16(&mut self, what: &'static str) -> Result<[u8; 16], CheckpointError> {
|
|
Ok(self.take(16, what)?.try_into().expect("16 bytes"))
|
|
}
|
|
|
|
fn finished(&self) -> bool {
|
|
self.at == self.bytes.len()
|
|
}
|
|
}
|
|
|
|
/// `Option<i64>` on the wire: a presence byte then the value. Not a sentinel —
|
|
/// a sentinel would make "unset" a legal timestamp.
|
|
fn put_optional_i64(out: &mut Vec<u8>, v: Option<i64>) {
|
|
match v {
|
|
Some(value) => {
|
|
out.push(1);
|
|
put_i64(out, value);
|
|
}
|
|
None => {
|
|
out.push(0);
|
|
put_i64(out, 0);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn read_optional_i64(
|
|
r: &mut Reader<'_>,
|
|
what: &'static str,
|
|
) -> Result<Option<i64>, CheckpointError> {
|
|
let present = r.u8(what)?;
|
|
let value = r.i64(what)?;
|
|
match present {
|
|
0 => Ok(None),
|
|
1 => Ok(Some(value)),
|
|
_ => Err(CheckpointError::Body(
|
|
"optional presence byte must be 0 or 1",
|
|
)),
|
|
}
|
|
}
|
|
|
|
fn put_optional_object_id(out: &mut Vec<u8>, value: Option<ObjectId>) {
|
|
match value {
|
|
Some(value) => {
|
|
out.push(1);
|
|
out.extend_from_slice(value.as_bytes());
|
|
}
|
|
None => out.push(0),
|
|
}
|
|
}
|
|
|
|
fn read_optional_object_id(
|
|
r: &mut Reader<'_>,
|
|
what: &'static str,
|
|
) -> Result<Option<ObjectId>, CheckpointError> {
|
|
match r.u8(what)? {
|
|
0 => Ok(None),
|
|
1 => Ok(Some(ObjectId(r.bytes32(what)?))),
|
|
_ => Err(CheckpointError::Body(
|
|
"optional object-id presence byte must be 0 or 1",
|
|
)),
|
|
}
|
|
}
|
|
|
|
/// The checkpoint body's ref-kind codes, encode side.
|
|
///
|
|
/// Paired with [`ref_target_from_kind_code`], and the only place in the crate
|
|
/// that assigns these two codes. `format.rs` has its own table for the *frame*
|
|
/// encoding; that is not a duplicate, because the two physical formats are
|
|
/// versioned independently and each is self-consistent — a frame code is only
|
|
/// ever compared to a frame code. The copies that mattered were the ones that
|
|
/// decoded a `RefRecord` this module produced, and those are now gone.
|
|
fn ref_target_parts(target: &RefTarget) -> (u8, &str) {
|
|
match target {
|
|
RefTarget::Branch(name) => (1, name),
|
|
RefTarget::Release(name) => (2, name),
|
|
}
|
|
}
|
|
|
|
/// The checkpoint body's ref-kind codes, decode side.
|
|
///
|
|
/// Rejects an unknown code by value rather than defaulting: a ref table is
|
|
/// derived state, and a silently reclassified ref would be written back into
|
|
/// the next checkpoint as though it were authoritative.
|
|
fn ref_target_from_kind_code(kind: u8, name: &str) -> Result<RefTarget, CheckpointError> {
|
|
if name.is_empty()
|
|
|| name.len() > MAX_REF_NAME_LEN as usize
|
|
|| name.as_bytes().contains(&0)
|
|
|| levcs_core::refs::validate_ref_name(name).is_err()
|
|
{
|
|
return Err(CheckpointError::Body("ref target name"));
|
|
}
|
|
match kind {
|
|
1 => Ok(RefTarget::Branch(name.to_owned())),
|
|
2 => Ok(RefTarget::Release(name.to_owned())),
|
|
unknown => Err(CheckpointError::RefKind(unknown)),
|
|
}
|
|
}
|
|
|
|
fn validate_ref_target(name: &str) -> Result<(), StoreError> {
|
|
if name.len() > MAX_REF_NAME_LEN as usize {
|
|
return Err(StoreError::LimitExceeded {
|
|
limit: "checkpoint_receipt_ref_name_len",
|
|
observed: name.len() as u64,
|
|
allowed: MAX_REF_NAME_LEN as u64,
|
|
});
|
|
}
|
|
if name.is_empty()
|
|
|| name.as_bytes().contains(&0)
|
|
|| levcs_core::refs::validate_ref_name(name).is_err()
|
|
{
|
|
return Err(StoreError::Corruption(
|
|
"checkpoint receipt contains an invalid applied-ref target".into(),
|
|
));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Checkpoint-local physical encoding of the frozen `AppliedRefV1` value.
|
|
///
|
|
/// The protocol codec intentionally keeps its element codec private. Plan
|
|
/// §13 keeps these checkpoint bytes internal, so this codec delegates name
|
|
/// validation to the same `levcs-core` function and pins the complete logical
|
|
/// value rather than reaching through a private protocol implementation.
|
|
fn put_applied_refs(out: &mut Vec<u8>, refs: &[AppliedRef]) -> Result<(), StoreError> {
|
|
if refs.len() > MAX_REF_UPDATES {
|
|
return Err(StoreError::LimitExceeded {
|
|
limit: "checkpoint_receipt_applied_refs",
|
|
observed: refs.len() as u64,
|
|
allowed: MAX_REF_UPDATES as u64,
|
|
});
|
|
}
|
|
let mut targets = std::collections::BTreeSet::new();
|
|
put_u32(out, refs.len() as u32);
|
|
for applied in refs {
|
|
if !targets.insert(applied.target.clone()) {
|
|
return Err(StoreError::Corruption(
|
|
"checkpoint receipt contains duplicate applied-ref targets".into(),
|
|
));
|
|
}
|
|
let (kind, name) = ref_target_parts(&applied.target);
|
|
validate_ref_target(name)?;
|
|
out.push(kind);
|
|
put_u16(out, name.len() as u16);
|
|
out.extend_from_slice(name.as_bytes());
|
|
put_optional_object_id(out, applied.old);
|
|
put_optional_object_id(out, applied.new);
|
|
out.push(u8::from(applied.force));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn read_applied_refs(r: &mut Reader<'_>) -> Result<Vec<AppliedRef>, CheckpointError> {
|
|
let count = r.u32("receipt applied-ref count")?;
|
|
if count as usize > MAX_REF_UPDATES {
|
|
return Err(CheckpointError::CountCeiling("receipt applied refs"));
|
|
}
|
|
// Seven bytes is the smallest valid record: kind, name length, one-byte
|
|
// name, two absent object IDs, and force.
|
|
let mut refs = Vec::with_capacity((count as usize).min(r.bytes.len() / 7 + 1));
|
|
let mut targets = std::collections::BTreeSet::new();
|
|
for _ in 0..count {
|
|
let kind = r.u8("receipt ref target kind")?;
|
|
let name_len = r.u16("receipt ref target name length")?;
|
|
if name_len > MAX_REF_NAME_LEN {
|
|
return Err(CheckpointError::CountCeiling(
|
|
"receipt ref target name length",
|
|
));
|
|
}
|
|
if name_len == 0 {
|
|
return Err(CheckpointError::Body("receipt ref target name length"));
|
|
}
|
|
let name = std::str::from_utf8(r.take(name_len as usize, "receipt ref target name")?)
|
|
.map_err(|_| CheckpointError::Body("receipt ref target name utf-8"))?;
|
|
let target = ref_target_from_kind_code(kind, name)?;
|
|
if !targets.insert(target.clone()) {
|
|
return Err(CheckpointError::Body(
|
|
"duplicate receipt applied-ref target",
|
|
));
|
|
}
|
|
let old = read_optional_object_id(r, "receipt old ref object")?;
|
|
let new = read_optional_object_id(r, "receipt new ref object")?;
|
|
let force = match r.u8("receipt applied-ref force")? {
|
|
0 => false,
|
|
1 => true,
|
|
_ => return Err(CheckpointError::Body("receipt applied-ref force")),
|
|
};
|
|
refs.push(AppliedRef {
|
|
target,
|
|
old,
|
|
new,
|
|
force,
|
|
});
|
|
}
|
|
Ok(refs)
|
|
}
|
|
|
|
impl Checkpoint {
|
|
pub fn encode(&self) -> Result<Vec<u8>, StoreError> {
|
|
self.encode_with_receipt_refs(true)
|
|
}
|
|
|
|
fn encode_with_receipt_refs(&self, include_receipt_refs: bool) -> Result<Vec<u8>, StoreError> {
|
|
if self.catalog.len() as u64 > MAX_CHECKPOINT_NAMESPACES as u64 {
|
|
return Err(StoreError::LimitExceeded {
|
|
limit: "checkpoint_namespaces",
|
|
observed: self.catalog.len() as u64,
|
|
allowed: MAX_CHECKPOINT_NAMESPACES as u64,
|
|
});
|
|
}
|
|
if self.refs.len() as u64 > MAX_CHECKPOINT_REFS as u64 {
|
|
return Err(StoreError::LimitExceeded {
|
|
limit: "checkpoint_refs",
|
|
observed: self.refs.len() as u64,
|
|
allowed: MAX_CHECKPOINT_REFS as u64,
|
|
});
|
|
}
|
|
if self.receipts.len() as u64 > MAX_CHECKPOINT_RECEIPTS as u64 {
|
|
return Err(StoreError::LimitExceeded {
|
|
limit: "checkpoint_receipts",
|
|
observed: self.receipts.len() as u64,
|
|
allowed: MAX_CHECKPOINT_RECEIPTS as u64,
|
|
});
|
|
}
|
|
|
|
let mut body = Vec::new();
|
|
|
|
put_u32(&mut body, self.catalog.len() as u32);
|
|
for (_, record) in self.catalog.iter() {
|
|
body.extend_from_slice(&record.namespace.0);
|
|
body.extend_from_slice(&record.genesis_authority.0);
|
|
body.extend_from_slice(&record.current_authority.0);
|
|
body.push(record.lifecycle.code());
|
|
body.push(record.storage_mode.code());
|
|
put_u16(&mut body, 0); // reserved, must be zero
|
|
put_u64(&mut body, record.repo_sequence);
|
|
body.extend_from_slice(&record.previous_event_digest.0);
|
|
}
|
|
|
|
put_u32(&mut body, self.refs.len() as u32);
|
|
for r in &self.refs {
|
|
if r.name.len() > MAX_REF_NAME_LEN as usize {
|
|
return Err(StoreError::LimitExceeded {
|
|
limit: "checkpoint_ref_name_len",
|
|
observed: r.name.len() as u64,
|
|
allowed: MAX_REF_NAME_LEN as u64,
|
|
});
|
|
}
|
|
body.extend_from_slice(&r.namespace.0);
|
|
body.push(r.ref_kind);
|
|
put_u16(&mut body, r.name.len() as u16);
|
|
body.push(0); // reserved, must be zero
|
|
body.extend_from_slice(&r.name);
|
|
body.extend_from_slice(&r.target.0);
|
|
}
|
|
|
|
put_u32(&mut body, self.receipts.len() as u32);
|
|
for rec in &self.receipts {
|
|
body.extend_from_slice(&rec.namespace.0);
|
|
body.extend_from_slice(&rec.operation_id.0);
|
|
body.extend_from_slice(&rec.operation_digest.0);
|
|
put_u64(&mut body, rec.repo_sequence);
|
|
put_u64(&mut body, rec.shard_sequence);
|
|
body.extend_from_slice(&rec.current_authority.0);
|
|
if include_receipt_refs {
|
|
put_applied_refs(&mut body, &rec.refs)?;
|
|
}
|
|
put_u64(&mut body, rec.objects_new);
|
|
put_i64(&mut body, rec.retry_until_micros);
|
|
put_optional_i64(&mut body, rec.first_receipt_visibility_micros);
|
|
put_i64(&mut body, rec.receipt_visible_until_micros);
|
|
}
|
|
|
|
let total_len = (CHECKPOINT_HEADER_LEN + body.len() + CHECKPOINT_TRAILER_LEN) as u64;
|
|
|
|
let mut header = Vec::with_capacity(CHECKPOINT_HEADER_LEN);
|
|
header.extend_from_slice(&CHECKPOINT_MAGIC);
|
|
put_u16(&mut header, STORAGE_VERSION);
|
|
put_u16(&mut header, CHECKPOINT_HEADER_LEN as u16);
|
|
put_u32(
|
|
&mut header,
|
|
if include_receipt_refs {
|
|
CHECKPOINT_FLAG_RECEIPT_REFS
|
|
} else {
|
|
0
|
|
},
|
|
);
|
|
header.extend_from_slice(&self.root_uuid);
|
|
put_u16(&mut header, self.shard_index);
|
|
put_u16(&mut header, 0); // reserved
|
|
put_u32(&mut header, 0); // reserved
|
|
put_u64(&mut header, self.shard_committed_sequence);
|
|
header.extend_from_slice(&self.active_journal_id);
|
|
put_u64(&mut header, self.active_journal_offset);
|
|
put_i64(&mut header, self.created_at_micros);
|
|
put_u64(&mut header, body.len() as u64);
|
|
debug_assert_eq!(header.len(), CHECKPOINT_HEADER_FIELDS_LEN);
|
|
header.resize(CHECKPOINT_HEADER_LEN - 32, 0);
|
|
let header_digest = crate::format::digest(CHECKPOINT_DIGEST_DOMAIN, &header);
|
|
header.extend_from_slice(&header_digest.0);
|
|
debug_assert_eq!(header.len(), CHECKPOINT_HEADER_LEN);
|
|
|
|
let mut out = Vec::with_capacity(total_len as usize);
|
|
out.extend_from_slice(&header);
|
|
out.extend_from_slice(&body);
|
|
let body_digest = crate::format::digest(CHECKPOINT_DIGEST_DOMAIN, &out);
|
|
out.extend_from_slice(&CHECKPOINT_MAGIC);
|
|
put_u64(&mut out, total_len);
|
|
out.extend_from_slice(&body_digest.0);
|
|
debug_assert_eq!(out.len() as u64, total_len);
|
|
Ok(out)
|
|
}
|
|
|
|
pub fn decode(
|
|
bytes: &[u8],
|
|
root_uuid: &[u8; 16],
|
|
shard_index: u16,
|
|
) -> Result<Self, CheckpointError> {
|
|
if bytes.len() < CHECKPOINT_HEADER_LEN + CHECKPOINT_TRAILER_LEN {
|
|
return Err(CheckpointError::Truncated);
|
|
}
|
|
if bytes[0..8] != CHECKPOINT_MAGIC {
|
|
return Err(CheckpointError::Magic);
|
|
}
|
|
let mut head = Reader::new(&bytes[..CHECKPOINT_HEADER_LEN]);
|
|
head.take(8, "magic")?;
|
|
let storage_version = head.u16("storage_version")?;
|
|
if storage_version != STORAGE_VERSION {
|
|
return Err(CheckpointError::StorageVersion(storage_version));
|
|
}
|
|
if head.u16("header_len")? as usize != CHECKPOINT_HEADER_LEN {
|
|
return Err(CheckpointError::Body("header_len"));
|
|
}
|
|
let checkpoint_flags = head.u32("flags")?;
|
|
if checkpoint_flags & !CHECKPOINT_KNOWN_FLAGS != 0 {
|
|
return Err(CheckpointError::Flags);
|
|
}
|
|
let has_receipt_refs = checkpoint_flags & CHECKPOINT_FLAG_RECEIPT_REFS != 0;
|
|
let file_root_uuid = head.bytes16("root_uuid")?;
|
|
let file_shard = head.u16("shard_index")?;
|
|
if head.u16("reserved")? != 0 || head.u32("reserved")? != 0 {
|
|
return Err(CheckpointError::Flags);
|
|
}
|
|
let shard_committed_sequence = head.u64("shard_committed_sequence")?;
|
|
let active_journal_id = head.bytes16("active_journal_id")?;
|
|
let active_journal_offset = head.u64("active_journal_offset")?;
|
|
let created_at_micros = head.i64("created_at_micros")?;
|
|
let body_len = head.u64("body_len")?;
|
|
if bytes[CHECKPOINT_HEADER_FIELDS_LEN..CHECKPOINT_HEADER_LEN - 32]
|
|
.iter()
|
|
.any(|b| *b != 0)
|
|
{
|
|
return Err(CheckpointError::Flags);
|
|
}
|
|
|
|
let stored_header_digest: [u8; 32] = bytes
|
|
[CHECKPOINT_HEADER_LEN - 32..CHECKPOINT_HEADER_LEN]
|
|
.try_into()
|
|
.expect("32 bytes");
|
|
if crate::format::digest(
|
|
CHECKPOINT_DIGEST_DOMAIN,
|
|
&bytes[..CHECKPOINT_HEADER_LEN - 32],
|
|
)
|
|
.0 != stored_header_digest
|
|
{
|
|
return Err(CheckpointError::HeaderDigest);
|
|
}
|
|
|
|
let expected_total = (CHECKPOINT_HEADER_LEN as u64)
|
|
.checked_add(body_len)
|
|
.and_then(|v| v.checked_add(CHECKPOINT_TRAILER_LEN as u64))
|
|
.ok_or(CheckpointError::Body("total_len overflow"))?;
|
|
if expected_total != bytes.len() as u64 {
|
|
return Err(CheckpointError::Body("body_len disagrees with file length"));
|
|
}
|
|
|
|
let trailer = &bytes[bytes.len() - CHECKPOINT_TRAILER_LEN..];
|
|
if trailer[0..8] != CHECKPOINT_MAGIC
|
|
|| u64::from_le_bytes(trailer[8..16].try_into().expect("8 bytes")) != expected_total
|
|
{
|
|
return Err(CheckpointError::Trailer);
|
|
}
|
|
let stored_body_digest: [u8; 32] = trailer[16..48].try_into().expect("32 bytes");
|
|
if crate::format::digest(
|
|
CHECKPOINT_DIGEST_DOMAIN,
|
|
&bytes[..bytes.len() - CHECKPOINT_TRAILER_LEN],
|
|
)
|
|
.0 != stored_body_digest
|
|
{
|
|
return Err(CheckpointError::BodyDigest);
|
|
}
|
|
|
|
// Identity binding is checked only after the file has been proved
|
|
// internally consistent, so a corrupt file cannot masquerade as a
|
|
// foreign-root file or vice versa.
|
|
if &file_root_uuid != root_uuid {
|
|
return Err(CheckpointError::RootUuid);
|
|
}
|
|
if file_shard != shard_index {
|
|
return Err(CheckpointError::ShardIndex {
|
|
expected: shard_index,
|
|
found: file_shard,
|
|
});
|
|
}
|
|
|
|
let body = &bytes[CHECKPOINT_HEADER_LEN..bytes.len() - CHECKPOINT_TRAILER_LEN];
|
|
let mut r = Reader::new(body);
|
|
|
|
let namespace_count = r.u32("namespace_count")?;
|
|
if namespace_count > MAX_CHECKPOINT_NAMESPACES {
|
|
return Err(CheckpointError::CountCeiling("namespaces"));
|
|
}
|
|
let mut catalog = NamespaceCatalog::new();
|
|
for _ in 0..namespace_count {
|
|
let namespace = NamespaceId(r.bytes32("namespace")?);
|
|
let genesis_authority = ObjectId(r.bytes32("genesis_authority")?);
|
|
let current_authority = ObjectId(r.bytes32("current_authority")?);
|
|
let lifecycle = NamespaceLifecycle::from_code(r.u8("lifecycle")?)
|
|
.ok_or(CheckpointError::Body("unknown namespace lifecycle"))?;
|
|
let storage_mode = NamespaceStorageMode::from_code(r.u8("storage_mode")?)
|
|
.ok_or(CheckpointError::Body("unknown namespace storage mode"))?;
|
|
if r.u16("reserved")? != 0 {
|
|
return Err(CheckpointError::Flags);
|
|
}
|
|
let repo_sequence = r.u64("repo_sequence")?;
|
|
let previous_event_digest = ObjectId(r.bytes32("previous_event_digest")?);
|
|
catalog
|
|
.bind(NamespaceRecord {
|
|
namespace,
|
|
genesis_authority,
|
|
current_authority,
|
|
lifecycle,
|
|
storage_mode,
|
|
repo_sequence,
|
|
previous_event_digest,
|
|
})
|
|
.map_err(|_| CheckpointError::Body("duplicate namespace in catalog"))?;
|
|
}
|
|
|
|
let ref_count = r.u32("ref_count")?;
|
|
if ref_count > MAX_CHECKPOINT_REFS {
|
|
return Err(CheckpointError::CountCeiling("refs"));
|
|
}
|
|
// Bound the reservation by what the remaining bytes could possibly
|
|
// hold: the smallest encodable ref is 68 bytes.
|
|
let mut refs = Vec::with_capacity((ref_count as usize).min(body.len() / 68 + 1));
|
|
for _ in 0..ref_count {
|
|
let namespace = NamespaceId(r.bytes32("ref namespace")?);
|
|
let ref_kind = r.u8("ref_kind")?;
|
|
let name_len = r.u16("ref name_len")?;
|
|
if name_len > MAX_REF_NAME_LEN {
|
|
return Err(CheckpointError::CountCeiling("ref name length"));
|
|
}
|
|
if r.u8("reserved")? != 0 {
|
|
return Err(CheckpointError::Flags);
|
|
}
|
|
let name = r.take(name_len as usize, "ref name")?.to_vec();
|
|
let target = ObjectId(r.bytes32("ref target")?);
|
|
refs.push(RefRecord {
|
|
namespace,
|
|
ref_kind,
|
|
name,
|
|
target,
|
|
});
|
|
}
|
|
|
|
let receipt_count = r.u32("receipt_count")?;
|
|
if receipt_count > MAX_CHECKPOINT_RECEIPTS {
|
|
return Err(CheckpointError::CountCeiling("receipts"));
|
|
}
|
|
let minimum_receipt_len = if has_receipt_refs { 165 } else { 161 };
|
|
let mut receipts =
|
|
Vec::with_capacity((receipt_count as usize).min(body.len() / minimum_receipt_len + 1));
|
|
for _ in 0..receipt_count {
|
|
receipts.push(ReceiptRecord {
|
|
namespace: NamespaceId(r.bytes32("receipt namespace")?),
|
|
operation_id: OperationId(r.bytes16("operation_id")?),
|
|
operation_digest: ObjectId(r.bytes32("operation_digest")?),
|
|
repo_sequence: r.u64("repo_sequence")?,
|
|
shard_sequence: r.u64("shard_sequence")?,
|
|
current_authority: ObjectId(r.bytes32("current_authority")?),
|
|
refs: if has_receipt_refs {
|
|
read_applied_refs(&mut r)?
|
|
} else {
|
|
Vec::new()
|
|
},
|
|
objects_new: r.u64("objects_new")?,
|
|
retry_until_micros: r.i64("retry_until_micros")?,
|
|
first_receipt_visibility_micros: read_optional_i64(&mut r, "first_visibility")?,
|
|
receipt_visible_until_micros: r.i64("receipt_visible_until")?,
|
|
});
|
|
}
|
|
|
|
if !r.finished() {
|
|
return Err(CheckpointError::TrailingBytes);
|
|
}
|
|
if !has_receipt_refs && !receipts.is_empty() {
|
|
return Err(CheckpointError::ReceiptRefsUnavailable);
|
|
}
|
|
|
|
Ok(Self {
|
|
root_uuid: file_root_uuid,
|
|
shard_index: file_shard,
|
|
shard_committed_sequence,
|
|
active_journal_id,
|
|
active_journal_offset,
|
|
created_at_micros,
|
|
catalog,
|
|
refs,
|
|
receipts,
|
|
})
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Installation and selection
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// What loading the checkpoint directory concluded.
|
|
///
|
|
/// `Empty` and `OfflineRebuildRequired` are deliberately different: a fresh
|
|
/// root has no checkpoints and is perfectly healthy, while a root whose every
|
|
/// generation fails validation must not silently behave like a fresh one and
|
|
/// replay from sequence zero. Collapsing them would turn the plan §5.3 rule
|
|
/// into a no-op on exactly the input it exists for.
|
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
pub enum CheckpointLoad {
|
|
/// No generation exists yet. Replay starts at the beginning.
|
|
Empty,
|
|
Loaded {
|
|
checkpoint: Box<Checkpoint>,
|
|
path: PathBuf,
|
|
/// Generations newer than the loaded one that failed validation, so an
|
|
/// operator sees that a fallback happened rather than inferring it.
|
|
rejected: Vec<(PathBuf, CheckpointError)>,
|
|
},
|
|
/// Every generation present failed validation. Startup must enter offline
|
|
/// rebuild explicitly rather than performing an unbounded scan.
|
|
OfflineRebuildRequired {
|
|
rejected: Vec<(PathBuf, CheckpointError)>,
|
|
},
|
|
}
|
|
|
|
/// Parse `<shard_sequence>.checkpoint`. Anything else in the directory is not
|
|
/// a candidate — recovery never guesses at a name it did not write.
|
|
fn parse_generation(path: &Path) -> Result<u64, CheckpointError> {
|
|
let name = path
|
|
.file_name()
|
|
.and_then(|n| n.to_str())
|
|
.ok_or(CheckpointError::FileName)?;
|
|
let stem = name
|
|
.strip_suffix(&format!(".{CHECKPOINT_EXTENSION}"))
|
|
.ok_or(CheckpointError::FileName)?;
|
|
if stem.is_empty() || !stem.bytes().all(|b| b.is_ascii_digit()) {
|
|
return Err(CheckpointError::FileName);
|
|
}
|
|
stem.parse::<u64>().map_err(|_| CheckpointError::FileName)
|
|
}
|
|
|
|
/// Candidate generations, newest first.
|
|
pub fn list_generations(dir: &Path) -> Result<Vec<(u64, PathBuf)>, StoreError> {
|
|
let mut found = Vec::new();
|
|
let entries = match std::fs::read_dir(dir) {
|
|
Ok(entries) => entries,
|
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(found),
|
|
Err(e) => return Err(e.into()),
|
|
};
|
|
for entry in entries {
|
|
let path = entry?.path();
|
|
if let Ok(generation) = parse_generation(&path) {
|
|
found.push((generation, path));
|
|
}
|
|
}
|
|
found.sort_by_key(|(generation, _)| std::cmp::Reverse(*generation));
|
|
Ok(found)
|
|
}
|
|
|
|
/// Load the newest validated generation, considering at most
|
|
/// `max_candidates` of them.
|
|
///
|
|
/// The candidate cap is what keeps this bounded. Plan §5.3 forbids an
|
|
/// unbounded normal-readiness scan; a directory that has accumulated thousands
|
|
/// of unpruned generations must not turn startup into a linear validation of
|
|
/// all of them before it gives up.
|
|
pub fn load_newest_valid(
|
|
dir: &Path,
|
|
root_uuid: &[u8; 16],
|
|
shard_index: u16,
|
|
max_candidates: usize,
|
|
) -> Result<CheckpointLoad, StoreError> {
|
|
let generations = list_generations(dir)?;
|
|
if generations.is_empty() {
|
|
return Ok(CheckpointLoad::Empty);
|
|
}
|
|
|
|
let mut rejected = Vec::new();
|
|
for (_, path) in generations.iter().take(max_candidates.max(1)) {
|
|
let bytes = match std::fs::read(path) {
|
|
Ok(bytes) => bytes,
|
|
Err(e) => {
|
|
rejected.push((path.clone(), CheckpointError::Unreadable(e.to_string())));
|
|
continue;
|
|
}
|
|
};
|
|
match Checkpoint::decode(&bytes, root_uuid, shard_index) {
|
|
Ok(checkpoint) => {
|
|
return Ok(CheckpointLoad::Loaded {
|
|
checkpoint: Box::new(checkpoint),
|
|
path: path.clone(),
|
|
rejected,
|
|
})
|
|
}
|
|
Err(e) => rejected.push((path.clone(), e)),
|
|
}
|
|
}
|
|
Ok(CheckpointLoad::OfflineRebuildRequired { rejected })
|
|
}
|
|
|
|
/// Write a checkpoint durably under a name that never overwrites.
|
|
///
|
|
/// Ordering matches scope 3.5's shape for any new immutable artifact: write a
|
|
/// temporary, fence it, `rename_noreplace` into place, then fsync the
|
|
/// directory. A reader can never see a partially written generation under its
|
|
/// final name because the final name only ever appears after the fence.
|
|
///
|
|
/// The body goes through `sys::write_vectored_all` rather than
|
|
/// `File::write_all`, and that is not a style preference. Review found this
|
|
/// call site writing directly: checkpoint bytes and short writes were invisible
|
|
/// to `DurabilityCounters`, and — worse — the `ENOSPC` / short-write /
|
|
/// cursor-skew seam could not reach checkpoint installation at all, so no fault
|
|
/// campaign could exercise it. A funnel that covers the fence but not the bytes
|
|
/// being fenced is not a funnel.
|
|
///
|
|
/// A short write is not an error at the syscall layer; it leaves a durable
|
|
/// prefix. Completeness — not a return value — decides the outcome, so an
|
|
/// incomplete body fails the install here and the temporary is never renamed.
|
|
/// The prefix that survives on disk is a `.tmp`, which `parse_generation`
|
|
/// refuses, and it would fail its trailer digest even if it were renamed.
|
|
pub fn install(
|
|
dir: &Path,
|
|
checkpoint: &Checkpoint,
|
|
counters: &DurabilityCounters,
|
|
) -> Result<PathBuf, StoreError> {
|
|
std::fs::create_dir_all(dir)?;
|
|
let bytes = checkpoint.encode()?;
|
|
let final_path = dir.join(checkpoint.file_name());
|
|
let tmp_path = dir.join(format!("{}.tmp", checkpoint.file_name()));
|
|
|
|
{
|
|
let mut file = File::options()
|
|
.create(true)
|
|
.write(true)
|
|
.truncate(true)
|
|
.open(&tmp_path)?;
|
|
let end = crate::sys::write_vectored_all(&mut file, &[IoSlice::new(&bytes)], counters)
|
|
.map_err(|e| {
|
|
StoreError::from(std::io::Error::new(
|
|
e.kind(),
|
|
format!("checkpoint body write failed: {e}"),
|
|
))
|
|
})?;
|
|
if end != bytes.len() as u64 {
|
|
return Err(StoreError::from(std::io::Error::new(
|
|
std::io::ErrorKind::WriteZero,
|
|
format!(
|
|
"short write installing checkpoint: wrote {end} of {} bytes; \
|
|
the temporary is left behind and never renamed",
|
|
bytes.len()
|
|
),
|
|
)));
|
|
}
|
|
crate::sys::fdatasync(&file, counters)?;
|
|
}
|
|
crate::sys::rename_noreplace(&tmp_path, &final_path).map_err(|e| {
|
|
// Leave the temporary behind for forensics rather than unlinking it
|
|
// on a path that already surprised us.
|
|
StoreError::from(e)
|
|
})?;
|
|
crate::sys::fsync_dir(dir, counters)?;
|
|
Ok(final_path)
|
|
}
|
|
|
|
/// Unlink generations older than the newest `retain`.
|
|
///
|
|
/// `retain` is `checkpoint_retain`, which `options.rs` refuses below 2. Pruning
|
|
/// happens only after a successful install, so the store is never below its
|
|
/// retention floor at any instant a crash could observe.
|
|
pub fn prune(dir: &Path, retain: u32, counters: &DurabilityCounters) -> Result<usize, StoreError> {
|
|
let generations = list_generations(dir)?;
|
|
let keep = retain.max(2) as usize;
|
|
if generations.len() <= keep {
|
|
return Ok(0);
|
|
}
|
|
let mut removed = 0;
|
|
for (_, path) in generations.iter().skip(keep) {
|
|
crate::sys::unlink(path)?;
|
|
removed += 1;
|
|
}
|
|
if removed > 0 {
|
|
crate::sys::fsync_dir(dir, counters)?;
|
|
}
|
|
Ok(removed)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
const ROOT: [u8; 16] = [3u8; 16];
|
|
const SHARD: u16 = 2;
|
|
|
|
fn ns(b: u8) -> NamespaceId {
|
|
NamespaceId([b; 32])
|
|
}
|
|
|
|
fn oid(b: u8) -> ObjectId {
|
|
ObjectId([b; 32])
|
|
}
|
|
|
|
fn sample() -> Checkpoint {
|
|
let mut catalog = NamespaceCatalog::new();
|
|
for i in 1..=3u8 {
|
|
catalog
|
|
.bind(NamespaceRecord {
|
|
namespace: ns(i),
|
|
genesis_authority: oid(0x10 + i),
|
|
current_authority: oid(0x20 + i),
|
|
lifecycle: NamespaceLifecycle::Active,
|
|
storage_mode: NamespaceStorageMode::Full,
|
|
repo_sequence: i as u64 * 7,
|
|
previous_event_digest: oid(0x30 + i),
|
|
})
|
|
.expect("bind");
|
|
}
|
|
Checkpoint {
|
|
root_uuid: ROOT,
|
|
shard_index: SHARD,
|
|
shard_committed_sequence: 4242,
|
|
active_journal_id: [9u8; 16],
|
|
active_journal_offset: 512 + 224 * 3,
|
|
created_at_micros: 1_700_000_000_000_000,
|
|
catalog,
|
|
refs: vec![
|
|
RefRecord {
|
|
namespace: ns(1),
|
|
ref_kind: 1,
|
|
name: b"refs/heads/main".to_vec(),
|
|
target: oid(0x40),
|
|
},
|
|
RefRecord {
|
|
namespace: ns(2),
|
|
ref_kind: 2,
|
|
name: Vec::new(),
|
|
target: oid(0x41),
|
|
},
|
|
],
|
|
receipts: vec![
|
|
ReceiptRecord {
|
|
namespace: ns(1),
|
|
operation_id: OperationId([1u8; 16]),
|
|
operation_digest: oid(0x50),
|
|
repo_sequence: 7,
|
|
shard_sequence: 4241,
|
|
current_authority: oid(0x21),
|
|
refs: vec![
|
|
AppliedRef {
|
|
target: RefTarget::Branch("refs/branches/main".into()),
|
|
old: Some(oid(0x60)),
|
|
new: Some(oid(0x61)),
|
|
force: false,
|
|
},
|
|
AppliedRef {
|
|
target: RefTarget::Release("refs/releases/old".into()),
|
|
old: Some(oid(0x62)),
|
|
new: None,
|
|
force: true,
|
|
},
|
|
],
|
|
objects_new: 3,
|
|
retry_until_micros: 1_700_000_900_000_000,
|
|
first_receipt_visibility_micros: Some(1_700_000_000_500_000),
|
|
receipt_visible_until_micros: 1_700_000_900_000_000,
|
|
},
|
|
ReceiptRecord {
|
|
namespace: ns(2),
|
|
operation_id: OperationId([2u8; 16]),
|
|
operation_digest: oid(0x51),
|
|
repo_sequence: 14,
|
|
shard_sequence: 4242,
|
|
current_authority: oid(0x22),
|
|
refs: Vec::new(),
|
|
objects_new: 0,
|
|
retry_until_micros: 1_700_000_900_000_000,
|
|
first_receipt_visibility_micros: None,
|
|
receipt_visible_until_micros: 1_700_000_900_000_000,
|
|
},
|
|
],
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn a_checkpoint_round_trips_every_field() {
|
|
let checkpoint = sample();
|
|
let bytes = checkpoint.encode().expect("encode");
|
|
let decoded = Checkpoint::decode(&bytes, &ROOT, SHARD).expect("decode");
|
|
assert_eq!(decoded, checkpoint);
|
|
assert_eq!(
|
|
decoded.receipts[0].refs, checkpoint.receipts[0].refs,
|
|
"old/new values, deletion, force, target kind, and order are receipt data"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_ref_record_round_trips_through_its_typed_target() {
|
|
for target in [
|
|
RefTarget::Branch("refs/heads/main".into()),
|
|
RefTarget::Release("refs/releases/v1".into()),
|
|
] {
|
|
let record = RefRecord::from_target(ns(1), &target, oid(0x40));
|
|
assert_eq!(record.target().expect("a record we built decodes"), target);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn an_unknown_ref_kind_is_refused_by_value_and_never_defaulted() {
|
|
let mut record = RefRecord::from_target(
|
|
ns(1),
|
|
&RefTarget::Branch("refs/heads/main".into()),
|
|
oid(0x40),
|
|
);
|
|
for kind in [0u8, 3, 255] {
|
|
record.ref_kind = kind;
|
|
assert_eq!(
|
|
record.target().unwrap_err(),
|
|
CheckpointError::RefKind(kind),
|
|
"an unreadable ref must not resolve as a branch: the next checkpoint would \
|
|
write the guess back as though it were recovered state"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn a_legacy_checkpoint_with_receipts_requires_offline_rebuild() {
|
|
let legacy = sample()
|
|
.encode_with_receipt_refs(false)
|
|
.expect("legacy fixture");
|
|
assert_eq!(
|
|
Checkpoint::decode(&legacy, &ROOT, SHARD).unwrap_err(),
|
|
CheckpointError::ReceiptRefsUnavailable,
|
|
"inventing an empty applied-ref result would turn a committed receipt into a lie"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_legacy_checkpoint_without_receipts_remains_readable() {
|
|
let mut checkpoint = sample();
|
|
checkpoint.receipts.clear();
|
|
let legacy = checkpoint
|
|
.encode_with_receipt_refs(false)
|
|
.expect("legacy fixture");
|
|
assert_eq!(
|
|
Checkpoint::decode(&legacy, &ROOT, SHARD).expect("no receipt information is missing"),
|
|
checkpoint
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_directory_of_legacy_receipt_checkpoints_enters_offline_rebuild() {
|
|
let dir = tempfile::tempdir().expect("tempdir");
|
|
let checkpoint = sample();
|
|
let legacy = checkpoint
|
|
.encode_with_receipt_refs(false)
|
|
.expect("legacy fixture");
|
|
std::fs::write(dir.path().join(checkpoint.file_name()), legacy).expect("write fixture");
|
|
match load_newest_valid(dir.path(), &ROOT, SHARD, 8).expect("load") {
|
|
CheckpointLoad::OfflineRebuildRequired { rejected } => {
|
|
assert_eq!(rejected.len(), 1);
|
|
assert_eq!(rejected[0].1, CheckpointError::ReceiptRefsUnavailable);
|
|
}
|
|
other => panic!("missing receipt data must require offline rebuild, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn encoding_is_deterministic() {
|
|
let checkpoint = sample();
|
|
assert_eq!(
|
|
checkpoint.encode().expect("a"),
|
|
checkpoint.encode().expect("b"),
|
|
"a non-deterministic encoding cannot be checksum-compared across generations"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn unset_first_visibility_survives_the_round_trip_as_unset() {
|
|
// The whole of recovery step 10 depends on being able to tell "not yet
|
|
// durably captured" from "captured". A sentinel would lose that.
|
|
let checkpoint = sample();
|
|
let bytes = checkpoint.encode().expect("encode");
|
|
let decoded = Checkpoint::decode(&bytes, &ROOT, SHARD).expect("decode");
|
|
assert_eq!(
|
|
decoded.receipts[0].first_receipt_visibility_micros,
|
|
Some(1_700_000_000_500_000)
|
|
);
|
|
assert_eq!(decoded.receipts[1].first_receipt_visibility_micros, None);
|
|
}
|
|
|
|
#[test]
|
|
fn each_corruption_is_rejected_by_its_own_condition() {
|
|
let good = sample().encode().expect("encode");
|
|
|
|
let mut magic = good.clone();
|
|
magic[0] ^= 0xFF;
|
|
assert_eq!(
|
|
Checkpoint::decode(&magic, &ROOT, SHARD).unwrap_err(),
|
|
CheckpointError::Magic
|
|
);
|
|
|
|
let mut version = good.clone();
|
|
version[8] = 7;
|
|
assert_eq!(
|
|
Checkpoint::decode(&version, &ROOT, SHARD).unwrap_err(),
|
|
CheckpointError::StorageVersion(7)
|
|
);
|
|
|
|
let mut flags = good.clone();
|
|
flags[12] |= 2;
|
|
assert_eq!(
|
|
Checkpoint::decode(&flags, &ROOT, SHARD).unwrap_err(),
|
|
CheckpointError::Flags
|
|
);
|
|
|
|
let mut header = good.clone();
|
|
header[40] ^= 0x01;
|
|
assert_eq!(
|
|
Checkpoint::decode(&header, &ROOT, SHARD).unwrap_err(),
|
|
CheckpointError::HeaderDigest
|
|
);
|
|
|
|
let mut body = good.clone();
|
|
let at = CHECKPOINT_HEADER_LEN + 8;
|
|
body[at] ^= 0x01;
|
|
assert_eq!(
|
|
Checkpoint::decode(&body, &ROOT, SHARD).unwrap_err(),
|
|
CheckpointError::BodyDigest
|
|
);
|
|
|
|
let mut trailer = good.clone();
|
|
let at = good.len() - CHECKPOINT_TRAILER_LEN;
|
|
trailer[at] ^= 0xFF;
|
|
assert_eq!(
|
|
Checkpoint::decode(&trailer, &ROOT, SHARD).unwrap_err(),
|
|
CheckpointError::Trailer
|
|
);
|
|
|
|
assert_eq!(
|
|
Checkpoint::decode(&good[..CHECKPOINT_HEADER_LEN], &ROOT, SHARD).unwrap_err(),
|
|
CheckpointError::Truncated
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_checkpoint_from_another_store_root_is_refused() {
|
|
let good = sample().encode().expect("encode");
|
|
assert_eq!(
|
|
Checkpoint::decode(&good, &[8u8; 16], SHARD).unwrap_err(),
|
|
CheckpointError::RootUuid
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_checkpoint_from_another_shard_is_refused() {
|
|
let good = sample().encode().expect("encode");
|
|
assert_eq!(
|
|
Checkpoint::decode(&good, &ROOT, SHARD + 1).unwrap_err(),
|
|
CheckpointError::ShardIndex {
|
|
expected: SHARD + 1,
|
|
found: SHARD,
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn truncation_at_every_offset_is_rejected_without_panic() {
|
|
let good = sample().encode().expect("encode");
|
|
for cut in 0..good.len() {
|
|
assert!(
|
|
Checkpoint::decode(&good[..cut], &ROOT, SHARD).is_err(),
|
|
"truncation at {cut} must be rejected"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn single_bit_mutation_never_validates() {
|
|
let good = sample().encode().expect("encode");
|
|
for byte in (0..good.len()).step_by(7) {
|
|
for bit in [0u8, 3, 7] {
|
|
let mut mutated = good.clone();
|
|
mutated[byte] ^= 1 << bit;
|
|
assert!(
|
|
Checkpoint::decode(&mutated, &ROOT, SHARD).is_err(),
|
|
"flipping bit {bit} of byte {byte} must not validate"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
// -- installation and selection ----------------------------------------
|
|
|
|
fn counters() -> DurabilityCounters {
|
|
DurabilityCounters::default()
|
|
}
|
|
|
|
#[test]
|
|
fn install_is_durable_and_never_overwrites_a_generation() {
|
|
let dir = tempfile::tempdir().expect("tempdir");
|
|
let c = counters();
|
|
let checkpoint = sample();
|
|
let path = install(dir.path(), &checkpoint, &c).expect("install");
|
|
assert_eq!(path.file_name().expect("name"), "4242.checkpoint");
|
|
assert!(c.snapshot().fdatasync >= 1, "the file must be fenced");
|
|
assert!(c.snapshot().fsync_dir >= 1, "the directory must be synced");
|
|
|
|
let err = install(dir.path(), &checkpoint, &c)
|
|
.expect_err("a second install of the same generation must be refused");
|
|
match err {
|
|
StoreError::Io(e) => assert_eq!(e.raw_os_error(), Some(17), "EEXIST"),
|
|
other => panic!("expected an EEXIST io error, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn an_empty_directory_is_empty_not_offline_rebuild() {
|
|
let dir = tempfile::tempdir().expect("tempdir");
|
|
assert_eq!(
|
|
load_newest_valid(dir.path(), &ROOT, SHARD, 8).expect("load"),
|
|
CheckpointLoad::Empty,
|
|
"a fresh root has no checkpoints and is healthy; it must not be \
|
|
confused with a root whose generations are all corrupt"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn the_newest_valid_generation_wins() {
|
|
let dir = tempfile::tempdir().expect("tempdir");
|
|
let c = counters();
|
|
for sequence in [10u64, 20, 30] {
|
|
let mut checkpoint = sample();
|
|
checkpoint.shard_committed_sequence = sequence;
|
|
install(dir.path(), &checkpoint, &c).expect("install");
|
|
}
|
|
match load_newest_valid(dir.path(), &ROOT, SHARD, 8).expect("load") {
|
|
CheckpointLoad::Loaded {
|
|
checkpoint,
|
|
rejected,
|
|
..
|
|
} => {
|
|
assert_eq!(checkpoint.shard_committed_sequence, 30);
|
|
assert!(rejected.is_empty());
|
|
}
|
|
other => panic!("expected Loaded, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn one_corrupt_generation_falls_back_to_the_next() {
|
|
let dir = tempfile::tempdir().expect("tempdir");
|
|
let c = counters();
|
|
for sequence in [10u64, 20] {
|
|
let mut checkpoint = sample();
|
|
checkpoint.shard_committed_sequence = sequence;
|
|
install(dir.path(), &checkpoint, &c).expect("install");
|
|
}
|
|
let newest = dir.path().join("20.checkpoint");
|
|
let mut bytes = std::fs::read(&newest).expect("read");
|
|
bytes[CHECKPOINT_HEADER_LEN + 4] ^= 0xFF;
|
|
std::fs::write(&newest, &bytes).expect("write");
|
|
|
|
match load_newest_valid(dir.path(), &ROOT, SHARD, 8).expect("load") {
|
|
CheckpointLoad::Loaded {
|
|
checkpoint,
|
|
rejected,
|
|
..
|
|
} => {
|
|
assert_eq!(checkpoint.shard_committed_sequence, 10);
|
|
assert_eq!(rejected.len(), 1);
|
|
assert_eq!(rejected[0].1, CheckpointError::BodyDigest);
|
|
}
|
|
other => panic!("expected a fallback to generation 10, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn all_generations_corrupt_enters_offline_rebuild_rather_than_replaying_from_zero() {
|
|
let dir = tempfile::tempdir().expect("tempdir");
|
|
let c = counters();
|
|
for sequence in [10u64, 20] {
|
|
let mut checkpoint = sample();
|
|
checkpoint.shard_committed_sequence = sequence;
|
|
install(dir.path(), &checkpoint, &c).expect("install");
|
|
}
|
|
for sequence in [10u64, 20] {
|
|
let path = dir.path().join(format!("{sequence}.checkpoint"));
|
|
let mut bytes = std::fs::read(&path).expect("read");
|
|
bytes[CHECKPOINT_HEADER_LEN + 4] ^= 0xFF;
|
|
std::fs::write(&path, &bytes).expect("write");
|
|
}
|
|
|
|
match load_newest_valid(dir.path(), &ROOT, SHARD, 8).expect("load") {
|
|
CheckpointLoad::OfflineRebuildRequired { rejected } => {
|
|
assert_eq!(rejected.len(), 2);
|
|
for (_, e) in rejected {
|
|
assert_eq!(e, CheckpointError::BodyDigest);
|
|
}
|
|
}
|
|
other => panic!("expected OfflineRebuildRequired, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn a_foreign_root_generation_is_rejected_with_its_own_variant() {
|
|
let dir = tempfile::tempdir().expect("tempdir");
|
|
let c = counters();
|
|
let mut foreign = sample();
|
|
foreign.root_uuid = [0xEE; 16];
|
|
install(dir.path(), &foreign, &c).expect("install");
|
|
|
|
match load_newest_valid(dir.path(), &ROOT, SHARD, 8).expect("load") {
|
|
CheckpointLoad::OfflineRebuildRequired { rejected } => {
|
|
assert_eq!(rejected[0].1, CheckpointError::RootUuid);
|
|
}
|
|
other => panic!("expected OfflineRebuildRequired, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn the_candidate_scan_is_bounded() {
|
|
let dir = tempfile::tempdir().expect("tempdir");
|
|
let c = counters();
|
|
// Twenty corrupt newer generations above one good one. With a
|
|
// candidate cap of four, the good generation must NOT be reached:
|
|
// plan §5.3 asks for a bounded attempt, not a linear hunt.
|
|
let mut good = sample();
|
|
good.shard_committed_sequence = 1;
|
|
install(dir.path(), &good, &c).expect("install");
|
|
for sequence in 2..=21u64 {
|
|
let mut checkpoint = sample();
|
|
checkpoint.shard_committed_sequence = sequence;
|
|
let path = install(dir.path(), &checkpoint, &c).expect("install");
|
|
let mut bytes = std::fs::read(&path).expect("read");
|
|
bytes[CHECKPOINT_HEADER_LEN + 4] ^= 0xFF;
|
|
std::fs::write(&path, &bytes).expect("write");
|
|
}
|
|
|
|
match load_newest_valid(dir.path(), &ROOT, SHARD, 4).expect("load") {
|
|
CheckpointLoad::OfflineRebuildRequired { rejected } => {
|
|
assert_eq!(rejected.len(), 4, "exactly the cap must be attempted");
|
|
}
|
|
other => panic!("expected a bounded give-up, got {other:?}"),
|
|
}
|
|
|
|
match load_newest_valid(dir.path(), &ROOT, SHARD, 64).expect("load") {
|
|
CheckpointLoad::Loaded { checkpoint, .. } => {
|
|
assert_eq!(checkpoint.shard_committed_sequence, 1)
|
|
}
|
|
other => panic!("with a larger cap the good generation must be found, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn pruning_never_drops_below_the_retention_floor() {
|
|
let dir = tempfile::tempdir().expect("tempdir");
|
|
let c = counters();
|
|
for sequence in 1..=6u64 {
|
|
let mut checkpoint = sample();
|
|
checkpoint.shard_committed_sequence = sequence;
|
|
install(dir.path(), &checkpoint, &c).expect("install");
|
|
}
|
|
let removed = prune(dir.path(), 2, &c).expect("prune");
|
|
assert_eq!(removed, 4);
|
|
let remaining = list_generations(dir.path()).expect("list");
|
|
assert_eq!(
|
|
remaining.iter().map(|(g, _)| *g).collect::<Vec<_>>(),
|
|
vec![6, 5]
|
|
);
|
|
|
|
// A retain value below the floor is clamped up, not honoured: the
|
|
// whole point of two generations is that one corrupt one is survivable.
|
|
let removed = prune(dir.path(), 0, &c).expect("prune");
|
|
assert_eq!(removed, 0);
|
|
assert_eq!(list_generations(dir.path()).expect("list").len(), 2);
|
|
}
|
|
|
|
#[test]
|
|
fn unrelated_files_are_not_candidates() {
|
|
let dir = tempfile::tempdir().expect("tempdir");
|
|
let c = counters();
|
|
install(dir.path(), &sample(), &c).expect("install");
|
|
std::fs::write(dir.path().join("notes.txt"), b"hello").expect("write");
|
|
std::fs::write(dir.path().join("abc.checkpoint"), b"garbage").expect("write");
|
|
std::fs::write(dir.path().join("12.checkpoint.tmp"), b"garbage").expect("write");
|
|
|
|
let generations = list_generations(dir.path()).expect("list");
|
|
assert_eq!(generations.len(), 1);
|
|
assert_eq!(generations[0].0, 4242);
|
|
}
|
|
|
|
#[test]
|
|
fn a_declared_count_beyond_the_ceiling_is_refused_before_any_allocation() {
|
|
let mut bytes = sample().encode().expect("encode");
|
|
// Overwrite namespace_count with a value past the ceiling and reseal
|
|
// both digests, so the ceiling check is what fires.
|
|
let at = CHECKPOINT_HEADER_LEN;
|
|
bytes[at..at + 4].copy_from_slice(&u32::MAX.to_le_bytes());
|
|
let end = bytes.len() - CHECKPOINT_TRAILER_LEN;
|
|
let digest = crate::format::digest(CHECKPOINT_DIGEST_DOMAIN, &bytes[..end]);
|
|
bytes[end + 16..end + 48].copy_from_slice(&digest.0);
|
|
|
|
assert_eq!(
|
|
Checkpoint::decode(&bytes, &ROOT, SHARD).unwrap_err(),
|
|
CheckpointError::CountCeiling("namespaces")
|
|
);
|
|
}
|
|
}
|