Implement D0-B storage publication interfaces
This commit is contained in:
parent
e85a159525
commit
5111d655da
|
|
@ -266,6 +266,15 @@ version = "2.11.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
|
||||
|
||||
[[package]]
|
||||
name = "bitmaps"
|
||||
version = "2.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "031043d04099746d8db04daf1fa424b2bc8bd69d92b25962dcde24da39ab64a2"
|
||||
dependencies = [
|
||||
"typenum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "blake2"
|
||||
version = "0.10.6"
|
||||
|
|
@ -1359,6 +1368,20 @@ dependencies = [
|
|||
"icu_properties",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "im"
|
||||
version = "15.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d0acd33ff0285af998aaf9b57342af478078f53492322fafc47450e09397e0e9"
|
||||
dependencies = [
|
||||
"bitmaps",
|
||||
"rand_core 0.6.4",
|
||||
"rand_xoshiro",
|
||||
"sized-chunks",
|
||||
"typenum",
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.14.0"
|
||||
|
|
@ -1669,6 +1692,7 @@ dependencies = [
|
|||
"ed25519-dalek",
|
||||
"hdrhistogram",
|
||||
"hex",
|
||||
"im",
|
||||
"levcs-core",
|
||||
"levcs-protocol",
|
||||
"libc",
|
||||
|
|
@ -2199,6 +2223,15 @@ dependencies = [
|
|||
"rand_core 0.9.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_xoshiro"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6f97cdb2a36ed4183de61b2f824cc45c9f1037f28afe0a322e9fff4c108b5aaa"
|
||||
dependencies = [
|
||||
"rand_core 0.6.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ratatui"
|
||||
version = "0.28.1"
|
||||
|
|
@ -2613,6 +2646,16 @@ version = "2.7.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa"
|
||||
|
||||
[[package]]
|
||||
name = "sized-chunks"
|
||||
version = "0.6.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "16d69225bde7a69b235da73377861095455d298f2b970996eec25ddbb42b3d1e"
|
||||
dependencies = [
|
||||
"bitmaps",
|
||||
"typenum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "slab"
|
||||
version = "0.4.12"
|
||||
|
|
|
|||
|
|
@ -77,6 +77,7 @@ anyhow = "1.0"
|
|||
# for why each of these is taken and what was rejected in its place.
|
||||
arc-swap = "1.7"
|
||||
crossbeam-channel = "0.5"
|
||||
im = "15.1"
|
||||
rustix = { version = "1.1", features = ["fs"] }
|
||||
libc = "0.2"
|
||||
memmap2 = "0.9"
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ thiserror = { workspace = true }
|
|||
hex = { workspace = true }
|
||||
arc-swap = { workspace = true }
|
||||
crossbeam-channel = { workspace = true }
|
||||
im = { workspace = true }
|
||||
rustix = { workspace = true }
|
||||
libc = { workspace = true }
|
||||
memmap2 = { workspace = true }
|
||||
|
|
|
|||
|
|
@ -30,10 +30,11 @@ 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::{DurabilityCounters, NamespaceId, OperationId, StoreError};
|
||||
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
|
||||
|
|
@ -51,6 +52,18 @@ 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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -68,8 +81,10 @@ pub enum CheckpointError {
|
|||
Magic,
|
||||
#[error("checkpoint storage version {0} is not readable")]
|
||||
StorageVersion(u16),
|
||||
#[error("checkpoint flags and reserved fields must be zero")]
|
||||
#[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")]
|
||||
|
|
@ -125,6 +140,12 @@ pub struct ReceiptRecord {
|
|||
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.
|
||||
|
|
@ -286,8 +307,148 @@ fn read_optional_i64(
|
|||
}
|
||||
}
|
||||
|
||||
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",
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn ref_target_parts(target: &RefTarget) -> (u8, &str) {
|
||||
match target {
|
||||
RefTarget::Branch(name) => (1, name),
|
||||
RefTarget::Release(name) => (2, name),
|
||||
}
|
||||
}
|
||||
|
||||
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"))?
|
||||
.to_owned();
|
||||
if name.as_bytes().contains(&0) || levcs_core::refs::validate_ref_name(&name).is_err() {
|
||||
return Err(CheckpointError::Body("receipt ref target name"));
|
||||
}
|
||||
let target = match kind {
|
||||
1 => RefTarget::Branch(name),
|
||||
2 => RefTarget::Release(name),
|
||||
_ => return Err(CheckpointError::Body("receipt ref target kind")),
|
||||
};
|
||||
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",
|
||||
|
|
@ -349,6 +510,9 @@ impl Checkpoint {
|
|||
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);
|
||||
|
|
@ -361,7 +525,14 @@ impl Checkpoint {
|
|||
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, 0); // flags: must be zero
|
||||
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
|
||||
|
|
@ -408,9 +579,11 @@ impl Checkpoint {
|
|||
if head.u16("header_len")? as usize != CHECKPOINT_HEADER_LEN {
|
||||
return Err(CheckpointError::Body("header_len"));
|
||||
}
|
||||
if head.u32("flags")? != 0 {
|
||||
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 {
|
||||
|
|
@ -543,7 +716,9 @@ impl Checkpoint {
|
|||
if receipt_count > MAX_CHECKPOINT_RECEIPTS {
|
||||
return Err(CheckpointError::CountCeiling("receipts"));
|
||||
}
|
||||
let mut receipts = Vec::with_capacity((receipt_count as usize).min(body.len() / 161 + 1));
|
||||
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")?),
|
||||
|
|
@ -552,6 +727,11 @@ impl Checkpoint {
|
|||
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")?,
|
||||
|
|
@ -562,6 +742,9 @@ impl Checkpoint {
|
|||
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,
|
||||
|
|
@ -719,13 +902,13 @@ pub fn install(
|
|||
.open(&tmp_path)?;
|
||||
let end = crate::sys::write_vectored_all(&mut file, &[IoSlice::new(&bytes)], counters)
|
||||
.map_err(|e| {
|
||||
StoreError::Io(std::io::Error::new(
|
||||
StoreError::from(std::io::Error::new(
|
||||
e.kind(),
|
||||
format!("checkpoint body write failed: {e}"),
|
||||
))
|
||||
})?;
|
||||
if end != bytes.len() as u64 {
|
||||
return Err(StoreError::Io(std::io::Error::new(
|
||||
return Err(StoreError::from(std::io::Error::new(
|
||||
std::io::ErrorKind::WriteZero,
|
||||
format!(
|
||||
"short write installing checkpoint: wrote {end} of {} bytes; \
|
||||
|
|
@ -739,7 +922,7 @@ pub fn install(
|
|||
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::Io(e)
|
||||
StoreError::from(e)
|
||||
})?;
|
||||
crate::sys::fsync_dir(dir, counters)?;
|
||||
Ok(final_path)
|
||||
|
|
@ -827,6 +1010,20 @@ mod tests {
|
|||
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),
|
||||
|
|
@ -839,6 +1036,7 @@ mod tests {
|
|||
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,
|
||||
|
|
@ -854,6 +1052,52 @@ mod tests {
|
|||
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_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]
|
||||
|
|
@ -899,7 +1143,7 @@ mod tests {
|
|||
);
|
||||
|
||||
let mut flags = good.clone();
|
||||
flags[12] = 1;
|
||||
flags[12] |= 2;
|
||||
assert_eq!(
|
||||
Checkpoint::decode(&flags, &ROOT, SHARD).unwrap_err(),
|
||||
CheckpointError::Flags
|
||||
|
|
|
|||
|
|
@ -0,0 +1,444 @@
|
|||
//! Runtime-agnostic, one-to-many completion for submitted transactions.
|
||||
//!
|
||||
//! One in-flight transaction owns a [`SharedCompletion`]. Every caller that
|
||||
//! coalesces onto that transaction obtains its own [`CompletionWaiter`], while
|
||||
//! the shard writer publishes exactly one [`CompletionOutcome`].
|
||||
//!
|
||||
//! The outcome and waiter registrations deliberately share one mutex. A
|
||||
//! waiter therefore cannot observe "not complete", lose the mutex, and
|
||||
//! register after the only wake has already happened. Completion takes the
|
||||
//! registered wakers while holding that mutex, then wakes them after releasing
|
||||
//! it so arbitrary executor code never runs in the critical section.
|
||||
|
||||
use std::future::Future;
|
||||
use std::pin::Pin;
|
||||
use std::sync::{Arc, Mutex, MutexGuard};
|
||||
use std::task::{Context, Poll, Waker};
|
||||
|
||||
use crate::types::{CommitReceipt, StoreError};
|
||||
|
||||
/// The one durable or definitive outcome shared by all attached callers.
|
||||
pub type CompletionOutcome = Result<CommitReceipt, StoreError>;
|
||||
|
||||
/// One transaction's shared completion state.
|
||||
///
|
||||
/// Cloning this value clones only the `Arc`; it does not create a second
|
||||
/// execution or a second outcome slot. [`SharedCompletion::complete`] accepts
|
||||
/// the first outcome and treats any later attempt as a harmless no-op.
|
||||
#[derive(Clone)]
|
||||
pub struct SharedCompletion {
|
||||
shared: Arc<Shared>,
|
||||
}
|
||||
|
||||
struct Shared {
|
||||
state: Mutex<State>,
|
||||
}
|
||||
|
||||
struct State {
|
||||
outcome: Option<CompletionOutcome>,
|
||||
wakers: Vec<WaiterWaker>,
|
||||
}
|
||||
|
||||
struct WaiterWaker {
|
||||
identity: Arc<()>,
|
||||
waker: Waker,
|
||||
}
|
||||
|
||||
/// The exactly-once future belonging to one attached caller.
|
||||
///
|
||||
/// Dropping this value removes its registered waker without affecting the
|
||||
/// transaction or any other waiter. Polling it after it has returned `Ready`
|
||||
/// is a caller bug and panics with a stable diagnostic instead of attempting
|
||||
/// to manufacture a second value.
|
||||
#[must_use = "a completion waiter does nothing unless polled or awaited"]
|
||||
pub struct CompletionWaiter {
|
||||
shared: Arc<Shared>,
|
||||
identity: Arc<()>,
|
||||
delivered: bool,
|
||||
}
|
||||
|
||||
impl SharedCompletion {
|
||||
/// Creates an empty completion to be owned by one in-flight transaction.
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
shared: Arc::new(Shared {
|
||||
state: Mutex::new(State {
|
||||
outcome: None,
|
||||
wakers: Vec::new(),
|
||||
}),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// Attaches one caller to this transaction's outcome.
|
||||
///
|
||||
/// Attachment remains valid after completion: its first poll observes the
|
||||
/// stored result immediately and requires no wake.
|
||||
pub fn subscribe(&self) -> CompletionWaiter {
|
||||
CompletionWaiter {
|
||||
shared: Arc::clone(&self.shared),
|
||||
identity: Arc::new(()),
|
||||
delivered: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Publishes the outcome and wakes every currently registered waiter.
|
||||
///
|
||||
/// Returns `true` when this call published the first outcome and `false`
|
||||
/// when the completion had already been signalled. No receiver is needed:
|
||||
/// completing after some or all waiters have dropped is successful and
|
||||
/// harmless.
|
||||
///
|
||||
/// Wakers are invoked outside the state mutex. A panicking executor waker
|
||||
/// is isolated so it cannot unwind the shard thread or prevent another
|
||||
/// waiter from being woken.
|
||||
pub fn complete(&self, outcome: CompletionOutcome) -> bool {
|
||||
let wakers = {
|
||||
let mut state = self.shared.lock_state();
|
||||
if state.outcome.is_some() {
|
||||
return false;
|
||||
}
|
||||
|
||||
state.outcome = Some(outcome);
|
||||
std::mem::take(&mut state.wakers)
|
||||
};
|
||||
|
||||
for waiter in wakers {
|
||||
let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
waiter.waker.wake();
|
||||
}));
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn registered_waiters(&self) -> usize {
|
||||
self.shared.lock_state().wakers.len()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for SharedCompletion {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Shared {
|
||||
/// A poisoned completion mutex must not turn a committed transaction into
|
||||
/// a shard-thread panic. The protected state remains structurally valid
|
||||
/// because every mutation is an individual move or collection operation.
|
||||
fn lock_state(&self) -> MutexGuard<'_, State> {
|
||||
self.state
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
}
|
||||
}
|
||||
|
||||
impl Future for CompletionWaiter {
|
||||
type Output = CompletionOutcome;
|
||||
|
||||
fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
|
||||
if self.delivered {
|
||||
panic!("CompletionWaiter polled after returning Ready");
|
||||
}
|
||||
|
||||
let outcome = {
|
||||
let mut state = self.shared.lock_state();
|
||||
|
||||
if let Some(outcome) = state.outcome.as_ref() {
|
||||
Some(outcome.clone())
|
||||
} else {
|
||||
match state
|
||||
.wakers
|
||||
.iter_mut()
|
||||
.find(|registered| Arc::ptr_eq(®istered.identity, &self.identity))
|
||||
{
|
||||
Some(registered) => {
|
||||
if !registered.waker.will_wake(cx.waker()) {
|
||||
registered.waker = cx.waker().clone();
|
||||
}
|
||||
}
|
||||
None => state.wakers.push(WaiterWaker {
|
||||
identity: Arc::clone(&self.identity),
|
||||
waker: cx.waker().clone(),
|
||||
}),
|
||||
}
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
match outcome {
|
||||
Some(outcome) => {
|
||||
self.delivered = true;
|
||||
Poll::Ready(outcome)
|
||||
}
|
||||
None => Poll::Pending,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for CompletionWaiter {
|
||||
fn drop(&mut self) {
|
||||
if self.delivered {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut state = self.shared.lock_state();
|
||||
if let Some(index) = state
|
||||
.wakers
|
||||
.iter()
|
||||
.position(|registered| Arc::ptr_eq(®istered.identity, &self.identity))
|
||||
{
|
||||
state.wakers.swap_remove(index);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::future::Future;
|
||||
use std::pin::Pin;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::task::{Context, Poll, Wake, Waker};
|
||||
|
||||
use super::{CompletionWaiter, SharedCompletion};
|
||||
use crate::types::{CommitReceipt, OperationId, StoreError};
|
||||
|
||||
#[derive(Default)]
|
||||
struct WakeCount {
|
||||
wakes: AtomicUsize,
|
||||
}
|
||||
|
||||
impl Wake for WakeCount {
|
||||
fn wake(self: Arc<Self>) {
|
||||
self.wakes.fetch_add(1, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
fn wake_by_ref(self: &Arc<Self>) {
|
||||
self.wakes.fetch_add(1, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
|
||||
struct PanickingWake;
|
||||
|
||||
impl Wake for PanickingWake {
|
||||
fn wake(self: Arc<Self>) {
|
||||
panic!("executor waker panic");
|
||||
}
|
||||
}
|
||||
|
||||
fn receipt(sequence: u64) -> CommitReceipt {
|
||||
CommitReceipt {
|
||||
operation_id: OperationId::from_bytes([7; 16]),
|
||||
repo_sequence: sequence,
|
||||
current_authority: levcs_core::ObjectId([11; 32]),
|
||||
refs: Vec::new(),
|
||||
objects_new: 3,
|
||||
}
|
||||
}
|
||||
|
||||
fn poll_with(
|
||||
waiter: &mut CompletionWaiter,
|
||||
wake: &Arc<WakeCount>,
|
||||
) -> Poll<Result<CommitReceipt, StoreError>> {
|
||||
let waker = Waker::from(Arc::clone(wake));
|
||||
let mut context = Context::from_waker(&waker);
|
||||
Pin::new(waiter).poll(&mut context)
|
||||
}
|
||||
|
||||
fn assert_ready_ok(outcome: Poll<Result<CommitReceipt, StoreError>>, expected: &CommitReceipt) {
|
||||
match outcome {
|
||||
Poll::Ready(Ok(actual)) => assert_eq!(&actual, expected),
|
||||
other => panic!("expected a successful ready outcome, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completion_after_first_poll_wakes_and_delivers() {
|
||||
let completion = SharedCompletion::new();
|
||||
let mut waiter = completion.subscribe();
|
||||
let wake = Arc::new(WakeCount::default());
|
||||
|
||||
assert!(poll_with(&mut waiter, &wake).is_pending());
|
||||
assert_eq!(completion.registered_waiters(), 1);
|
||||
|
||||
let expected = receipt(13);
|
||||
assert!(completion.complete(Ok(expected.clone())));
|
||||
assert_eq!(wake.wakes.load(Ordering::SeqCst), 1);
|
||||
assert_eq!(completion.registered_waiters(), 0);
|
||||
assert_ready_ok(poll_with(&mut waiter, &wake), &expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completion_before_first_poll_is_immediately_ready() {
|
||||
let completion = SharedCompletion::new();
|
||||
let expected = receipt(21);
|
||||
assert!(completion.complete(Ok(expected.clone())));
|
||||
|
||||
let mut waiter = completion.subscribe();
|
||||
let wake = Arc::new(WakeCount::default());
|
||||
assert_ready_ok(poll_with(&mut waiter, &wake), &expected);
|
||||
assert_eq!(wake.wakes.load(Ordering::SeqCst), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_waiter_delivers_exactly_once() {
|
||||
let completion = SharedCompletion::new();
|
||||
let mut waiter = completion.subscribe();
|
||||
let wake = Arc::new(WakeCount::default());
|
||||
assert!(completion.complete(Ok(receipt(34))));
|
||||
assert!(poll_with(&mut waiter, &wake).is_ready());
|
||||
|
||||
let second_poll = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
let _ = poll_with(&mut waiter, &wake);
|
||||
}));
|
||||
assert!(second_poll.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiple_waiters_receive_the_same_outcome() {
|
||||
let completion = SharedCompletion::new();
|
||||
let mut first = completion.subscribe();
|
||||
let mut second = completion.subscribe();
|
||||
let first_wake = Arc::new(WakeCount::default());
|
||||
let second_wake = Arc::new(WakeCount::default());
|
||||
|
||||
assert!(poll_with(&mut first, &first_wake).is_pending());
|
||||
assert!(poll_with(&mut second, &second_wake).is_pending());
|
||||
|
||||
let expected = receipt(55);
|
||||
assert!(completion.complete(Ok(expected.clone())));
|
||||
assert_eq!(first_wake.wakes.load(Ordering::SeqCst), 1);
|
||||
assert_eq!(second_wake.wakes.load(Ordering::SeqCst), 1);
|
||||
assert_ready_ok(poll_with(&mut first, &first_wake), &expected);
|
||||
assert_ready_ok(poll_with(&mut second, &second_wake), &expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dropping_one_waiter_removes_only_its_waker() {
|
||||
let completion = SharedCompletion::new();
|
||||
let mut dropped = completion.subscribe();
|
||||
let mut retained = completion.subscribe();
|
||||
let dropped_wake = Arc::new(WakeCount::default());
|
||||
let retained_wake = Arc::new(WakeCount::default());
|
||||
|
||||
assert!(poll_with(&mut dropped, &dropped_wake).is_pending());
|
||||
assert!(poll_with(&mut retained, &retained_wake).is_pending());
|
||||
assert_eq!(completion.registered_waiters(), 2);
|
||||
drop(dropped);
|
||||
assert_eq!(completion.registered_waiters(), 1);
|
||||
|
||||
let expected = receipt(89);
|
||||
assert!(completion.complete(Ok(expected.clone())));
|
||||
assert_eq!(dropped_wake.wakes.load(Ordering::SeqCst), 0);
|
||||
assert_eq!(retained_wake.wakes.load(Ordering::SeqCst), 1);
|
||||
assert_ready_ok(poll_with(&mut retained, &retained_wake), &expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completing_after_all_waiters_drop_is_harmless() {
|
||||
let completion = SharedCompletion::new();
|
||||
let mut first = completion.subscribe();
|
||||
let mut second = completion.subscribe();
|
||||
let first_wake = Arc::new(WakeCount::default());
|
||||
let second_wake = Arc::new(WakeCount::default());
|
||||
|
||||
assert!(poll_with(&mut first, &first_wake).is_pending());
|
||||
assert!(poll_with(&mut second, &second_wake).is_pending());
|
||||
drop(first);
|
||||
drop(second);
|
||||
assert_eq!(completion.registered_waiters(), 0);
|
||||
|
||||
assert!(completion.complete(Ok(receipt(144))));
|
||||
assert_eq!(first_wake.wakes.load(Ordering::SeqCst), 0);
|
||||
assert_eq!(second_wake.wakes.load(Ordering::SeqCst), 0);
|
||||
assert!(!completion.complete(Ok(receipt(145))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn io_error_is_shared_losslessly_between_waiters() {
|
||||
let completion = SharedCompletion::new();
|
||||
let mut first = completion.subscribe();
|
||||
let mut second = completion.subscribe();
|
||||
let wake = Arc::new(WakeCount::default());
|
||||
let original = Arc::new(std::io::Error::other("one failure"));
|
||||
|
||||
assert!(completion.complete(Err(StoreError::Io(Arc::clone(&original)))));
|
||||
let first_error = match poll_with(&mut first, &wake) {
|
||||
Poll::Ready(Err(StoreError::Io(error))) => error,
|
||||
other => panic!("unexpected first outcome: {other:?}"),
|
||||
};
|
||||
let second_error = match poll_with(&mut second, &wake) {
|
||||
Poll::Ready(Err(StoreError::Io(error))) => error,
|
||||
other => panic!("unexpected second outcome: {other:?}"),
|
||||
};
|
||||
|
||||
assert!(Arc::ptr_eq(&original, &first_error));
|
||||
assert!(Arc::ptr_eq(&first_error, &second_error));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn panicking_waker_cannot_unwind_completion_or_skip_other_waiters() {
|
||||
let completion = SharedCompletion::new();
|
||||
let mut panicking = completion.subscribe();
|
||||
let mut retained = completion.subscribe();
|
||||
let panic_waker = Waker::from(Arc::new(PanickingWake));
|
||||
let mut panic_context = Context::from_waker(&panic_waker);
|
||||
let retained_wake = Arc::new(WakeCount::default());
|
||||
|
||||
assert!(Pin::new(&mut panicking)
|
||||
.poll(&mut panic_context)
|
||||
.is_pending());
|
||||
assert!(poll_with(&mut retained, &retained_wake).is_pending());
|
||||
|
||||
let completed = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
completion.complete(Ok(receipt(233)))
|
||||
}));
|
||||
assert!(matches!(completed, Ok(true)));
|
||||
assert_eq!(retained_wake.wakes.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn poisoned_state_mutex_does_not_panic_completion() {
|
||||
let completion = SharedCompletion::new();
|
||||
let shared = Arc::clone(&completion.shared);
|
||||
|
||||
let poisoned = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
let _guard = shared.state.lock().expect("initial lock");
|
||||
panic!("poison completion state");
|
||||
}));
|
||||
assert!(poisoned.is_err());
|
||||
|
||||
assert!(completion.complete(Ok(receipt(377))));
|
||||
let mut waiter = completion.subscribe();
|
||||
let wake = Arc::new(WakeCount::default());
|
||||
assert!(poll_with(&mut waiter, &wake).is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_pending_polls_replace_instead_of_accumulating_wakers() {
|
||||
let completion = SharedCompletion::new();
|
||||
let mut waiter = completion.subscribe();
|
||||
let first_wake = Arc::new(WakeCount::default());
|
||||
let second_wake = Arc::new(WakeCount::default());
|
||||
|
||||
assert!(poll_with(&mut waiter, &first_wake).is_pending());
|
||||
assert!(poll_with(&mut waiter, &second_wake).is_pending());
|
||||
assert_eq!(completion.registered_waiters(), 1);
|
||||
|
||||
assert!(completion.complete(Ok(receipt(610))));
|
||||
assert_eq!(first_wake.wakes.load(Ordering::SeqCst), 0);
|
||||
assert_eq!(second_wake.wakes.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completion_is_send_sync_and_waiter_is_send() {
|
||||
fn assert_send_sync<T: Send + Sync>() {}
|
||||
fn assert_send<T: Send>() {}
|
||||
|
||||
assert_send_sync::<SharedCompletion>();
|
||||
assert_send::<CompletionWaiter>();
|
||||
}
|
||||
}
|
||||
|
|
@ -36,14 +36,11 @@ use std::sync::Arc;
|
|||
|
||||
use levcs_core::ObjectId;
|
||||
|
||||
use crate::format::{
|
||||
Frame, FrameHeader, JournalHeader, TransactionFramePayloadV1, JOURNAL_HEADER_LEN,
|
||||
};
|
||||
use crate::format::{Frame, FrameHeader, TransactionFramePayloadV1};
|
||||
use crate::index::{NamespaceCatalog, NamespaceLifecycle, NamespaceRecord, NamespaceStorageMode};
|
||||
use crate::journal::{Journal, TailStop};
|
||||
use crate::recovery::{self, ActiveJournalDisposition, FrameFacts, PayloadFacts};
|
||||
use crate::journal::Journal;
|
||||
use crate::recovery::{self, FrameFacts, PayloadFacts};
|
||||
use crate::segment::{self, RootLayout, ShardPaths};
|
||||
use crate::sys;
|
||||
use crate::types::{DurabilityCounterSnapshot, DurabilityCounters, NamespaceId, StoreError};
|
||||
|
||||
/// A single shard's journal, opened directly.
|
||||
|
|
@ -90,8 +87,14 @@ const DRIVE_MANIFEST_CANDIDATES: usize = 8;
|
|||
const DRIVE_TERMINAL_STATUS_GRACE_MICROS: i64 = 900_000_000;
|
||||
|
||||
/// What recovery concluded about a driven shard after reopening.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct DriveRecovery {
|
||||
/// The full recovered state retained by the production entry point.
|
||||
///
|
||||
/// The summary fields below are projections only. Keeping this value is
|
||||
/// what lets the Wave B equivalence test compare the engine and drive
|
||||
/// roots rather than merely comparing a sequence list.
|
||||
pub recovered: recovery::RecoveredShard,
|
||||
/// Frames recovery adopted, in `shard_sequence` order. Always a contiguous
|
||||
/// prefix of what was appended — recovery stops at the first incomplete
|
||||
/// frame and discards everything after it, even a syntactically complete
|
||||
|
|
@ -106,7 +109,7 @@ pub struct DriveRecovery {
|
|||
/// The full report recovery produced, verbatim.
|
||||
///
|
||||
/// The four fields above are a convenience projection of it and nothing
|
||||
/// more; [`DriveRecovery::from_report`] is the only thing that builds
|
||||
/// more; [`DriveRecovery::from_recovered`] is the only thing that builds
|
||||
/// them, so they cannot disagree with this.
|
||||
///
|
||||
/// Added after the Wave A review (record 2026-07-24-D). The seam
|
||||
|
|
@ -121,6 +124,21 @@ pub struct DriveRecovery {
|
|||
pub report: recovery::ShardRecoveryReport,
|
||||
}
|
||||
|
||||
// Preserve the Wave A comparison contract for the diagnostic projection. The
|
||||
// newly retained `RecoveredShard` contains mmap/file ownership whose identity
|
||||
// is deliberately not value-comparable.
|
||||
impl PartialEq for DriveRecovery {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.adopted_shard_sequences == other.adopted_shard_sequences
|
||||
&& self.tail_stop_offset == other.tail_stop_offset
|
||||
&& self.quarantined_bytes == other.quarantined_bytes
|
||||
&& self.used_manifest_fallback == other.used_manifest_fallback
|
||||
&& self.report == other.report
|
||||
}
|
||||
}
|
||||
|
||||
impl Eq for DriveRecovery {}
|
||||
|
||||
impl DriveRecovery {
|
||||
/// Project a recovery report into the seam's shape.
|
||||
///
|
||||
|
|
@ -133,16 +151,18 @@ impl DriveRecovery {
|
|||
/// records *why* the scan stopped; whether that stop discarded anything is
|
||||
/// a separate judgment (a clean journal always stops at its first unwritten
|
||||
/// byte), and only the scan site holds both halves.
|
||||
fn from_report(report: recovery::ShardRecoveryReport, tail_stop_offset: Option<u64>) -> Self {
|
||||
fn from_recovered(recovered: recovery::RecoveredShard) -> Self {
|
||||
let report = &recovered.report;
|
||||
Self {
|
||||
adopted_shard_sequences: report.adopted_shard_sequences.clone(),
|
||||
tail_stop_offset,
|
||||
tail_stop_offset: recovered.tail_stop_offset,
|
||||
quarantined_bytes: report.quarantined_bytes,
|
||||
used_manifest_fallback: matches!(
|
||||
report.manifest_source,
|
||||
Some(recovery::ManifestSource::Fallback { .. })
|
||||
),
|
||||
report,
|
||||
report: report.clone(),
|
||||
recovered,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -423,11 +443,16 @@ impl ShardDrive {
|
|||
/// supplies the real ref state.
|
||||
pub fn checkpoint(&mut self) -> Result<PathBuf, StoreError> {
|
||||
let facts = self.durable_facts()?;
|
||||
if facts.is_empty() {
|
||||
return Err(StoreError::Conflict(
|
||||
"an empty Phase 1 shard has no manifest and cannot install a checkpoint".into(),
|
||||
));
|
||||
}
|
||||
let created_at_micros = now_micros();
|
||||
|
||||
let mut catalog = NamespaceCatalog::new();
|
||||
let mut receipts = Vec::with_capacity(facts.len());
|
||||
for fact in &facts {
|
||||
for (fact, recovered) in &facts {
|
||||
if catalog.get(&fact.namespace).is_none() {
|
||||
catalog.bind(NamespaceRecord {
|
||||
namespace: fact.namespace,
|
||||
|
|
@ -446,13 +471,16 @@ impl ShardDrive {
|
|||
fact.event_digest,
|
||||
)?;
|
||||
}
|
||||
receipts.push(receipt_for(fact, created_at_micros)?);
|
||||
receipts.push(receipt_for(fact, recovered, created_at_micros)?);
|
||||
}
|
||||
|
||||
let checkpoint = crate::checkpoint::Checkpoint {
|
||||
root_uuid: self.root_uuid,
|
||||
shard_index: self.shard,
|
||||
shard_committed_sequence: facts.last().map(|f| f.shard_sequence).unwrap_or(0),
|
||||
shard_committed_sequence: facts
|
||||
.last()
|
||||
.map(|(facts, _)| facts.shard_sequence)
|
||||
.unwrap_or(0),
|
||||
// The resume point of scope 3.6: `journal_id` accompanies the
|
||||
// offset because an offset alone is meaningless once the journal
|
||||
// has rotated, and recovery checks the identity before it trusts
|
||||
|
|
@ -467,8 +495,92 @@ impl ShardDrive {
|
|||
|
||||
let path =
|
||||
crate::checkpoint::install(&self.paths.checkpoints(), &checkpoint, &self.counters)?;
|
||||
// Only after a successful install, so the store is never below its
|
||||
// retention floor at an instant a crash could observe.
|
||||
|
||||
// A checkpoint file is derived state, not authority by directory
|
||||
// presence. Seal the active frames and publish both the new tail range
|
||||
// and this checkpoint row in one manifest generation. Installing an
|
||||
// intermediate seal-only generation here would let manifest_retain=2
|
||||
// evict the predecessor checkpoint manifest between two checkpoints.
|
||||
let previous = segment::load_manifest_with_fallback(&self.paths, &self.root_uuid)?
|
||||
.map(|(manifest, _)| manifest);
|
||||
let generation = previous
|
||||
.as_ref()
|
||||
.map(|manifest| manifest.generation.saturating_add(1))
|
||||
.unwrap_or(1);
|
||||
let mut retained_tail_ranges = previous
|
||||
.as_ref()
|
||||
.map(|manifest| manifest.retained_tail_ranges.clone())
|
||||
.unwrap_or_default();
|
||||
let active_path = self.journal.path().to_path_buf();
|
||||
let sealed_active = if let Some(last) = self.journal.last_appended_shard_sequence() {
|
||||
let first = self.journal.header().first_shard_sequence;
|
||||
let destination =
|
||||
segment::seal_journal(&mut self.journal, &self.paths, generation, &self.counters)?;
|
||||
let filename = destination
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.ok_or_else(|| StoreError::Corruption("segment name is not UTF-8".into()))?
|
||||
.to_string();
|
||||
retained_tail_ranges.push(crate::format::TailRange {
|
||||
generation,
|
||||
first_shard_sequence: first,
|
||||
last_shard_sequence: last,
|
||||
filename,
|
||||
});
|
||||
true
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
let filename = path
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.ok_or_else(|| StoreError::Corruption("checkpoint name is not UTF-8".into()))?
|
||||
.to_string();
|
||||
let mut checkpoint_rows = previous
|
||||
.as_ref()
|
||||
.map(|manifest| manifest.checkpoints.clone())
|
||||
.unwrap_or_default();
|
||||
checkpoint_rows.retain(|(sequence, _)| *sequence != checkpoint.shard_committed_sequence);
|
||||
checkpoint_rows.push((checkpoint.shard_committed_sequence, filename));
|
||||
let retain = self.checkpoint_retain.max(2) as usize;
|
||||
if checkpoint_rows.len() > retain {
|
||||
let drop_count = checkpoint_rows.len() - retain;
|
||||
checkpoint_rows.drain(..drop_count);
|
||||
}
|
||||
let manifest = crate::format::Manifest {
|
||||
root_uuid: self.root_uuid,
|
||||
generation,
|
||||
base_generation: previous
|
||||
.as_ref()
|
||||
.map(|manifest| manifest.base_generation)
|
||||
.unwrap_or(0),
|
||||
retained_tail_ranges,
|
||||
index_runs: previous
|
||||
.as_ref()
|
||||
.map(|manifest| manifest.index_runs.clone())
|
||||
.unwrap_or_default(),
|
||||
checkpoints: checkpoint_rows,
|
||||
committed_shard_sequence: checkpoint.shard_committed_sequence,
|
||||
};
|
||||
segment::install_manifest(&self.paths, &manifest, self.manifest_retain, &self.counters)?;
|
||||
|
||||
if sealed_active {
|
||||
segment::unlink_sealed_journal(&active_path, &self.paths, &self.counters)?;
|
||||
self.journal = Journal::create(
|
||||
&self.paths.active(),
|
||||
fresh_id(b"journal"),
|
||||
checkpoint.shard_committed_sequence.saturating_add(1),
|
||||
self.shard,
|
||||
self.root_uuid,
|
||||
self.preallocate_bytes,
|
||||
now_micros(),
|
||||
Arc::clone(&self.counters),
|
||||
)?;
|
||||
}
|
||||
|
||||
// Only after both the checkpoint and its authoritative manifest are
|
||||
// fenced, so every retained manifest keeps a retained referent.
|
||||
crate::checkpoint::prune(
|
||||
&self.paths.checkpoints(),
|
||||
self.checkpoint_retain,
|
||||
|
|
@ -480,7 +592,9 @@ impl ShardDrive {
|
|||
/// Every frame durably reachable in this shard right now, in
|
||||
/// `shard_sequence` order, each proved complete by the same readers
|
||||
/// recovery uses.
|
||||
fn durable_facts(&self) -> Result<Vec<FrameFacts>, StoreError> {
|
||||
fn durable_facts(
|
||||
&self,
|
||||
) -> Result<Vec<(FrameFacts, recovery::RecoveredPayloadFacts)>, StoreError> {
|
||||
let mut facts = Vec::new();
|
||||
if let Some((manifest, _)) =
|
||||
segment::load_manifest_with_fallback(&self.paths, &self.root_uuid)?
|
||||
|
|
@ -572,331 +686,31 @@ impl ShardDrive {
|
|||
/// prevent. A crash between the manifest install and the active-name
|
||||
/// unlink produced `0,1,2,3,0,1,2,3`.
|
||||
///
|
||||
/// The rule this function now follows is structural: every production
|
||||
/// entry point in `recovery.rs` that belongs at this layer is called, and
|
||||
/// the two that do not are named below with the reason.
|
||||
///
|
||||
/// | `recovery.rs` entry point | here |
|
||||
/// |---|---|
|
||||
/// | `resolve_manifest` | step 2, with `SegmentFooterValidator` |
|
||||
/// | `load_checkpoint` / `checkpoint_for_recovery` | step 3 |
|
||||
/// | `validate_journal_binding` | every segment and the active journal |
|
||||
/// | `classify_active_journal` / `complete_interrupted_seal` | scope 3.4 |
|
||||
/// | `recover_journal_tail` | steps 5 to 7 (`journal::scan_journal` + `journal::quarantine_tail`) |
|
||||
/// | `verify_shard_sequence` / `verify_repo_chain` | step 9 |
|
||||
/// | `promote_receipt_visibility` | step 10 |
|
||||
/// | `tail_outcome` | **not applicable.** It maps a scan to the frozen `RecoveryOutcome`; this seam returns the raw adoption set and A3's matrix does the oracle comparison itself. Calling it here would compute a verdict nothing reads. |
|
||||
/// | `index_adopted_frames` | **not applicable.** The drive publishes no index — that is B1's `engine.rs`. Over scripted payloads `FrameFacts::objects` is empty, so the call would insert nothing and assert nothing. |
|
||||
///
|
||||
/// What this function adds is the ordering between them and the
|
||||
/// `DriveRecovery` projection, and nothing else.
|
||||
/// The rule is now stronger and simpler: this function makes exactly one
|
||||
/// call to [`recovery::recover_shard`], the same non-feature-gated entry
|
||||
/// point the engine uses. It adds only the scripted-payload extractor and
|
||||
/// the `DriveRecovery` summary projection. Manifest/checkpoint selection,
|
||||
/// journal classification, replay, sequence verification, receipts,
|
||||
/// index layering, and retained generation ownership have no drive-local
|
||||
/// implementation to drift.
|
||||
pub fn reopen_through_recovery(root: &Path, shard: u16) -> Result<DriveRecovery, StoreError> {
|
||||
let layout = RootLayout::new(root);
|
||||
// Step 1: FORMAT, then the lock. Read first so an unopenable root is
|
||||
// refused before a lock file is created inside it.
|
||||
let marker = segment::read_format(&layout)?;
|
||||
if shard >= marker.shard_count {
|
||||
return Err(StoreError::FormatMismatch(format!(
|
||||
"shard {shard} is outside the root's frozen topology of {} shards",
|
||||
marker.shard_count
|
||||
)));
|
||||
}
|
||||
let root_uuid = marker.root_uuid;
|
||||
let _lock = segment::lock_root(&layout)?;
|
||||
let paths = layout.shard(shard);
|
||||
let counters = Arc::new(DurabilityCounters::default());
|
||||
let mut report = recovery::ShardRecoveryReport::new(shard);
|
||||
|
||||
// --- step 2: CURRENT, then the manifest fallback --------------------
|
||||
//
|
||||
// Through `recovery::resolve_manifest` rather than
|
||||
// `segment::load_manifest_with_fallback`: the two answer the same
|
||||
// selection question, but only the former validates a referenced
|
||||
// segment *beyond existence*, and only the former distinguishes "the
|
||||
// pointer does not validate" from "the pointer validates and its
|
||||
// referent does not". `recovery_manifest.rs` asserts the two agree.
|
||||
let selection = recovery::resolve_manifest(
|
||||
&paths,
|
||||
&root_uuid,
|
||||
&recovery::SegmentFooterValidator::new(root_uuid),
|
||||
DRIVE_MANIFEST_CANDIDATES,
|
||||
)?;
|
||||
let manifest = match selection {
|
||||
Some(selection) => {
|
||||
report.manifest_generation = Some(selection.generation);
|
||||
report.manifest_source = Some(selection.source.clone());
|
||||
Some(selection)
|
||||
}
|
||||
// No generation validated. Emptiness and total corruption are
|
||||
// different facts and are never collapsed: a shard that has never
|
||||
// sealed has no manifests and is healthy, while one whose every
|
||||
// generation fails validation must refuse to open rather than
|
||||
// silently behave like a fresh shard.
|
||||
None if segment::list_manifest_generations(&paths)?.is_empty() => None,
|
||||
None => {
|
||||
return Err(StoreError::Corruption(format!(
|
||||
"shard {shard} has manifest generations but none of the newest \
|
||||
{DRIVE_MANIFEST_CANDIDATES} validate; refusing to open rather \
|
||||
than treating a corrupt shard as an empty one"
|
||||
)))
|
||||
}
|
||||
let payload_facts = DrivePayloadFacts;
|
||||
let config = recovery::RecoveryConfig {
|
||||
max_manifest_candidates: DRIVE_MANIFEST_CANDIDATES,
|
||||
manifest_retain: 2,
|
||||
checkpoint_retain: DRIVE_CHECKPOINT_RETAIN,
|
||||
journal_preallocate_bytes: DRIVE_PREALLOCATE_BYTES,
|
||||
terminal_status_grace_micros: DRIVE_TERMINAL_STATUS_GRACE_MICROS,
|
||||
max_active_index_entries: 4_000_000,
|
||||
max_active_index_bytes: 512 * 1024 * 1024,
|
||||
max_index_runs: 64,
|
||||
max_open_index_runs: 32,
|
||||
max_replay_frames: u64::MAX,
|
||||
max_replay_bytes: u64::MAX,
|
||||
payload_facts: &payload_facts,
|
||||
projection_recovery_resolver: None,
|
||||
};
|
||||
let used_manifest_fallback = matches!(
|
||||
report.manifest_source,
|
||||
Some(recovery::ManifestSource::Fallback { .. })
|
||||
);
|
||||
|
||||
// --- step 3: the checkpoint -----------------------------------------
|
||||
//
|
||||
// Three outcomes, kept distinct by the type: no generations at all is
|
||||
// a shard that has not checkpointed yet and replays from the start;
|
||||
// at least one validates and bounds the replay; every generation
|
||||
// failing is an explicit offline-rebuild refusal and never an
|
||||
// unbounded scan.
|
||||
let load = recovery::load_checkpoint(
|
||||
&paths.checkpoints(),
|
||||
&root_uuid,
|
||||
shard,
|
||||
DRIVE_CHECKPOINT_RETAIN,
|
||||
)?;
|
||||
let checkpoint =
|
||||
match recovery::checkpoint_for_recovery(load, &root_uuid, shard, &mut report) {
|
||||
Some(checkpoint) => checkpoint,
|
||||
None => {
|
||||
debug_assert!(report.offline_rebuild_required);
|
||||
return Err(StoreError::RecoveryRequired);
|
||||
}
|
||||
};
|
||||
// `Checkpoint::empty` also reports sequence 0, and sequence 0 is a
|
||||
// legitimate committed sequence, so "was a checkpoint loaded" is read
|
||||
// off the report — which only the `Loaded` branch sets — and never
|
||||
// off the sequence number.
|
||||
let checkpointed = report.checkpoint_sequence.is_some();
|
||||
let committed = checkpoint.shard_committed_sequence;
|
||||
|
||||
let mut adopted_shard_sequences: Vec<u64> = Vec::new();
|
||||
// Only the frames actually replayed are re-verified. What a durable
|
||||
// checkpoint already accounts for was verified when it was adopted;
|
||||
// re-deriving it is what makes replay unbounded, which plan section
|
||||
// 5.3 forbids.
|
||||
let mut replayed: Vec<FrameFacts> = Vec::new();
|
||||
if checkpointed {
|
||||
let mut accounted: Vec<u64> = checkpoint
|
||||
.receipts
|
||||
.iter()
|
||||
.map(|receipt| receipt.shard_sequence)
|
||||
.filter(|sequence| *sequence <= committed)
|
||||
.collect();
|
||||
accounted.sort_unstable();
|
||||
adopted_shard_sequences.extend(accounted);
|
||||
}
|
||||
|
||||
// --- step 4: sealed segments named by the manifest, in order --------
|
||||
if let Some(selection) = &manifest {
|
||||
for range in &selection.manifest.retained_tail_ranges {
|
||||
let path = paths.segments().join(&range.filename);
|
||||
let reader = segment::SegmentReader::open(&path, &root_uuid)?;
|
||||
// The footer proves the *offset table* is intact. It says
|
||||
// nothing about the frames, and nothing at all about which
|
||||
// shard the file belongs to — so both are established here,
|
||||
// from the journal header the sealed file still carries.
|
||||
recovery::validate_journal_binding(
|
||||
&reader.journal_header()?,
|
||||
&root_uuid,
|
||||
shard,
|
||||
Some(&reader.footer().journal_id),
|
||||
None,
|
||||
)
|
||||
.map_err(StoreError::from)?;
|
||||
|
||||
// A segment wholly at or below the checkpoint is already
|
||||
// accounted for by it; step 4 replays segments *above* the
|
||||
// checkpoint.
|
||||
if checkpointed && range.last_shard_sequence <= committed {
|
||||
continue;
|
||||
}
|
||||
|
||||
let sequences: Vec<u64> = reader
|
||||
.footer()
|
||||
.offsets
|
||||
.iter()
|
||||
.map(|(sequence, _, _)| *sequence)
|
||||
.collect();
|
||||
for sequence in sequences {
|
||||
if checkpointed && sequence <= committed {
|
||||
continue;
|
||||
}
|
||||
// `SegmentReader::read_frame` re-reads the bytes and
|
||||
// decodes them through `format::verify_complete` against
|
||||
// this file's `journal_id`. Presence in the offset table
|
||||
// is not evidence of anything.
|
||||
let frame = reader.read_frame(sequence)?;
|
||||
adopted_shard_sequences.push(sequence);
|
||||
replayed.push(drive_frame_facts(&frame)?);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- steps 5 to 7: the active journal -------------------------------
|
||||
//
|
||||
// A shard with no active journal is not an error — a crash between
|
||||
// sealing and opening the next journal leaves exactly that.
|
||||
let mut tail_stop_offset = None;
|
||||
let mut quarantined_bytes = 0;
|
||||
if let Some(path) = active_journal_path(&paths)? {
|
||||
let file = File::open(&path)?;
|
||||
let mut header_bytes = [0u8; JOURNAL_HEADER_LEN];
|
||||
sys::pread_exact(&file, 0, &mut header_bytes)?;
|
||||
let header = JournalHeader::decode(&header_bytes)?;
|
||||
// `root_uuid` catches a file copied in from another instance;
|
||||
// `shard_index` catches one moved between shards of this root,
|
||||
// which no digest in the file can detect on its own. The sequence
|
||||
// bound is what is covered *so far* — the checkpoint or the
|
||||
// segments above it — not the checkpoint alone, because a journal
|
||||
// opened after a rotation legitimately starts above the
|
||||
// checkpoint's committed sequence.
|
||||
let covered_through = adopted_shard_sequences
|
||||
.last()
|
||||
.copied()
|
||||
.or(checkpointed.then_some(committed));
|
||||
recovery::validate_journal_binding(&header, &root_uuid, shard, None, covered_through)
|
||||
.map_err(StoreError::from)?;
|
||||
|
||||
// Scope 3.4: is this a live journal, or the residue of a seal that
|
||||
// completed through the manifest install and lost only its final
|
||||
// unlink? The two are opposite errors — replaying a sealed journal
|
||||
// duplicates every frame in it, and treating a live one as sealed
|
||||
// drops every frame the manifest does not yet name — so the
|
||||
// decision is A2's `classify_active_journal` and never an
|
||||
// ordering assumption here.
|
||||
let disposition = match &manifest {
|
||||
Some(selection) => recovery::classify_active_journal(
|
||||
&paths,
|
||||
&selection.manifest,
|
||||
&header.journal_id,
|
||||
&root_uuid,
|
||||
)?,
|
||||
// No manifest generation validated, so nothing can prove a
|
||||
// seal completed. The journal is the only authority there is.
|
||||
None => ActiveJournalDisposition::Replay,
|
||||
};
|
||||
report.active_journal = Some(disposition.clone());
|
||||
|
||||
match disposition {
|
||||
ActiveJournalDisposition::AlreadySealed { .. } => {
|
||||
// The frames are already in the adopted set, taken from
|
||||
// the manifest above. Finish the seal and do not scan.
|
||||
drop(file);
|
||||
recovery::complete_interrupted_seal(&path, &paths, &counters)?;
|
||||
}
|
||||
ActiveJournalDisposition::Replay => {
|
||||
// Step 5 resumes at the checkpoint offset, and only when
|
||||
// the checkpoint names *this* journal: an offset into a
|
||||
// journal that has since rotated is meaningless, which is
|
||||
// why the checkpoint records the identity alongside it.
|
||||
let from = if checkpointed && checkpoint.active_journal_id == header.journal_id
|
||||
{
|
||||
checkpoint
|
||||
.active_journal_offset
|
||||
.max(JOURNAL_HEADER_LEN as u64)
|
||||
} else {
|
||||
JOURNAL_HEADER_LEN as u64
|
||||
};
|
||||
let (scan, record) = recovery::recover_journal_tail(
|
||||
&layout.quarantine_dir(),
|
||||
&file,
|
||||
&header,
|
||||
from,
|
||||
&counters,
|
||||
)?;
|
||||
// Both halves of the record, from the one call that wrote
|
||||
// it. The byte count without the path is evidence nobody
|
||||
// can find, which is what `report.quarantined` being
|
||||
// unsettable amounted to.
|
||||
quarantined_bytes = record.as_ref().map(|r| r.bytes).unwrap_or(0);
|
||||
report.quarantined = record.map(|r| r.path);
|
||||
for scanned in &scan.frames {
|
||||
let mut frame_bytes = vec![
|
||||
0u8;
|
||||
usize::try_from(scanned.len).map_err(|_| {
|
||||
StoreError::from(crate::format::FrameError::Length)
|
||||
})?
|
||||
];
|
||||
sys::pread_exact(&file, scanned.offset, &mut frame_bytes)?;
|
||||
let frame = Frame::decode(&frame_bytes, &header.journal_id)?;
|
||||
adopted_shard_sequences.push(scanned.shard_sequence);
|
||||
replayed.push(drive_frame_facts(&frame)?);
|
||||
}
|
||||
// A stop is only a *tail* when something was actually
|
||||
// discarded: a clean journal always stops at the first
|
||||
// unwritten byte, and reporting that as a torn tail would
|
||||
// make every ordinary crash look like a torn one. Each
|
||||
// stop reason is matched explicitly — no catch-all, per
|
||||
// the scope 5 charter.
|
||||
tail_stop_offset = match scan.stop {
|
||||
TailStop::EndOfPreallocation => None,
|
||||
TailStop::NotAFrame => (quarantined_bytes > 0).then_some(scan.stop_offset),
|
||||
TailStop::Incomplete(_) => Some(scan.stop_offset),
|
||||
TailStop::ReadError => Some(scan.stop_offset),
|
||||
};
|
||||
report.tail_stop = Some(scan.stop);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- step 9: both halves, each with its own error type --------------
|
||||
//
|
||||
// The shard domain is anchored on the checkpoint when there is one and
|
||||
// on the first replayed frame otherwise; the repository domain is
|
||||
// anchored on the checkpoint's catalog, extended by the first replayed
|
||||
// frame of any repository the catalog does not already know.
|
||||
verify_replayed_sequences(
|
||||
&replayed,
|
||||
checkpointed.then(|| committed.saturating_add(1)),
|
||||
&checkpoint.catalog,
|
||||
)?;
|
||||
|
||||
// --- step 10: receipt visibility ------------------------------------
|
||||
//
|
||||
// Over the checkpointed receipts and over one synthesized per replayed
|
||||
// frame, so the promotion runs against both halves: an operation whose
|
||||
// first visibility was durably captured must not move, and one that
|
||||
// was only replayed must be promoted to recovery publication time.
|
||||
let recovery_publication_micros = now_micros();
|
||||
let mut receipts = checkpoint.receipts.clone();
|
||||
for facts in &replayed {
|
||||
let mut record = receipt_for(facts, recovery_publication_micros)?;
|
||||
// Not durably captured: this frame was replayed, not restored
|
||||
// from a checkpoint. `None` is what makes step 10 promote it.
|
||||
record.first_receipt_visibility_micros = None;
|
||||
receipts.push(record);
|
||||
}
|
||||
report.promotions = recovery::promote_receipt_visibility(
|
||||
&mut receipts,
|
||||
recovery_publication_micros,
|
||||
DRIVE_TERMINAL_STATUS_GRACE_MICROS,
|
||||
)?;
|
||||
|
||||
report.adopted_shard_sequences = adopted_shard_sequences;
|
||||
report.quarantined_bytes = quarantined_bytes;
|
||||
debug_assert_eq!(
|
||||
report.quarantined.is_some(),
|
||||
quarantined_bytes > 0,
|
||||
"a quarantine record and a non-zero byte count are the same fact \
|
||||
and are produced by the same call; they can never disagree"
|
||||
);
|
||||
// Scope 3.8 step 12: computed, never a default.
|
||||
report.ready = !report.offline_rebuild_required;
|
||||
|
||||
debug_assert_eq!(
|
||||
used_manifest_fallback,
|
||||
matches!(
|
||||
report.manifest_source,
|
||||
Some(recovery::ManifestSource::Fallback { .. })
|
||||
),
|
||||
"the fallback decision and the reported manifest source must agree"
|
||||
);
|
||||
Ok(DriveRecovery::from_report(report, tail_stop_offset))
|
||||
recovery::recover_shard(root, shard, &config).map(DriveRecovery::from_recovered)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -909,6 +723,7 @@ impl ShardDrive {
|
|||
/// transaction.
|
||||
fn receipt_for(
|
||||
facts: &FrameFacts,
|
||||
recovered: &recovery::RecoveredPayloadFacts,
|
||||
first_visible_micros: i64,
|
||||
) -> Result<crate::checkpoint::ReceiptRecord, StoreError> {
|
||||
Ok(crate::checkpoint::ReceiptRecord {
|
||||
|
|
@ -918,7 +733,8 @@ fn receipt_for(
|
|||
repo_sequence: facts.repo_sequence,
|
||||
shard_sequence: facts.shard_sequence,
|
||||
current_authority: facts.current_authority,
|
||||
objects_new: facts.objects.len() as u64,
|
||||
refs: recovered.applied_refs.clone(),
|
||||
objects_new: recovered.objects_new,
|
||||
retry_until_micros: facts.retry_until_micros,
|
||||
first_receipt_visibility_micros: Some(first_visible_micros),
|
||||
// Recomputed by `recovery::promote_receipt_visibility`, which may only
|
||||
|
|
@ -980,33 +796,7 @@ impl PayloadFacts for DrivePayloadFacts {
|
|||
fn extend(&self, facts: &mut FrameFacts, payload: &[u8]) -> Result<(), StoreError> {
|
||||
let namespace = *facts.namespace.as_bytes();
|
||||
match TransactionFramePayloadV1::decode_canonical(payload) {
|
||||
Ok(decoded) => {
|
||||
let extracted = decoded.facts()?;
|
||||
if extracted.repo_id.0 != namespace {
|
||||
return Err(StoreError::Corruption(format!(
|
||||
"frame header names namespace {} but its payload names repository {}",
|
||||
facts.namespace.to_hex(),
|
||||
hex::encode(extracted.repo_id.0)
|
||||
)));
|
||||
}
|
||||
if extracted.repo_sequence != facts.repo_sequence {
|
||||
return Err(StoreError::Corruption(format!(
|
||||
"frame header carries repo_sequence {} but its payload carries {}",
|
||||
facts.repo_sequence, extracted.repo_sequence
|
||||
)));
|
||||
}
|
||||
facts.event_digest = extracted.event_digest;
|
||||
facts.previous_event_digest = extracted.previous_event_digest;
|
||||
facts.creates_namespace = decoded.repository_create.is_some();
|
||||
facts.genesis_authority = decoded
|
||||
.repository_create
|
||||
.as_ref()
|
||||
.map(|create| create.genesis_authority)
|
||||
.unwrap_or(extracted.old_authority);
|
||||
facts.current_authority = extracted.new_authority;
|
||||
facts.retry_until_micros = extracted.retry_until_micros;
|
||||
Ok(())
|
||||
}
|
||||
Ok(_) => recovery::CanonicalPayloadFacts.extend(facts, payload),
|
||||
Err(_) => {
|
||||
facts.event_digest = synthetic_event_digest(&namespace, Some(facts.repo_sequence));
|
||||
facts.previous_event_digest =
|
||||
|
|
@ -1019,68 +809,28 @@ impl PayloadFacts for DrivePayloadFacts {
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn drive_frame_facts(frame: &Frame) -> Result<FrameFacts, StoreError> {
|
||||
let mut facts = FrameFacts::from_header(&frame.header);
|
||||
DrivePayloadFacts.extend(&mut facts, &frame.payload)?;
|
||||
Ok(facts)
|
||||
}
|
||||
fn permits_implicit_namespace_anchor(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// Recovery step 9 for the driven shard, through A2's two verifiers.
|
||||
///
|
||||
/// They return distinct, non-interchangeable fault types by construction, so a
|
||||
/// shard-domain fault and a repository-domain fault can never arrive as the
|
||||
/// same error — which is what scope 4-A2 deliverable 4 requires and what a
|
||||
/// single hand-rolled contiguity loop here would have destroyed.
|
||||
///
|
||||
/// Both domains need an anchor, and the anchor is stated rather than implied:
|
||||
///
|
||||
/// - `first_expected` is the checkpoint's committed sequence plus one when a
|
||||
/// checkpoint was loaded. Without one there is nothing above which the shard
|
||||
/// sequence is known to be contiguous, so the first replayed frame anchors
|
||||
/// itself; a verifier started at zero would report a gap for every store
|
||||
/// that has ever rotated, and one started at the first frame *with* a
|
||||
/// checkpoint present would miss a hole between the two.
|
||||
/// - `catalog` is the checkpoint's namespace catalog. A repository the catalog
|
||||
/// does not know is anchored on its own first replayed frame, because the
|
||||
/// drive seam has no earlier durable state to chain onto.
|
||||
fn verify_replayed_sequences(
|
||||
replayed: &[FrameFacts],
|
||||
first_expected: Option<u64>,
|
||||
catalog: &NamespaceCatalog,
|
||||
) -> Result<(), StoreError> {
|
||||
let anchor = match first_expected.or_else(|| replayed.first().map(|f| f.shard_sequence)) {
|
||||
Some(anchor) => anchor,
|
||||
None => return Ok(()),
|
||||
};
|
||||
recovery::verify_shard_sequence(replayed, anchor).map_err(StoreError::from)?;
|
||||
|
||||
let mut catalog = catalog.clone();
|
||||
let mut chained: Vec<FrameFacts> = Vec::with_capacity(replayed.len());
|
||||
for facts in replayed {
|
||||
if catalog.get(&facts.namespace).is_none() {
|
||||
catalog.bind(NamespaceRecord {
|
||||
namespace: facts.namespace,
|
||||
genesis_authority: facts.genesis_authority,
|
||||
current_authority: facts.current_authority,
|
||||
lifecycle: NamespaceLifecycle::Active,
|
||||
storage_mode: NamespaceStorageMode::Full,
|
||||
repo_sequence: facts.repo_sequence,
|
||||
previous_event_digest: facts.event_digest,
|
||||
})?;
|
||||
} else {
|
||||
chained.push(facts.clone());
|
||||
fn recovered(&self, payload: &[u8]) -> Result<recovery::RecoveredPayloadFacts, StoreError> {
|
||||
match TransactionFramePayloadV1::decode_canonical(payload) {
|
||||
Ok(_) => recovery::CanonicalPayloadFacts.recovered(payload),
|
||||
Err(_) => Ok(recovery::RecoveredPayloadFacts::default()),
|
||||
}
|
||||
}
|
||||
recovery::verify_repo_chain(&chained, &catalog).map_err(StoreError::from)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The single `*.journal` in a shard's `active/`, if there is one.
|
||||
///
|
||||
/// More than one is a corruption, not a choice to make: only the shard owner
|
||||
/// names anything under its shard, and it opens exactly one journal at a time.
|
||||
fn drive_frame_facts(
|
||||
frame: &Frame,
|
||||
) -> Result<(FrameFacts, recovery::RecoveredPayloadFacts), StoreError> {
|
||||
let mut facts = FrameFacts::from_header(&frame.header);
|
||||
DrivePayloadFacts.extend(&mut facts, &frame.payload)?;
|
||||
let recovered = DrivePayloadFacts.recovered(&frame.payload)?;
|
||||
Ok((facts, recovered))
|
||||
}
|
||||
|
||||
fn active_journal_path(paths: &ShardPaths) -> Result<Option<PathBuf>, StoreError> {
|
||||
let dir = match std::fs::read_dir(paths.active()) {
|
||||
Ok(dir) => dir,
|
||||
|
|
|
|||
|
|
@ -149,13 +149,19 @@ impl IndexKey {
|
|||
}
|
||||
}
|
||||
|
||||
/// Where the frame carrying an object lives.
|
||||
/// Where the certified storage record carrying an object lives.
|
||||
///
|
||||
/// The location names the *frame*, not the object bytes inside it: the frame
|
||||
/// is the unit the format certifies — its trailer certifies the whole — so a
|
||||
/// reader validates the frame and then takes the object out of the validated
|
||||
/// payload. Pointing straight at object bytes would let a reader return bytes
|
||||
/// from a frame it never proved complete.
|
||||
/// For inline transactions the record is a complete journal frame. For an
|
||||
/// adopted projection it is a canonical, digest-bound staged chunk retained
|
||||
/// by the same committed generation. In both cases the location names the
|
||||
/// whole certified record, never the object bytes inside it: a reader
|
||||
/// validates the frame or chunk first and only then extracts the object.
|
||||
/// Pointing straight at object bytes would let a reader return bytes from a
|
||||
/// record it never proved complete.
|
||||
///
|
||||
/// The physical field names remain `frame_offset`/`frame_len` in storage
|
||||
/// version 1. Source kind is resolved from the generation pin in the captured
|
||||
/// `CommittedRoot`, so the packed index bytes do not need a new discriminant.
|
||||
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
|
||||
pub struct IndexLocation {
|
||||
pub segment_generation: u64,
|
||||
|
|
|
|||
|
|
@ -243,13 +243,30 @@ impl Journal {
|
|||
)));
|
||||
}
|
||||
let path = active_dir.join(format!("{first_shard_sequence}.journal"));
|
||||
let mut file = File::options()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.open(&path)?;
|
||||
if path.exists() {
|
||||
let (journal, scan) = Self::open(&path, &root_uuid, Arc::clone(&counters))?;
|
||||
let header = journal.header();
|
||||
if header.shard_index != shard_index
|
||||
|| header.first_shard_sequence != first_shard_sequence
|
||||
|| header.preallocated_len != preallocated_len
|
||||
|| !scan.frames.is_empty()
|
||||
|| scan.stop_offset != JOURNAL_HEADER_LEN as u64
|
||||
{
|
||||
return Err(StoreError::Corruption(format!(
|
||||
"occupied fresh-journal target {} is not the requested empty journal",
|
||||
path.display()
|
||||
)));
|
||||
}
|
||||
sys::fsync_dir(active_dir, &counters)?;
|
||||
return Ok(journal);
|
||||
}
|
||||
|
||||
let header = JournalHeader {
|
||||
// Target-scoped and deterministic: one interrupted creation can leave
|
||||
// at most one invisible construction artifact. Re-entry either reuses
|
||||
// a fully fenced empty journal or reconstructs a partial pre-publish
|
||||
// file in place; it never allocates another temp name.
|
||||
let temporary = active_dir.join(format!(".{first_shard_sequence}.journal.tmp"));
|
||||
let requested_header = JournalHeader {
|
||||
shard_index,
|
||||
root_uuid,
|
||||
journal_id,
|
||||
|
|
@ -257,11 +274,58 @@ impl Journal {
|
|||
preallocated_len,
|
||||
created_at_micros,
|
||||
};
|
||||
sys::preallocate(&file, preallocated_len)?;
|
||||
let encoded = header.encode()?;
|
||||
sys::seek_to(&mut file, 0)?;
|
||||
sys::write_vectored_all(&mut file, &[IoSlice::new(&encoded)], &counters)?;
|
||||
sys::fdatasync(&file, &counters)?;
|
||||
let existed = temporary.exists();
|
||||
let mut file = File::options()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(false)
|
||||
.open(&temporary)?;
|
||||
let decoded_header = if existed {
|
||||
let mut bytes = [0u8; JOURNAL_HEADER_LEN];
|
||||
sys::pread_exact(&file, 0, &mut bytes)
|
||||
.ok()
|
||||
.and_then(|()| JournalHeader::decode(&bytes).ok())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let header = if let Some(header) = decoded_header {
|
||||
if header.root_uuid != root_uuid
|
||||
|| header.shard_index != shard_index
|
||||
|| header.first_shard_sequence != first_shard_sequence
|
||||
|| header.preallocated_len != preallocated_len
|
||||
{
|
||||
return Err(StoreError::Corruption(format!(
|
||||
"fresh-journal temp {} belongs to a different target",
|
||||
temporary.display()
|
||||
)));
|
||||
}
|
||||
let scan = scan_journal(&file, &header, JOURNAL_HEADER_LEN as u64);
|
||||
if !scan.frames.is_empty() || scan.stop_offset != JOURNAL_HEADER_LEN as u64 {
|
||||
return Err(StoreError::Corruption(format!(
|
||||
"fresh-journal temp {} is not empty",
|
||||
temporary.display()
|
||||
)));
|
||||
}
|
||||
// Re-fence before publication. Exact bytes in page cache do not
|
||||
// prove the previous process reached its fdatasync.
|
||||
sys::fdatasync(&file, &counters)?;
|
||||
header
|
||||
} else {
|
||||
// The deterministic temp is unpublished and target-scoped. A bad
|
||||
// or partial header can only be an interrupted construction for
|
||||
// this missing final name, so finish that construction in place.
|
||||
sys::truncate(&file, 0, &counters)?;
|
||||
sys::preallocate(&file, preallocated_len)?;
|
||||
let encoded = requested_header.encode()?;
|
||||
sys::seek_to(&mut file, 0)?;
|
||||
sys::write_vectored_all(&mut file, &[IoSlice::new(&encoded)], &counters)?;
|
||||
sys::fdatasync(&file, &counters)?;
|
||||
requested_header
|
||||
};
|
||||
if let Err(error) = sys::rename_noreplace(&temporary, &path) {
|
||||
return Err(error.into());
|
||||
}
|
||||
sys::fsync_dir(active_dir, &counters)?;
|
||||
|
||||
Ok(Self {
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@
|
|||
//! snapshots and events, never to these bytes.
|
||||
|
||||
pub mod checkpoint;
|
||||
pub mod completion;
|
||||
pub mod engine;
|
||||
pub mod failpoints;
|
||||
pub mod format;
|
||||
|
|
@ -40,6 +41,7 @@ pub mod index;
|
|||
pub mod journal;
|
||||
pub mod options;
|
||||
pub mod recovery;
|
||||
pub mod roots;
|
||||
pub mod segment;
|
||||
pub mod snapshot;
|
||||
pub mod staging;
|
||||
|
|
@ -57,9 +59,21 @@ mod sys;
|
|||
#[cfg(feature = "store-internals")]
|
||||
pub mod drive;
|
||||
|
||||
pub use completion::{CompletionWaiter, SharedCompletion};
|
||||
pub use engine::{CheckpointLease, StoreEngine};
|
||||
pub use options::{StoreDirectoryAttributes, StoreOptions};
|
||||
pub use roots::{
|
||||
CommittedRoot, GenerationId, IndexDeltaLayer, LayeredObjectIndex, OperationKey,
|
||||
OperationStatusMetricSnapshot, OperationStatusMetrics, OperationStatusRoot, PinnedFile,
|
||||
ProjectionArtifactFormat, ReceiptTombstone, RepoState, RetainedGeneration, RetainedIndexRun,
|
||||
RetainedObjectSource, RetainedProjectionArtifact, RetainedReceipt, RetainedSegment,
|
||||
RetainedTail, ShardSubtree, StatusEntry, StatusPhase, StatusReservation, TerminalStatusEntry,
|
||||
};
|
||||
pub use snapshot::RepoSnapshot;
|
||||
pub use staging::{
|
||||
ProjectionAdoption, ProjectionAdoptionOutcome, ProjectionAdoptionResolution,
|
||||
ProjectionArtifact, RecoveredProjectionOutcome, RecoveredProjectionResolution,
|
||||
};
|
||||
pub use transaction::{StagedObject, ValidatedTransaction, ValidatedTransactionBuilder};
|
||||
pub use types::{
|
||||
AppliedRef, CommitEvidenceSigner, CommitReceipt, DurabilityCounterSnapshot, DurabilityCounters,
|
||||
|
|
|
|||
|
|
@ -74,6 +74,24 @@ pub struct StoreOptions {
|
|||
pub max_refs_per_transaction: u32,
|
||||
pub max_projection_objects: u64,
|
||||
pub max_projection_bytes: u64,
|
||||
pub max_projection_chunks: u32,
|
||||
/// Maximum number of transient entries in `OperationStatusRoot`.
|
||||
pub max_status_entries: u64,
|
||||
|
||||
// --- projection staging ---------------------------------------------
|
||||
pub staging_max_sessions_per_principal: u32,
|
||||
pub staging_max_sessions_global: u32,
|
||||
pub staging_max_objects_per_principal: u64,
|
||||
pub staging_max_objects_global: u64,
|
||||
pub staging_max_bytes_per_principal: u64,
|
||||
pub staging_max_bytes_global: u64,
|
||||
pub staging_max_files_per_session: u64,
|
||||
pub staging_max_files_per_principal: u64,
|
||||
pub staging_max_files_global: u64,
|
||||
pub staging_session_max_age_micros: i64,
|
||||
pub staging_finalize_margin_micros: i64,
|
||||
pub minimum_projection_transfer_bytes_per_second: u64,
|
||||
pub staging_max_compaction_debt_bytes: u64,
|
||||
|
||||
// --- retention and time ---------------------------------------------
|
||||
pub max_retry_window_micros: i64,
|
||||
|
|
@ -116,6 +134,24 @@ impl Default for StoreOptions {
|
|||
max_refs_per_transaction: 4_096,
|
||||
max_projection_objects: 100_000_000,
|
||||
max_projection_bytes: 1024 * 1024 * 1024 * 1024,
|
||||
max_projection_chunks: 1_000_000,
|
||||
max_status_entries: 1_000_000,
|
||||
staging_max_sessions_per_principal: 2,
|
||||
staging_max_sessions_global: 16,
|
||||
staging_max_objects_per_principal: 200_000_000,
|
||||
staging_max_objects_global: 800_000_000,
|
||||
staging_max_bytes_per_principal: 2 * 1024 * 1024 * 1024 * 1024,
|
||||
staging_max_bytes_global: 8 * 1024 * 1024 * 1024 * 1024,
|
||||
staging_max_files_per_session: 1_000_002,
|
||||
staging_max_files_per_principal: 2_000_000,
|
||||
staging_max_files_global: 8_000_000,
|
||||
// A maximal 1 TiB projection at the supported 16 MiB/s floor
|
||||
// takes a little over 18 hours. The 24-hour session horizon
|
||||
// leaves room for the five-minute finalize margin.
|
||||
staging_session_max_age_micros: 24 * 60 * 60 * 1_000_000,
|
||||
staging_finalize_margin_micros: 5 * 60 * 1_000_000,
|
||||
minimum_projection_transfer_bytes_per_second: 16 * 1024 * 1024,
|
||||
staging_max_compaction_debt_bytes: 8 * 1024 * 1024 * 1024 * 1024,
|
||||
// Plan §7 stage 3 initial hosted defaults: +/-60 s skew, 1 s timer
|
||||
// resolution, at least 121 s replay retention.
|
||||
max_retry_window_micros: 900_000_000,
|
||||
|
|
@ -191,12 +227,100 @@ impl StoreOptions {
|
|||
self.max_objects_per_transaction >= 1,
|
||||
"max_objects_per_transaction must be nonzero"
|
||||
);
|
||||
require!(
|
||||
self.max_projection_objects >= 1,
|
||||
"max_projection_objects must be nonzero"
|
||||
);
|
||||
require!(
|
||||
self.max_projection_bytes >= 1,
|
||||
"max_projection_bytes must be nonzero"
|
||||
);
|
||||
require!(
|
||||
self.max_projection_chunks >= 1,
|
||||
"max_projection_chunks must be nonzero"
|
||||
);
|
||||
require!(
|
||||
self.max_status_entries >= 1,
|
||||
"max_status_entries must be nonzero"
|
||||
);
|
||||
require!(
|
||||
self.max_refs_per_transaction >= 1
|
||||
&& (self.max_refs_per_transaction as usize) <= levcs_protocol::v2::MAX_REF_UPDATES,
|
||||
"max_refs_per_transaction must be in 1..={}",
|
||||
levcs_protocol::v2::MAX_REF_UPDATES
|
||||
);
|
||||
require!(
|
||||
self.staging_max_sessions_per_principal >= 1
|
||||
&& self.staging_max_sessions_per_principal <= self.staging_max_sessions_global,
|
||||
"staging session limits must be nonzero and per-principal <= global"
|
||||
);
|
||||
require!(
|
||||
self.staging_max_objects_per_principal >= self.max_projection_objects
|
||||
&& self.staging_max_objects_per_principal <= self.staging_max_objects_global,
|
||||
"staging object limits must admit one maximal projection and \
|
||||
per-principal must be <= global"
|
||||
);
|
||||
require!(
|
||||
self.staging_max_bytes_per_principal >= self.max_projection_bytes
|
||||
&& self.staging_max_bytes_per_principal <= self.staging_max_bytes_global,
|
||||
"staging byte limits must admit one maximal projection and \
|
||||
per-principal must be <= global"
|
||||
);
|
||||
require!(
|
||||
self.staging_max_files_per_session >= u64::from(self.max_projection_chunks) + 2
|
||||
&& self.staging_max_files_per_session <= self.staging_max_files_per_principal
|
||||
&& self.staging_max_files_per_principal <= self.staging_max_files_global,
|
||||
"staging file limits must admit one maximal projection's chunks, \
|
||||
session record, and manifest, and \
|
||||
per-session <= per-principal <= global"
|
||||
);
|
||||
require!(
|
||||
self.staging_session_max_age_micros > 0,
|
||||
"staging_session_max_age_micros must be positive"
|
||||
);
|
||||
require!(
|
||||
self.staging_finalize_margin_micros >= 0,
|
||||
"staging_finalize_margin_micros must not be negative"
|
||||
);
|
||||
require!(
|
||||
self.minimum_projection_transfer_bytes_per_second >= 1,
|
||||
"minimum_projection_transfer_bytes_per_second must be nonzero"
|
||||
);
|
||||
require!(
|
||||
self.staging_max_compaction_debt_bytes >= self.max_projection_bytes,
|
||||
"staging_max_compaction_debt_bytes must admit one maximal projection"
|
||||
);
|
||||
|
||||
let transfer_seconds = self
|
||||
.max_projection_bytes
|
||||
.checked_add(self.minimum_projection_transfer_bytes_per_second - 1)
|
||||
.ok_or_else(|| {
|
||||
StoreError::InvalidConfiguration(
|
||||
"projection transfer ceiling division overflows u64".into(),
|
||||
)
|
||||
})?
|
||||
/ self.minimum_projection_transfer_bytes_per_second;
|
||||
let transfer_micros = transfer_seconds.checked_mul(1_000_000).ok_or_else(|| {
|
||||
StoreError::InvalidConfiguration("projection transfer duration overflows u64".into())
|
||||
})?;
|
||||
let transfer_micros = i64::try_from(transfer_micros).map_err(|_| {
|
||||
StoreError::InvalidConfiguration(
|
||||
"projection transfer duration does not fit signed microseconds".into(),
|
||||
)
|
||||
})?;
|
||||
let required_session_age = transfer_micros
|
||||
.checked_add(self.staging_finalize_margin_micros)
|
||||
.ok_or_else(|| {
|
||||
StoreError::InvalidConfiguration(
|
||||
"projection transfer duration plus finalize margin overflows i64".into(),
|
||||
)
|
||||
})?;
|
||||
require!(
|
||||
required_session_age <= self.staging_session_max_age_micros,
|
||||
"max projection requires {required_session_age}us at the supported transfer floor, \
|
||||
exceeding staging_session_max_age_micros ({})",
|
||||
self.staging_session_max_age_micros
|
||||
);
|
||||
|
||||
require!(
|
||||
self.clock_skew_micros > 0,
|
||||
|
|
@ -323,6 +447,46 @@ mod tests {
|
|||
assert!(o.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_status_or_staging_bounds_are_refused() {
|
||||
let mut o = valid();
|
||||
o.max_status_entries = 0;
|
||||
assert!(o.validate().is_err());
|
||||
|
||||
let mut o = valid();
|
||||
o.staging_max_sessions_global = 0;
|
||||
assert!(o.validate().is_err());
|
||||
|
||||
let mut o = valid();
|
||||
o.minimum_projection_transfer_bytes_per_second = 0;
|
||||
assert!(o.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn staging_feasibility_is_checked_before_startup() {
|
||||
let mut o = valid();
|
||||
o.staging_session_max_age_micros = o.staging_finalize_margin_micros;
|
||||
assert!(
|
||||
o.validate().is_err(),
|
||||
"a session too short for one maximal transfer must be refused"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn staging_nested_bounds_must_be_monotonic() {
|
||||
let mut o = valid();
|
||||
o.staging_max_bytes_per_principal = o.max_projection_bytes - 1;
|
||||
assert!(o.validate().is_err());
|
||||
|
||||
let mut o = valid();
|
||||
o.staging_max_files_per_principal = o.staging_max_files_per_session - 1;
|
||||
assert!(o.validate().is_err());
|
||||
|
||||
let mut o = valid();
|
||||
o.staging_max_files_per_session = u64::from(o.max_projection_chunks) + 1;
|
||||
assert!(o.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shard_routing_is_stable_and_in_range() {
|
||||
for count in [1u16, 2, 4, 7, 256, 1024] {
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -18,7 +18,7 @@ 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;
|
||||
use crate::journal::{Journal, TailScan};
|
||||
use crate::sys;
|
||||
use crate::types::{DurabilityCounters, StoreError};
|
||||
|
||||
|
|
@ -304,6 +304,228 @@ pub fn seal_journal(
|
|||
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<DurabilityCounters>,
|
||||
) -> Result<PathBuf, StoreError> {
|
||||
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.
|
||||
///
|
||||
|
|
@ -364,12 +586,37 @@ pub fn install_manifest(
|
|||
let final_path = paths.manifest(manifest.generation);
|
||||
let tmp = manifests.join(format!("{}.tmp", manifest_filename(manifest.generation)));
|
||||
|
||||
let encoded = manifest.encode()?;
|
||||
// 1, 2
|
||||
write_fenced(&tmp, &manifest.encode()?, counters)?;
|
||||
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);
|
||||
return Err(e.into());
|
||||
if existing != encoded {
|
||||
return Err(StoreError::Corruption(format!(
|
||||
"manifest generation {} already exists with different contents",
|
||||
manifest.generation
|
||||
)));
|
||||
}
|
||||
}
|
||||
// 4
|
||||
sys::fsync_dir(&manifests, counters)?;
|
||||
|
|
|
|||
|
|
@ -1,9 +1,514 @@
|
|||
//! Bounded invisible projection-staging sessions: begin, idempotent chunk-put,
|
||||
//! read-only resolver, seal to `StagedProjectionInstallV1`, abort, expiry, and
|
||||
//! cleanup.
|
||||
//! Bounded invisible projection-staging sessions.
|
||||
//!
|
||||
//! **Owned by B1 NamespaceTxn** (scope 2.1, 6-B1). Empty in D0.
|
||||
//!
|
||||
//! Sealing cannot publish membership. Only `submit(ValidatedTransaction)` may
|
||||
//! adopt a sealed descriptor, so possession of a session ID never authorizes
|
||||
//! publication (plan §4 identity invariant 7, §8).
|
||||
//! B3 owns the storage mechanism. D0-B owns and freezes the adoption seam in
|
||||
//! this file so B1 never has to read B3's on-disk representation directly.
|
||||
|
||||
use std::fmt;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use levcs_core::ObjectId;
|
||||
use levcs_protocol::v2::{
|
||||
ProjectionStageManifestV1, ProjectionStageSessionV1, StagedProjectionInstallV1,
|
||||
};
|
||||
|
||||
use crate::index::{IndexDelta, IndexRun};
|
||||
use crate::roots::{CommittedRoot, RetainedIndexRun, RetainedProjectionArtifact};
|
||||
use crate::types::{NamespaceId, StoreError};
|
||||
|
||||
/// One immutable artifact offered for adoption.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct ProjectionArtifact {
|
||||
pub path: PathBuf,
|
||||
pub digest: ObjectId,
|
||||
pub bytes: u64,
|
||||
}
|
||||
|
||||
/// Everything B1 may inspect while revalidating a sealed staged projection.
|
||||
///
|
||||
/// This is deliberately read-only. Adoption may reject this state but may
|
||||
/// never repair or mutate it.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct ProjectionAdoptionResolution {
|
||||
pub session: ProjectionStageSessionV1,
|
||||
pub manifest: ProjectionStageManifestV1,
|
||||
pub artifacts: Arc<[ProjectionArtifact]>,
|
||||
}
|
||||
|
||||
/// The only three ways ownership of an admitted adoption pin may end.
|
||||
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
|
||||
pub enum ProjectionAdoptionOutcome {
|
||||
Adopted,
|
||||
DefinitivePreAppendFailure,
|
||||
TransferredToRecovery,
|
||||
}
|
||||
|
||||
/// Resolution delivered to staging after production recovery has made the
|
||||
/// final frame authoritative or proved that no complete frame exists.
|
||||
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
|
||||
pub enum RecoveredProjectionOutcome {
|
||||
Committed,
|
||||
ProvedAbsent,
|
||||
}
|
||||
|
||||
/// Recovery's resolution of a staging pin transferred across a poisoned
|
||||
/// process boundary.
|
||||
///
|
||||
/// B3 consumes these notifications before expiry or cleanup may inspect the
|
||||
/// recovered shard.
|
||||
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
|
||||
pub struct RecoveredProjectionResolution {
|
||||
pub session_id: [u8; 16],
|
||||
pub outcome: RecoveredProjectionOutcome,
|
||||
}
|
||||
|
||||
/// Read-only recovery result for one authoritative staged-install frame.
|
||||
///
|
||||
/// A final frame carries only a canonical descriptor, not the installed
|
||||
/// object's locations. Recovery obtains those locations and live file
|
||||
/// ownership from B3 through this value. The vectors are newest-first where
|
||||
/// order is observable by index lookup.
|
||||
///
|
||||
/// The fields remain private so recovery can compose the result into its
|
||||
/// recovered root but cannot mutate staging state or reinterpret B3's on-disk
|
||||
/// representation.
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct RecoveredProjectionArtifacts {
|
||||
descriptor: StagedProjectionInstallV1,
|
||||
index_delta: Arc<IndexDelta>,
|
||||
/// Newest-first; each retained entry is also the lookup run, so lookup
|
||||
/// membership cannot accidentally diverge from physical ownership.
|
||||
retained_index_runs: Arc<[RetainedIndexRun]>,
|
||||
retained_artifacts: Arc<[RetainedProjectionArtifact]>,
|
||||
}
|
||||
|
||||
impl RecoveredProjectionArtifacts {
|
||||
/// Construct the complete physical result of resolving one descriptor.
|
||||
///
|
||||
/// B3 calls this only after mechanically verifying the descriptor against
|
||||
/// the sealed session, manifest, artifact hashes, and index. Identity,
|
||||
/// graph, policy, authority, and federation decisions are deliberately
|
||||
/// absent from this interface.
|
||||
pub(crate) fn new(
|
||||
descriptor: StagedProjectionInstallV1,
|
||||
index_delta: Arc<IndexDelta>,
|
||||
retained_index_runs: Arc<[RetainedIndexRun]>,
|
||||
retained_artifacts: Arc<[RetainedProjectionArtifact]>,
|
||||
) -> Result<Self, StoreError> {
|
||||
let run_entries = retained_index_runs
|
||||
.iter()
|
||||
.try_fold(0u64, |count, retained| {
|
||||
count.checked_add(retained.run().entry_count())
|
||||
})
|
||||
.ok_or_else(|| {
|
||||
StoreError::Corruption(
|
||||
"recovered staged-projection index entry count overflowed".into(),
|
||||
)
|
||||
})?;
|
||||
let delta_entries = u64::try_from(index_delta.len()).map_err(|_| {
|
||||
StoreError::Corruption(
|
||||
"recovered staged-projection delta count does not fit u64".into(),
|
||||
)
|
||||
})?;
|
||||
let total_entries = run_entries.checked_add(delta_entries).ok_or_else(|| {
|
||||
StoreError::Corruption(
|
||||
"recovered staged-projection total index count overflowed".into(),
|
||||
)
|
||||
})?;
|
||||
if total_entries == 0 {
|
||||
return Err(StoreError::Corruption(
|
||||
"committed staged projection resolved without object membership".into(),
|
||||
));
|
||||
}
|
||||
if total_entries != descriptor.object_count {
|
||||
return Err(StoreError::Corruption(format!(
|
||||
"committed staged projection declares {} objects but its recovered \
|
||||
index contains {total_entries}",
|
||||
descriptor.object_count
|
||||
)));
|
||||
}
|
||||
if retained_artifacts.is_empty() {
|
||||
return Err(StoreError::Corruption(
|
||||
"committed staged projection resolved without live artifact ownership".into(),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
descriptor,
|
||||
index_delta,
|
||||
retained_index_runs,
|
||||
retained_artifacts,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn descriptor(&self) -> &StagedProjectionInstallV1 {
|
||||
&self.descriptor
|
||||
}
|
||||
|
||||
pub(crate) fn index_delta(&self) -> &Arc<IndexDelta> {
|
||||
&self.index_delta
|
||||
}
|
||||
|
||||
pub(crate) fn index_runs_newest_first(&self) -> impl ExactSizeIterator<Item = &Arc<IndexRun>> {
|
||||
self.retained_index_runs.iter().map(RetainedIndexRun::run)
|
||||
}
|
||||
|
||||
pub(crate) fn retained_index_runs(&self) -> &[RetainedIndexRun] {
|
||||
&self.retained_index_runs
|
||||
}
|
||||
|
||||
pub(crate) fn retained_artifacts(&self) -> &[RetainedProjectionArtifact] {
|
||||
&self.retained_artifacts
|
||||
}
|
||||
}
|
||||
|
||||
/// The complete staging-owned seam used by production recovery.
|
||||
///
|
||||
/// `resolve_committed` is read-only: the complete frame is already the
|
||||
/// durable authority and resolution may inspect, open, hash, and pin its
|
||||
/// immutable artifacts but may not repair them. `notify_recovered` is the
|
||||
/// separate lifecycle transition performed only after recovery has either
|
||||
/// incorporated a committed result into the recovered root or proved absence
|
||||
/// by scanning the complete authoritative journal history.
|
||||
pub(crate) trait ProjectionRecoveryResolver: Send + Sync {
|
||||
/// Sessions whose in-process adoption handle transferred responsibility
|
||||
/// to recovery before the previous engine stopped.
|
||||
fn transferred_sessions(&self, shard_index: u16) -> Result<Arc<[[u8; 16]]>, StoreError>;
|
||||
|
||||
/// Resolve a canonical committed descriptor into exact namespace-scoped
|
||||
/// membership and live physical ownership.
|
||||
fn resolve_committed(
|
||||
&self,
|
||||
namespace: NamespaceId,
|
||||
descriptor: &StagedProjectionInstallV1,
|
||||
) -> Result<RecoveredProjectionArtifacts, StoreError>;
|
||||
|
||||
/// Finish one transferred session after the physical-state proof is
|
||||
/// complete. This transition must be idempotent: recovery remains unready
|
||||
/// if a later notification fails and repeats every notification on the
|
||||
/// next attempt.
|
||||
fn notify_recovered(&self, resolution: RecoveredProjectionResolution)
|
||||
-> Result<(), StoreError>;
|
||||
}
|
||||
|
||||
/// B3's implementation behind the opaque handle.
|
||||
///
|
||||
/// The trait and constructor are crate-private: external callers may carry a
|
||||
/// handle issued by staging but cannot forge one.
|
||||
pub(crate) trait ProjectionAdoptionLifecycle: Send + Sync {
|
||||
fn resolution(&self) -> Result<Arc<ProjectionAdoptionResolution>, StoreError>;
|
||||
|
||||
fn finish(&self, outcome: ProjectionAdoptionOutcome) -> Result<(), StoreError>;
|
||||
|
||||
fn dropped_without_outcome(&self);
|
||||
}
|
||||
|
||||
/// An unforgeable staging capability consumed alongside the wire descriptor.
|
||||
///
|
||||
/// Holding this value pins the sealed artifacts. Exactly one terminal outcome
|
||||
/// must be recorded before it is dropped.
|
||||
pub struct ProjectionAdoption {
|
||||
lifecycle: Arc<dyn ProjectionAdoptionLifecycle>,
|
||||
finished: bool,
|
||||
}
|
||||
|
||||
impl ProjectionAdoption {
|
||||
pub(crate) fn new(lifecycle: Arc<dyn ProjectionAdoptionLifecycle>) -> Self {
|
||||
Self {
|
||||
lifecycle,
|
||||
finished: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn resolution(&self) -> Result<Arc<ProjectionAdoptionResolution>, StoreError> {
|
||||
self.lifecycle.resolution()
|
||||
}
|
||||
|
||||
/// Prove artifact retention against the state readers actually capture.
|
||||
pub(crate) fn artifact_is_referenced(&self, root: &CommittedRoot, path: &Path) -> bool {
|
||||
root.references_path(path)
|
||||
}
|
||||
|
||||
pub(crate) fn finish(mut self, outcome: ProjectionAdoptionOutcome) -> Result<(), StoreError> {
|
||||
self.lifecycle.finish(outcome)?;
|
||||
self.finished = true;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for ProjectionAdoption {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter
|
||||
.debug_struct("ProjectionAdoption")
|
||||
.field("finished", &self.finished)
|
||||
.finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ProjectionAdoption {
|
||||
fn drop(&mut self) {
|
||||
if !self.finished {
|
||||
self.lifecycle.dropped_without_outcome();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// D0-B's exact B1/B3 handoff payload.
|
||||
pub(crate) struct StagedProjectionAdoption {
|
||||
pub(crate) descriptor: StagedProjectionInstallV1,
|
||||
pub(crate) handle: ProjectionAdoption,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::fs::File;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use levcs_protocol::v2::{ProjectionMode, StageSourceKindV1};
|
||||
use tempfile::TempDir;
|
||||
|
||||
use super::*;
|
||||
use crate::index::{IndexKey, IndexLocation};
|
||||
use crate::roots::{PinnedFile, ProjectionArtifactFormat};
|
||||
|
||||
struct RecordingLifecycle {
|
||||
resolution: Arc<ProjectionAdoptionResolution>,
|
||||
outcomes: Mutex<Vec<ProjectionAdoptionOutcome>>,
|
||||
dropped: Mutex<u64>,
|
||||
}
|
||||
|
||||
impl ProjectionAdoptionLifecycle for RecordingLifecycle {
|
||||
fn resolution(&self) -> Result<Arc<ProjectionAdoptionResolution>, StoreError> {
|
||||
Ok(Arc::clone(&self.resolution))
|
||||
}
|
||||
|
||||
fn finish(&self, outcome: ProjectionAdoptionOutcome) -> Result<(), StoreError> {
|
||||
self.outcomes.lock().unwrap().push(outcome);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn dropped_without_outcome(&self) {
|
||||
*self.dropped.lock().unwrap() += 1;
|
||||
}
|
||||
}
|
||||
|
||||
fn resolution() -> Arc<ProjectionAdoptionResolution> {
|
||||
Arc::new(ProjectionAdoptionResolution {
|
||||
session: ProjectionStageSessionV1 {
|
||||
session_id: [1; 16],
|
||||
destination_repo: ObjectId([2; 32]),
|
||||
destination_genesis: ObjectId([3; 32]),
|
||||
expected_authority: ObjectId([4; 32]),
|
||||
projection: ProjectionMode::Full,
|
||||
source_kind: StageSourceKindV1::Mirror,
|
||||
actor: [5; 32],
|
||||
actor_key_epoch: 7,
|
||||
source_generation_digest: ObjectId([6; 32]),
|
||||
fork_proof: None,
|
||||
final_operation_id: [8; 16],
|
||||
final_operation_digest: ObjectId([9; 32]),
|
||||
final_evidence_digest: ObjectId([10; 32]),
|
||||
total_object_count: 1,
|
||||
total_object_bytes: 1,
|
||||
chunk_count: 1,
|
||||
manifest_digest: ObjectId([11; 32]),
|
||||
expires_at_micros: 12,
|
||||
},
|
||||
manifest: ProjectionStageManifestV1 {
|
||||
session_id: [1; 16],
|
||||
chunk_digests: Vec::new(),
|
||||
objects: Vec::new(),
|
||||
membership_root: ObjectId([13; 32]),
|
||||
},
|
||||
artifacts: Arc::from([]),
|
||||
})
|
||||
}
|
||||
|
||||
fn lifecycle() -> Arc<RecordingLifecycle> {
|
||||
Arc::new(RecordingLifecycle {
|
||||
resolution: resolution(),
|
||||
outcomes: Mutex::new(Vec::new()),
|
||||
dropped: Mutex::new(0),
|
||||
})
|
||||
}
|
||||
|
||||
fn install() -> StagedProjectionInstallV1 {
|
||||
StagedProjectionInstallV1 {
|
||||
session_id: [20; 16],
|
||||
manifest_digest: ObjectId([21; 32]),
|
||||
projection: ProjectionMode::Full,
|
||||
object_count: 1,
|
||||
object_bytes: 32,
|
||||
membership_root: ObjectId([22; 32]),
|
||||
artifact_set_digest: ObjectId([23; 32]),
|
||||
}
|
||||
}
|
||||
|
||||
fn recovered_artifacts(
|
||||
directory: &TempDir,
|
||||
) -> Result<RecoveredProjectionArtifacts, StoreError> {
|
||||
let path = directory.path().join("projection-objects");
|
||||
File::create(&path)?;
|
||||
let mut delta = IndexDelta::new(8, 4096);
|
||||
delta.insert(
|
||||
IndexKey::new(NamespaceId([24; 32]), ObjectId([25; 32])),
|
||||
IndexLocation {
|
||||
segment_generation: 7,
|
||||
frame_offset: 128,
|
||||
frame_len: 256,
|
||||
object_type: 1,
|
||||
shard_sequence: 9,
|
||||
},
|
||||
)?;
|
||||
RecoveredProjectionArtifacts::new(
|
||||
install(),
|
||||
Arc::new(delta),
|
||||
Arc::from([]),
|
||||
Arc::from([RetainedProjectionArtifact::new(
|
||||
7,
|
||||
ProjectionArtifactFormat::CanonicalStageChunkV1,
|
||||
PinnedFile::open(path)?,
|
||||
)]),
|
||||
)
|
||||
}
|
||||
|
||||
struct RecordingRecoveryResolver {
|
||||
artifacts: RecoveredProjectionArtifacts,
|
||||
transferred: Arc<[[u8; 16]]>,
|
||||
notifications: Mutex<Vec<RecoveredProjectionResolution>>,
|
||||
}
|
||||
|
||||
impl ProjectionRecoveryResolver for RecordingRecoveryResolver {
|
||||
fn transferred_sessions(&self, _shard_index: u16) -> Result<Arc<[[u8; 16]]>, StoreError> {
|
||||
Ok(Arc::clone(&self.transferred))
|
||||
}
|
||||
|
||||
fn resolve_committed(
|
||||
&self,
|
||||
_namespace: NamespaceId,
|
||||
descriptor: &StagedProjectionInstallV1,
|
||||
) -> Result<RecoveredProjectionArtifacts, StoreError> {
|
||||
if self.artifacts.descriptor() != descriptor {
|
||||
return Err(StoreError::Corruption(
|
||||
"descriptor did not name the sealed staging session".into(),
|
||||
));
|
||||
}
|
||||
Ok(self.artifacts.clone())
|
||||
}
|
||||
|
||||
fn notify_recovered(
|
||||
&self,
|
||||
resolution: RecoveredProjectionResolution,
|
||||
) -> Result<(), StoreError> {
|
||||
self.notifications.lock().unwrap().push(resolution);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handle_drop_without_outcome_is_observable() {
|
||||
let lifecycle = lifecycle();
|
||||
let handle = ProjectionAdoption::new(lifecycle.clone());
|
||||
drop(handle);
|
||||
assert_eq!(*lifecycle.dropped.lock().unwrap(), 1);
|
||||
assert!(lifecycle.outcomes.lock().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_terminal_outcome_suppresses_the_drop_bug() {
|
||||
for outcome in [
|
||||
ProjectionAdoptionOutcome::Adopted,
|
||||
ProjectionAdoptionOutcome::DefinitivePreAppendFailure,
|
||||
ProjectionAdoptionOutcome::TransferredToRecovery,
|
||||
] {
|
||||
let lifecycle = lifecycle();
|
||||
ProjectionAdoption::new(lifecycle.clone())
|
||||
.finish(outcome)
|
||||
.unwrap();
|
||||
assert_eq!(&*lifecycle.outcomes.lock().unwrap(), &[outcome]);
|
||||
assert_eq!(*lifecycle.dropped.lock().unwrap(), 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recovery_resolution_is_read_only_and_carries_live_ownership() {
|
||||
let directory = TempDir::new().unwrap();
|
||||
let artifacts = recovered_artifacts(&directory).unwrap();
|
||||
let retained_path = artifacts.retained_artifacts()[0].path().to_owned();
|
||||
let resolver = RecordingRecoveryResolver {
|
||||
artifacts,
|
||||
transferred: Arc::from([[20; 16], [26; 16]]),
|
||||
notifications: Mutex::new(Vec::new()),
|
||||
};
|
||||
|
||||
let transferred = resolver.transferred_sessions(3).unwrap();
|
||||
assert_eq!(&*transferred, &[[20; 16], [26; 16]]);
|
||||
let recovered = resolver
|
||||
.resolve_committed(NamespaceId([24; 32]), &install())
|
||||
.unwrap();
|
||||
assert_eq!(recovered.descriptor(), &install());
|
||||
assert_eq!(recovered.index_delta().len(), 1);
|
||||
assert_eq!(recovered.index_runs_newest_first().len(), 0);
|
||||
assert!(recovered.retained_index_runs().is_empty());
|
||||
assert_eq!(recovered.retained_artifacts()[0].path(), retained_path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recovery_notifies_both_terminal_physical_outcomes() {
|
||||
let directory = TempDir::new().unwrap();
|
||||
let resolver = RecordingRecoveryResolver {
|
||||
artifacts: recovered_artifacts(&directory).unwrap(),
|
||||
transferred: Arc::from([]),
|
||||
notifications: Mutex::new(Vec::new()),
|
||||
};
|
||||
for outcome in [
|
||||
RecoveredProjectionOutcome::Committed,
|
||||
RecoveredProjectionOutcome::ProvedAbsent,
|
||||
] {
|
||||
resolver
|
||||
.notify_recovered(RecoveredProjectionResolution {
|
||||
session_id: [20; 16],
|
||||
outcome,
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
assert_eq!(
|
||||
&*resolver.notifications.lock().unwrap(),
|
||||
&[
|
||||
RecoveredProjectionResolution {
|
||||
session_id: [20; 16],
|
||||
outcome: RecoveredProjectionOutcome::Committed,
|
||||
},
|
||||
RecoveredProjectionResolution {
|
||||
session_id: [20; 16],
|
||||
outcome: RecoveredProjectionOutcome::ProvedAbsent,
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recovery_rejects_membership_without_live_artifact_ownership() {
|
||||
let mut delta = IndexDelta::new(8, 4096);
|
||||
delta
|
||||
.insert(
|
||||
IndexKey::new(NamespaceId([24; 32]), ObjectId([25; 32])),
|
||||
IndexLocation {
|
||||
segment_generation: 7,
|
||||
frame_offset: 128,
|
||||
frame_len: 256,
|
||||
object_type: 1,
|
||||
shard_sequence: 9,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let error = RecoveredProjectionArtifacts::new(
|
||||
install(),
|
||||
Arc::new(delta),
|
||||
Arc::from([]),
|
||||
Arc::from([]),
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(matches!(error, StoreError::Corruption(message) if message.contains("ownership")));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
use levcs_core::{ObjectId, ObjectType};
|
||||
use levcs_protocol::v2::{TransactionEvidenceV1, TypedRefCas};
|
||||
|
||||
use crate::staging::{ProjectionAdoptionOutcome, StagedProjectionAdoption};
|
||||
use crate::types::{NamespaceId, OperationId, PrivilegedConstruction, StoreError};
|
||||
|
||||
/// The immutable output of the instance pipeline's stages 4-8 (plan §7).
|
||||
|
|
@ -32,12 +33,12 @@ impl ValidatedTransaction {
|
|||
/// Begin construction. Requires a capability token that is not reachable
|
||||
/// from a `&StoreEngine`; see `PrivilegedConstruction` (scope 2.2).
|
||||
pub fn builder(_token: PrivilegedConstruction) -> ValidatedTransactionBuilder {
|
||||
ValidatedTransactionBuilder { _private: () }
|
||||
ValidatedTransactionBuilder { adoption: None }
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ValidatedTransactionBuilder {
|
||||
_private: (),
|
||||
adoption: Option<StagedProjectionAdoption>,
|
||||
}
|
||||
|
||||
impl ValidatedTransactionBuilder {
|
||||
|
|
@ -76,9 +77,111 @@ impl ValidatedTransactionBuilder {
|
|||
self
|
||||
}
|
||||
|
||||
pub fn build(self) -> Result<ValidatedTransaction, StoreError> {
|
||||
/// Adopt one sealed staged projection. The opaque handle is the adoption
|
||||
/// pin and is consumed with the canonical wire descriptor so neither half
|
||||
/// can be forgotten independently.
|
||||
pub fn adopt_projection(
|
||||
mut self,
|
||||
descriptor: levcs_protocol::v2::StagedProjectionInstallV1,
|
||||
handle: crate::staging::ProjectionAdoption,
|
||||
) -> Result<Self, StoreError> {
|
||||
if let Some(previous) = self.adoption.take() {
|
||||
// Both pins were admitted, so both receive a terminal outcome
|
||||
// even though the builder rejects the duplicate. Returning early
|
||||
// after finishing only one would turn the other drop into the
|
||||
// lifecycle bug this handle exists to expose.
|
||||
let previous_result = previous
|
||||
.handle
|
||||
.finish(ProjectionAdoptionOutcome::DefinitivePreAppendFailure);
|
||||
let submitted_result =
|
||||
handle.finish(ProjectionAdoptionOutcome::DefinitivePreAppendFailure);
|
||||
previous_result?;
|
||||
submitted_result?;
|
||||
return Err(StoreError::Conflict(
|
||||
"a transaction may adopt exactly one staged projection".into(),
|
||||
));
|
||||
}
|
||||
self.adoption = Some(StagedProjectionAdoption { descriptor, handle });
|
||||
Ok(self)
|
||||
}
|
||||
|
||||
pub fn build(mut self) -> Result<ValidatedTransaction, StoreError> {
|
||||
if let Some(adoption) = self.adoption.take() {
|
||||
// D0-B freezes the lifecycle while B1 still owns the successful
|
||||
// builder body. `NotImplemented` is definitive and pre-append.
|
||||
adoption
|
||||
.handle
|
||||
.finish(ProjectionAdoptionOutcome::DefinitivePreAppendFailure)?;
|
||||
}
|
||||
Err(StoreError::NotImplemented(
|
||||
"ValidatedTransactionBuilder::build — B1 NamespaceTxn, scope 6-B1 deliverable 3",
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use levcs_protocol::v2::{ProjectionMode, StagedProjectionInstallV1};
|
||||
|
||||
use super::*;
|
||||
use crate::staging::{
|
||||
ProjectionAdoption, ProjectionAdoptionLifecycle, ProjectionAdoptionResolution,
|
||||
};
|
||||
|
||||
#[derive(Default)]
|
||||
struct Lifecycle {
|
||||
outcomes: Mutex<Vec<ProjectionAdoptionOutcome>>,
|
||||
dropped: Mutex<u64>,
|
||||
}
|
||||
|
||||
impl ProjectionAdoptionLifecycle for Lifecycle {
|
||||
fn resolution(&self) -> Result<Arc<ProjectionAdoptionResolution>, StoreError> {
|
||||
Err(StoreError::NotImplemented(
|
||||
"not needed by this contract test",
|
||||
))
|
||||
}
|
||||
|
||||
fn finish(&self, outcome: ProjectionAdoptionOutcome) -> Result<(), StoreError> {
|
||||
self.outcomes.lock().unwrap().push(outcome);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn dropped_without_outcome(&self) {
|
||||
*self.dropped.lock().unwrap() += 1;
|
||||
}
|
||||
}
|
||||
|
||||
fn descriptor(session_id: [u8; 16]) -> StagedProjectionInstallV1 {
|
||||
StagedProjectionInstallV1 {
|
||||
session_id,
|
||||
manifest_digest: ObjectId([1; 32]),
|
||||
projection: ProjectionMode::Full,
|
||||
object_count: 1,
|
||||
object_bytes: 1,
|
||||
membership_root: ObjectId([2; 32]),
|
||||
artifact_set_digest: ObjectId([3; 32]),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_projection_adoption_releases_both_pins() {
|
||||
let first = Arc::new(Lifecycle::default());
|
||||
let second = Arc::new(Lifecycle::default());
|
||||
let builder = ValidatedTransaction::builder(PrivilegedConstruction::internal())
|
||||
.adopt_projection(descriptor([1; 16]), ProjectionAdoption::new(first.clone()))
|
||||
.expect("first adoption");
|
||||
|
||||
let result =
|
||||
builder.adopt_projection(descriptor([2; 16]), ProjectionAdoption::new(second.clone()));
|
||||
assert!(matches!(result, Err(StoreError::Conflict(_))));
|
||||
for lifecycle in [first, second] {
|
||||
assert_eq!(
|
||||
&*lifecycle.outcomes.lock().unwrap(),
|
||||
&[ProjectionAdoptionOutcome::DefinitivePreAppendFailure]
|
||||
);
|
||||
assert_eq!(*lifecycle.dropped.lock().unwrap(), 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
//! The whole file is the Phase 1 realization of plan §5.1's frozen public API.
|
||||
|
||||
use std::fmt;
|
||||
use std::sync::Arc;
|
||||
|
||||
use levcs_core::ObjectId;
|
||||
|
||||
|
|
@ -140,7 +141,7 @@ pub enum TransactionStatus {
|
|||
|
||||
/// Inability to answer. Corruption, unavailable recovery state, refusal to
|
||||
/// open, or a definitive rejection — never a lifecycle state.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
#[derive(Clone, Debug, thiserror::Error)]
|
||||
pub enum StoreError {
|
||||
#[error("not implemented: {0}")]
|
||||
NotImplemented(&'static str),
|
||||
|
|
@ -174,7 +175,7 @@ pub enum StoreError {
|
|||
AlreadyLocked,
|
||||
|
||||
#[error("io: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
Io(#[source] Arc<std::io::Error>),
|
||||
|
||||
#[error("no space left on the store device")]
|
||||
NoSpace,
|
||||
|
|
@ -186,6 +187,12 @@ pub enum StoreError {
|
|||
allowed: u64,
|
||||
},
|
||||
|
||||
#[error("store overloaded at {limit}; retry after {retry_after_micros} microseconds")]
|
||||
Overloaded {
|
||||
limit: &'static str,
|
||||
retry_after_micros: u64,
|
||||
},
|
||||
|
||||
#[error("mutable-state conflict: {0}")]
|
||||
Conflict(String),
|
||||
|
||||
|
|
@ -199,7 +206,7 @@ pub enum StoreError {
|
|||
InvalidConfiguration(String),
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
#[derive(Clone, Debug, thiserror::Error)]
|
||||
pub enum SignerError {
|
||||
#[error("signer unavailable")]
|
||||
Unavailable,
|
||||
|
|
@ -207,6 +214,12 @@ pub enum SignerError {
|
|||
Rejected(String),
|
||||
}
|
||||
|
||||
impl From<std::io::Error> for StoreError {
|
||||
fn from(error: std::io::Error) -> Self {
|
||||
Self::Io(Arc::new(error))
|
||||
}
|
||||
}
|
||||
|
||||
/// Signs `CommittedTransactionV1` event digests on behalf of the instance.
|
||||
///
|
||||
/// Registered by instance composition (plan §5.2). The store calls it before
|
||||
|
|
@ -278,3 +291,44 @@ pub struct DurabilityCounterSnapshot {
|
|||
pub short_writes: u64,
|
||||
pub bytes_written: u64,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn cloned_io_error_shares_the_original_error_losslessly() {
|
||||
use std::error::Error;
|
||||
|
||||
let error = StoreError::from(std::io::Error::from_raw_os_error(libc::EIO));
|
||||
let clone = error.clone();
|
||||
|
||||
let (StoreError::Io(original), StoreError::Io(cloned)) = (&error, &clone) else {
|
||||
panic!("expected two shared I/O errors");
|
||||
};
|
||||
assert!(Arc::ptr_eq(original, cloned));
|
||||
assert_eq!(cloned.raw_os_error(), Some(libc::EIO));
|
||||
let source = clone.source().expect("I/O source remains available");
|
||||
let shared = source
|
||||
.downcast_ref::<Arc<std::io::Error>>()
|
||||
.expect("shared I/O source remains downcastable without reconstruction");
|
||||
assert!(Arc::ptr_eq(original, shared));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overloaded_error_is_cloneable_and_preserves_retry_guidance() {
|
||||
let error = StoreError::Overloaded {
|
||||
limit: "max_status_entries",
|
||||
retry_after_micros: 1_000,
|
||||
};
|
||||
let StoreError::Overloaded {
|
||||
limit,
|
||||
retry_after_micros,
|
||||
} = error.clone()
|
||||
else {
|
||||
panic!("clone changed the error variant");
|
||||
};
|
||||
assert_eq!(limit, "max_status_entries");
|
||||
assert_eq!(retry_after_micros, 1_000);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -58,6 +58,7 @@ fn checkpoint(sequence: u64) -> Checkpoint {
|
|||
repo_sequence: sequence,
|
||||
shard_sequence: sequence,
|
||||
current_authority: oid(0xA1),
|
||||
refs: Vec::new(),
|
||||
objects_new: 1,
|
||||
retry_until_micros: 1_700_000_900_000_000,
|
||||
first_receipt_visibility_micros: Some(1_700_000_000_100_000),
|
||||
|
|
|
|||
|
|
@ -76,6 +76,7 @@ mod faults {
|
|||
repo_sequence: sequence,
|
||||
shard_sequence: sequence,
|
||||
current_authority: oid(0xB1),
|
||||
refs: Vec::new(),
|
||||
objects_new: 1,
|
||||
retry_until_micros: 1_700_000_900_000_000,
|
||||
first_receipt_visibility_micros: Some(1_700_000_000_100_000),
|
||||
|
|
|
|||
|
|
@ -135,33 +135,17 @@ fn a_corrupt_current_falls_back_to_the_newest_valid_predecessor_manifest() {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn a_corrupt_current_whose_newest_manifest_is_also_corrupt_falls_further_back() {
|
||||
fn a_corrupt_current_with_a_higher_invalid_manifest_refuses_lossy_fallback() {
|
||||
let shard = two_generations();
|
||||
shard.corrupt("CURRENT", 12);
|
||||
shard.corrupt("manifests/4.manifest", 30);
|
||||
|
||||
let selection = resolve(&shard).expect("generation 3 must be reachable");
|
||||
assert_eq!(selection.generation, 3);
|
||||
match selection.source {
|
||||
ManifestSource::Fallback {
|
||||
reason: ManifestFallbackReason::CurrentCorrupt(_),
|
||||
} => {}
|
||||
ref other => panic!(
|
||||
"the reported reason is why CURRENT was abandoned, not why an \
|
||||
intermediate generation was skipped; got {other:?}"
|
||||
),
|
||||
}
|
||||
assert_eq!(
|
||||
selection.rejected.len(),
|
||||
1,
|
||||
"the skipped generation must be reported, not silently passed over"
|
||||
resolve(&shard),
|
||||
None,
|
||||
"a finalized higher manifest exists but its committed closure cannot be \
|
||||
proved; generation 3 may be a shorter acknowledged prefix"
|
||||
);
|
||||
assert_eq!(selection.rejected[0].0, 4);
|
||||
match selection.rejected[0].1 {
|
||||
ManifestFallbackReason::ReferentCorrupt { generation, .. } => assert_eq!(generation, 4),
|
||||
ref other => panic!("expected a corrupt referent, got {other:?}"),
|
||||
}
|
||||
assert_agrees_with_a1(&shard);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -202,26 +186,18 @@ fn a_current_naming_another_store_root_is_refused_as_a_pointer_failure() {
|
|||
// ===========================================================================
|
||||
|
||||
#[test]
|
||||
fn a_valid_current_naming_a_missing_manifest_generation_falls_back() {
|
||||
fn a_valid_current_naming_a_missing_manifest_generation_refuses_to_guess() {
|
||||
let shard = two_generations();
|
||||
// The pointer stays intact and keeps naming generation 9, which was never
|
||||
// written. This is a failure the single-`CURRENT` layout could not express.
|
||||
shard.write_current(9, ROOT_UUID);
|
||||
|
||||
let selection = resolve(&shard).expect("the newest present generation must be reachable");
|
||||
assert_eq!(selection.generation, 4);
|
||||
assert_eq!(
|
||||
selection.source,
|
||||
ManifestSource::Fallback {
|
||||
reason: ManifestFallbackReason::ReferentMissing { generation: 9 }
|
||||
},
|
||||
"an absent referent is not the same fault as a pointer that does not validate"
|
||||
resolve(&shard),
|
||||
None,
|
||||
"without generation 9's manifest bytes recovery cannot prove that \
|
||||
generation 4 has the same committed closure"
|
||||
);
|
||||
assert_eq!(
|
||||
selection.rejected,
|
||||
vec![(9, ManifestFallbackReason::ReferentMissing { generation: 9 })]
|
||||
);
|
||||
assert_agrees_with_a1(&shard);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
|
|
@ -229,49 +205,45 @@ fn a_valid_current_naming_a_missing_manifest_generation_falls_back() {
|
|||
// ===========================================================================
|
||||
|
||||
#[test]
|
||||
fn a_valid_current_naming_a_manifest_that_fails_its_own_checksum_falls_back() {
|
||||
fn a_valid_current_naming_a_corrupt_manifest_refuses_to_guess() {
|
||||
let shard = two_generations();
|
||||
// CURRENT still names 4 and still validates. Generation 4's own bytes are
|
||||
// damaged.
|
||||
shard.corrupt("manifests/4.manifest", 40);
|
||||
|
||||
let selection = resolve(&shard).expect("generation 3 must be reachable");
|
||||
assert_eq!(selection.generation, 3);
|
||||
match selection.source {
|
||||
ManifestSource::Fallback {
|
||||
reason: ManifestFallbackReason::ReferentCorrupt { generation, .. },
|
||||
} => assert_eq!(generation, 4),
|
||||
ref other => panic!(
|
||||
"a corrupt referent is a third distinct branch, not the corrupt-pointer \
|
||||
one; got {other:?}"
|
||||
),
|
||||
}
|
||||
assert_agrees_with_a1(&shard);
|
||||
assert_eq!(
|
||||
resolve(&shard),
|
||||
None,
|
||||
"corrupt generation 4 cannot prove generation 3 authorizes the same tail"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_three_branches_report_three_different_reasons() {
|
||||
// The point of scope 4-A2's "may not be collapsed" is only checkable by
|
||||
// comparing the three outcomes against each other.
|
||||
fn only_a_pointer_failure_with_a_highest_valid_manifest_permits_fallback() {
|
||||
let corrupt_pointer = {
|
||||
let shard = two_generations();
|
||||
shard.corrupt("CURRENT", 12);
|
||||
resolve(&shard).expect("selected").source
|
||||
resolve(&shard)
|
||||
};
|
||||
let missing_referent = {
|
||||
let shard = two_generations();
|
||||
shard.write_current(9, ROOT_UUID);
|
||||
resolve(&shard).expect("selected").source
|
||||
resolve(&shard)
|
||||
};
|
||||
let corrupt_referent = {
|
||||
let shard = two_generations();
|
||||
shard.corrupt("manifests/4.manifest", 40);
|
||||
resolve(&shard).expect("selected").source
|
||||
resolve(&shard)
|
||||
};
|
||||
|
||||
assert_ne!(corrupt_pointer, missing_referent);
|
||||
assert_ne!(corrupt_pointer, corrupt_referent);
|
||||
assert_ne!(missing_referent, corrupt_referent);
|
||||
assert_eq!(
|
||||
corrupt_pointer
|
||||
.expect("highest immutable manifest validates")
|
||||
.generation,
|
||||
4
|
||||
);
|
||||
assert_eq!(missing_referent, None);
|
||||
assert_eq!(corrupt_referent, None);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
|
|
@ -279,7 +251,7 @@ fn the_three_branches_report_three_different_reasons() {
|
|||
// ===========================================================================
|
||||
|
||||
#[test]
|
||||
fn a_corrupt_segment_referenced_by_the_active_manifest_forces_a_fallback() {
|
||||
fn a_corrupt_segment_referenced_by_current_refuses_a_shorter_closure() {
|
||||
let shard = two_generations();
|
||||
// Generation 4's manifest is intact; the segment it names is truncated to
|
||||
// nothing, which is what a failed link or a partially copied restore leaves
|
||||
|
|
@ -288,19 +260,11 @@ fn a_corrupt_segment_referenced_by_the_active_manifest_forces_a_fallback() {
|
|||
// place A2 and A1 legitimately disagree.
|
||||
std::fs::write(shard.paths.segments().join("4-0-9.seg"), b"").expect("truncate segment");
|
||||
|
||||
let selection = resolve(&shard).expect("generation 3 must be reachable");
|
||||
assert_eq!(selection.generation, 3);
|
||||
assert_eq!(
|
||||
selection.source,
|
||||
ManifestSource::Fallback {
|
||||
reason: ManifestFallbackReason::ReferentFileInvalid {
|
||||
generation: 4,
|
||||
filename: "4-0-9.seg".into(),
|
||||
cause: ReferencedFileFault::TooShort,
|
||||
}
|
||||
},
|
||||
"scope 3.8 step 2 falls back when ANY referenced file fails validation, \
|
||||
not only when the manifest itself is corrupt"
|
||||
resolve(&shard),
|
||||
None,
|
||||
"generation 3 names a different immutable segment closure; accepting it \
|
||||
could discard acknowledged generation-4 transactions"
|
||||
);
|
||||
|
||||
let (a1, _) = segment::load_manifest_with_fallback(&shard.paths, &ROOT_UUID)
|
||||
|
|
@ -314,23 +278,11 @@ fn a_corrupt_segment_referenced_by_the_active_manifest_forces_a_fallback() {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn a_missing_segment_referenced_by_the_active_manifest_forces_a_fallback() {
|
||||
fn a_missing_segment_referenced_by_current_refuses_a_shorter_closure() {
|
||||
let shard = two_generations();
|
||||
std::fs::remove_file(shard.paths.segments().join("4-0-9.seg")).expect("remove");
|
||||
|
||||
let selection = resolve(&shard).expect("generation 3 must be reachable");
|
||||
assert_eq!(selection.generation, 3);
|
||||
assert_eq!(
|
||||
selection.source,
|
||||
ManifestSource::Fallback {
|
||||
reason: ManifestFallbackReason::ReferentFileInvalid {
|
||||
generation: 4,
|
||||
filename: "4-0-9.seg".into(),
|
||||
cause: ReferencedFileFault::Missing,
|
||||
}
|
||||
}
|
||||
);
|
||||
assert_agrees_with_a1(&shard);
|
||||
assert_eq!(resolve(&shard), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -377,7 +329,7 @@ fn a_manifest_whose_generation_disagrees_with_its_file_name_is_refused() {
|
|||
// ===========================================================================
|
||||
|
||||
#[test]
|
||||
fn the_fallback_scan_is_bounded() {
|
||||
fn an_invalid_highest_finalized_manifest_is_never_skipped() {
|
||||
let shard = ShardDir::new();
|
||||
shard.write_placeholder_segment("1-0-9.seg", SEGMENT_LEN);
|
||||
shard.write_manifest(&manifest(1, "1-0-9.seg"));
|
||||
|
|
@ -402,15 +354,18 @@ fn the_fallback_scan_is_bounded() {
|
|||
of reach; startup must refuse rather than hunt linearly"
|
||||
);
|
||||
|
||||
let selection = resolve_manifest(
|
||||
&shard.paths,
|
||||
&ROOT_UUID,
|
||||
&PresenceAndLengthValidator::default(),
|
||||
64,
|
||||
)
|
||||
.expect("resolution")
|
||||
.expect("with a larger cap the good generation is found");
|
||||
assert_eq!(selection.generation, 1);
|
||||
assert_eq!(
|
||||
resolve_manifest(
|
||||
&shard.paths,
|
||||
&ROOT_UUID,
|
||||
&PresenceAndLengthValidator::default(),
|
||||
64,
|
||||
)
|
||||
.expect("resolution"),
|
||||
None,
|
||||
"a larger scan bound must not license rollback past a finalized higher \
|
||||
generation whose closure cannot be proved"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ fn receipt(
|
|||
repo_sequence: id as u64,
|
||||
shard_sequence: id as u64,
|
||||
current_authority: ObjectId([0xA1; 32]),
|
||||
refs: Vec::new(),
|
||||
objects_new: 1,
|
||||
retry_until_micros: retry_until,
|
||||
first_receipt_visibility_micros: first_visible,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,333 @@
|
|||
#![cfg(all(feature = "store-internals", feature = "failpoints"))]
|
||||
|
||||
use std::fs::{File, OpenOptions};
|
||||
use std::os::unix::fs::FileExt;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use levcs_store::drive::{faults, ShardDrive};
|
||||
use levcs_store::format::{Manifest, TailRange, JOURNAL_HEADER_LEN};
|
||||
use levcs_store::journal::Journal;
|
||||
use levcs_store::segment::{self, RootLayout};
|
||||
use levcs_store::{DurabilityCounters, NamespaceId};
|
||||
|
||||
fn append(drive: &mut ShardDrive, repo_sequence: u64) {
|
||||
let frame = drive
|
||||
.build_frame(
|
||||
NamespaceId([0xA5; 32]),
|
||||
repo_sequence,
|
||||
vec![repo_sequence as u8; 64],
|
||||
)
|
||||
.expect("build frame");
|
||||
drive
|
||||
.append_group_and_fence(&[frame])
|
||||
.expect("append and fence");
|
||||
}
|
||||
|
||||
fn only_active_journal(active: &Path) -> PathBuf {
|
||||
let mut journals: Vec<PathBuf> = std::fs::read_dir(active)
|
||||
.expect("list active")
|
||||
.map(|entry| entry.expect("entry").path())
|
||||
.filter(|path| {
|
||||
path.extension()
|
||||
.is_some_and(|extension| extension == "journal")
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(
|
||||
journals.len(),
|
||||
1,
|
||||
"write readiness requires one active journal"
|
||||
);
|
||||
journals.pop().expect("one journal")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nonempty_recovery_preserves_evidence_seals_the_exact_prefix_and_opens_fresh() {
|
||||
let _serial = faults::serial();
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let (root_uuid, original_path, original_bytes) = {
|
||||
let mut drive = ShardDrive::create(dir.path(), 0, 1).expect("create");
|
||||
append(&mut drive, 0);
|
||||
append(&mut drive, 1);
|
||||
let path = drive.journal_path().to_path_buf();
|
||||
(
|
||||
drive.root_uuid(),
|
||||
path.clone(),
|
||||
std::fs::read(path).expect("read crash image"),
|
||||
)
|
||||
};
|
||||
|
||||
let recovered =
|
||||
ShardDrive::reopen_through_recovery(dir.path(), 0).expect("recover nonempty journal");
|
||||
assert!(recovered.report.ready);
|
||||
assert_eq!(recovered.adopted_shard_sequences, vec![0, 1]);
|
||||
let evidence = recovered
|
||||
.report
|
||||
.preserved_journal
|
||||
.as_ref()
|
||||
.expect("recovery must retain the original inode");
|
||||
assert_eq!(
|
||||
std::fs::read(evidence).expect("read evidence"),
|
||||
original_bytes,
|
||||
"recovery may not truncate or rewrite the crash image"
|
||||
);
|
||||
assert!(
|
||||
!original_path.exists(),
|
||||
"the old active name must be retired"
|
||||
);
|
||||
|
||||
let paths = RootLayout::new(dir.path()).shard(0);
|
||||
let fresh = only_active_journal(&paths.active());
|
||||
assert_ne!(fresh, original_path);
|
||||
let counters = Arc::new(DurabilityCounters::default());
|
||||
let (journal, scan) = Journal::open(&fresh, &root_uuid, counters).expect("open fresh");
|
||||
assert!(scan.frames.is_empty());
|
||||
assert_eq!(scan.stop_offset, JOURNAL_HEADER_LEN as u64);
|
||||
assert_eq!(journal.next_shard_sequence(), 2);
|
||||
assert!(
|
||||
std::fs::read_dir(paths.segments())
|
||||
.expect("segments")
|
||||
.all(|entry| !entry
|
||||
.expect("entry")
|
||||
.path()
|
||||
.extension()
|
||||
.is_some_and(|extension| extension == "prefix")),
|
||||
"a successful recovery may not leak a construction prefix"
|
||||
);
|
||||
|
||||
let manifest_generation = recovered.recovered.manifest_generation;
|
||||
drop(recovered);
|
||||
let second =
|
||||
ShardDrive::reopen_through_recovery(dir.path(), 0).expect("idempotent re-recovery");
|
||||
assert_eq!(second.recovered.manifest_generation, manifest_generation);
|
||||
assert!(second.report.preserved_journal.is_none());
|
||||
assert_eq!(only_active_journal(&paths.active()), fresh);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn torn_first_frame_is_quarantined_and_replaced_without_an_empty_segment() {
|
||||
let _serial = faults::serial();
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let (root_uuid, original_path, original_bytes) = {
|
||||
let mut drive = ShardDrive::create(dir.path(), 0, 1).expect("create");
|
||||
let frame = drive
|
||||
.build_frame(NamespaceId([0xB6; 32]), 0, vec![0x7E; 256])
|
||||
.expect("build");
|
||||
let encoded = frame.encode().expect("encode");
|
||||
let path = drive.journal_path().to_path_buf();
|
||||
let file = OpenOptions::new().write(true).open(&path).expect("open");
|
||||
file.write_at(&encoded[..encoded.len() / 2], JOURNAL_HEADER_LEN as u64)
|
||||
.expect("write torn first frame");
|
||||
file.sync_data().expect("fence torn bytes");
|
||||
(
|
||||
drive.root_uuid(),
|
||||
path.clone(),
|
||||
std::fs::read(path).expect("read crash image"),
|
||||
)
|
||||
};
|
||||
|
||||
let recovered =
|
||||
ShardDrive::reopen_through_recovery(dir.path(), 0).expect("recover torn first frame");
|
||||
assert!(recovered.report.ready);
|
||||
assert!(recovered.adopted_shard_sequences.is_empty());
|
||||
assert!(recovered.report.quarantined_bytes > 0);
|
||||
let evidence = recovered
|
||||
.report
|
||||
.preserved_journal
|
||||
.as_ref()
|
||||
.expect("damaged zero-frame journal must be retained");
|
||||
assert_eq!(std::fs::read(evidence).expect("evidence"), original_bytes);
|
||||
assert_ne!(
|
||||
std::fs::read(&original_path).expect("fresh journal at reused sequence name"),
|
||||
original_bytes,
|
||||
"the sequence-zero pathname may be reused, but it must name a fresh inode"
|
||||
);
|
||||
|
||||
let paths = RootLayout::new(dir.path()).shard(0);
|
||||
assert!(
|
||||
std::fs::read_dir(paths.segments())
|
||||
.expect("segments")
|
||||
.all(|entry| entry
|
||||
.expect("entry")
|
||||
.path()
|
||||
.extension()
|
||||
.and_then(|extension| extension.to_str())
|
||||
!= Some("seg")),
|
||||
"an empty validated prefix is not a segment"
|
||||
);
|
||||
let fresh = only_active_journal(&paths.active());
|
||||
let counters = Arc::new(DurabilityCounters::default());
|
||||
let (journal, scan) = Journal::open(&fresh, &root_uuid, counters).expect("open fresh");
|
||||
assert!(scan.frames.is_empty());
|
||||
assert_eq!(journal.next_shard_sequence(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn healthy_empty_active_journal_is_already_write_ready_and_is_not_replaced() {
|
||||
let _serial = faults::serial();
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let (root_uuid, original) = {
|
||||
let drive = ShardDrive::create(dir.path(), 0, 1).expect("create");
|
||||
(drive.root_uuid(), drive.journal_path().to_path_buf())
|
||||
};
|
||||
|
||||
let recovered =
|
||||
ShardDrive::reopen_through_recovery(dir.path(), 0).expect("recover healthy empty");
|
||||
assert!(recovered.report.ready);
|
||||
assert!(recovered.report.preserved_journal.is_none());
|
||||
assert_eq!(recovered.recovered.manifest_generation, None);
|
||||
let paths = RootLayout::new(dir.path()).shard(0);
|
||||
assert_eq!(only_active_journal(&paths.active()), original);
|
||||
let (journal, scan) = Journal::open(
|
||||
&original,
|
||||
&root_uuid,
|
||||
Arc::new(DurabilityCounters::default()),
|
||||
)
|
||||
.expect("open");
|
||||
assert!(scan.frames.is_empty());
|
||||
assert_eq!(journal.next_shard_sequence(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejected_newer_generation_forces_recovery_artifacts_above_every_immutable_name() {
|
||||
let _serial = faults::serial();
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
{
|
||||
let mut drive = ShardDrive::create(dir.path(), 0, 1).expect("create");
|
||||
append(&mut drive, 0);
|
||||
drive.seal_and_install().expect("generation one");
|
||||
append(&mut drive, 1);
|
||||
|
||||
let paths = drive.shard_paths();
|
||||
let selected = segment::read_manifest(paths, 1, &drive.root_uuid()).expect("manifest one");
|
||||
let rejected = Manifest {
|
||||
root_uuid: drive.root_uuid(),
|
||||
generation: 100,
|
||||
base_generation: 0,
|
||||
retained_tail_ranges: selected.retained_tail_ranges.clone(),
|
||||
index_runs: vec![(99, "missing.idx".into())],
|
||||
checkpoints: Vec::new(),
|
||||
committed_shard_sequence: selected.committed_shard_sequence,
|
||||
};
|
||||
segment::install_manifest(paths, &rejected, 2, &DurabilityCounters::default())
|
||||
.expect("publish rejected newer manifest");
|
||||
}
|
||||
|
||||
let recovered =
|
||||
ShardDrive::reopen_through_recovery(dir.path(), 0).expect("fallback and repair");
|
||||
assert_eq!(recovered.recovered.manifest_generation, Some(101));
|
||||
assert_eq!(recovered.adopted_shard_sequences, vec![0, 1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn journal_creation_reuses_one_target_scoped_temp_after_an_interrupted_attempt() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let active = dir.path().join("active");
|
||||
std::fs::create_dir(&active).expect("active");
|
||||
std::fs::write(active.join(".0.journal.tmp"), [0xA5; 31]).expect("partial temp");
|
||||
let counters = Arc::new(DurabilityCounters::default());
|
||||
let journal = Journal::create(
|
||||
&active,
|
||||
[7; 16],
|
||||
0,
|
||||
0,
|
||||
[9; 16],
|
||||
1 << 20,
|
||||
1,
|
||||
Arc::clone(&counters),
|
||||
)
|
||||
.expect("resume partial creation");
|
||||
assert_eq!(journal.next_shard_sequence(), 0);
|
||||
assert!(!active.join(".0.journal.tmp").exists());
|
||||
assert_eq!(only_active_journal(&active), active.join("0.journal"));
|
||||
|
||||
let reopened = Journal::create(&active, [8; 16], 0, 0, [9; 16], 1 << 20, 2, counters)
|
||||
.expect("resume after final rename");
|
||||
assert_eq!(reopened.journal_id(), [7; 16]);
|
||||
assert_eq!(
|
||||
std::fs::read_dir(active).expect("active entries").count(),
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recovered_prefix_resumes_one_deterministic_partial_copy() {
|
||||
let _serial = faults::serial();
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let (root_uuid, journal_path, paths) = {
|
||||
let mut drive = ShardDrive::create(dir.path(), 0, 1).expect("create");
|
||||
append(&mut drive, 0);
|
||||
(
|
||||
drive.root_uuid(),
|
||||
drive.journal_path().to_path_buf(),
|
||||
drive.shard_paths().clone(),
|
||||
)
|
||||
};
|
||||
let original = std::fs::read(&journal_path).expect("original");
|
||||
let counters = Arc::new(DurabilityCounters::default());
|
||||
let (journal, scan) =
|
||||
Journal::open(&journal_path, &root_uuid, Arc::clone(&counters)).expect("open journal");
|
||||
let artifact = paths.segments().join(format!(
|
||||
".recovery-{}-1.prefix",
|
||||
hex::encode(journal.journal_id())
|
||||
));
|
||||
std::fs::write(&artifact, &original[..137]).expect("partial prefix");
|
||||
|
||||
let installed = segment::seal_recovered_prefix(
|
||||
journal.file(),
|
||||
journal.header(),
|
||||
&scan,
|
||||
&paths,
|
||||
1,
|
||||
&counters,
|
||||
)
|
||||
.expect("resume and seal");
|
||||
assert!(installed.exists());
|
||||
assert!(!artifact.exists());
|
||||
assert_eq!(
|
||||
std::fs::read(journal_path).expect("source after seal"),
|
||||
original,
|
||||
"the source journal must remain byte-for-byte unchanged"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manifest_install_resumes_exact_temp_and_exact_final_but_rejects_difference() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let layout = RootLayout::new(dir.path());
|
||||
let counters = DurabilityCounters::default();
|
||||
segment::initialize_root(&layout, 1, [0xC7; 16], 1, &counters).expect("initialize");
|
||||
let paths = layout.shard(0);
|
||||
let manifest = Manifest {
|
||||
root_uuid: [0xC7; 16],
|
||||
generation: 1,
|
||||
base_generation: 0,
|
||||
retained_tail_ranges: vec![TailRange {
|
||||
generation: 1,
|
||||
first_shard_sequence: 0,
|
||||
last_shard_sequence: 0,
|
||||
filename: "1-0-0.seg".into(),
|
||||
}],
|
||||
index_runs: Vec::new(),
|
||||
checkpoints: Vec::new(),
|
||||
committed_shard_sequence: 0,
|
||||
};
|
||||
let encoded = manifest.encode().expect("encode");
|
||||
let temp = paths.manifests().join("1.manifest.tmp");
|
||||
std::fs::write(&temp, &encoded).expect("exact temp");
|
||||
File::open(&temp)
|
||||
.expect("open temp")
|
||||
.sync_data()
|
||||
.expect("fence");
|
||||
segment::install_manifest(&paths, &manifest, 2, &counters).expect("resume exact temp");
|
||||
|
||||
std::fs::write(&temp, &encoded).expect("temp after final");
|
||||
segment::install_manifest(&paths, &manifest, 2, &counters)
|
||||
.expect("resume after final publication");
|
||||
assert!(!temp.exists());
|
||||
|
||||
std::fs::write(&temp, [0xDD; 64]).expect("different temp");
|
||||
let error = segment::install_manifest(&paths, &manifest, 2, &counters)
|
||||
.expect_err("different temp must not be overwritten");
|
||||
assert!(error.to_string().contains("different bytes"));
|
||||
}
|
||||
|
|
@ -890,6 +890,89 @@ This is the second frozen Phase 0 classification found to be physically wrong. B
|
|||
found by asking what state the device is actually in, rather than by checking the model
|
||||
against itself.
|
||||
|
||||
##### Contract review 2026-07-27-A
|
||||
|
||||
Wave B's D0-B publication freeze requires nine amendments to Wave A frozen or
|
||||
signature-frozen surfaces. They land as one reviewed interface change before B1, B3, or B4
|
||||
is dispatched:
|
||||
|
||||
- `lib.rs` declares and re-exports the immutable publication roots and runtime-agnostic
|
||||
completion primitive.
|
||||
- The workspace and `levcs-store` manifests take `im`; `Cargo.lock` records the resolved
|
||||
graph. Decision 9.7 applies structural sharing to repositories, terminal
|
||||
receipts/tombstones, typed refs, and transient operation statuses. Canonically iterated
|
||||
refs use `OrdMap`; unordered hot lookup maps use the HAMT-backed `HashMap`.
|
||||
- `recovery.rs` exposes one production recovery path returning `RecoveredShard`, including
|
||||
the complete layered index, catalog, refs, exact receipts, sequences, report, staging
|
||||
resolutions, and live retained segment/index/checkpoint/tail ownership. A root-wide
|
||||
`RecoverySession` holds `LOCK` continuously across all shard recoveries and is retained by
|
||||
the engine; the one-shot drive wrapper uses that same session. `drive.rs` now retains the
|
||||
recovered state instead of projecting it down to diagnostics.
|
||||
- `options.rs` gains the status-root and projection-staging session/principal/global
|
||||
count/object/byte/file/age/rate/debt ceilings. Startup checks all non-zero and nesting
|
||||
constraints and, with checked ceiling arithmetic, refuses a configuration in which one
|
||||
maximal projection cannot finish before the session horizon.
|
||||
- `types.rs` makes the exact shared completion outcome cloneable. `StoreError::Io` carries
|
||||
`Arc<std::io::Error>` and a handwritten `From<std::io::Error>` preserves existing `?`
|
||||
call sites without reconstructing the error; `StoreError::Overloaded` is the typed
|
||||
status-capacity refusal from decision 9.8.
|
||||
- `transaction.rs` and `staging.rs` freeze the opaque projection-adoption handle. The handle
|
||||
is the pin, exposes only read-only resolution and committed-root reference proof, and must
|
||||
end as adopted, definitively failed before append, or transferred to recovery. Recovery's
|
||||
committed/proved-absent notification is part of the same seam, and cleanup may run only
|
||||
after recovery resolves the shard. A staging-owned recovery resolver turns every
|
||||
authoritative committed descriptor into namespace-scoped index layers and live artifact
|
||||
pins before readiness; notification alone is insufficient.
|
||||
|
||||
The resolver exposed one Wave A index assumption that inline-only tests could not exercise.
|
||||
`IndexLocation` names the complete certified storage record, not naked object bytes. That
|
||||
record is a transaction frame for inline objects and a canonical digest-bound stage chunk
|
||||
for an adopted projection. The packed storage-version-1 fields do not change: the captured
|
||||
committed root maps `segment_generation` to a retained source kind and selects the
|
||||
corresponding decoder. Generation collisions across source kinds are corruption. This
|
||||
amends the `index.rs` contract without changing its bytes.
|
||||
|
||||
Integration found one additional frozen-format defect. A canonical transaction frame
|
||||
contains the exact applied refs returned in `CommitReceipt`, but Wave A's checkpoint
|
||||
`ReceiptRecord` omitted them. After the replay horizon moved past the frame, current ref
|
||||
state could not reconstruct old values, deletions, `force`, or transaction membership, so
|
||||
the same committed retry could return a different receipt after reopen.
|
||||
|
||||
`checkpoint.rs` therefore adds a bounded applied-ref vector to each retained receipt and
|
||||
sets authenticated checkpoint capability flag bit 0. Production recovery and the drive
|
||||
seam populate it only from the canonical committed transaction. A storage-version-1
|
||||
checkpoint without the flag remains readable if it has no retained receipts; if it has any,
|
||||
the generation is rejected as `ReceiptRefsUnavailable` and the existing fallback/offline
|
||||
rebuild policy applies. Older readers already reject the new non-zero flag. The derived
|
||||
checkpoint format therefore fails closed in both directions without a global
|
||||
`STORAGE_VERSION` bump; journal and segment authority bytes are unchanged.
|
||||
|
||||
Production-path integration also found that Wave A's recovery stopped after logical replay:
|
||||
it did not perform normative step 8, so a damaged active journal could be reported ready
|
||||
without sealing its validated prefix and installing a fresh active journal. Recovery now
|
||||
preserves crash evidence, constructs the recovered segment and fresh journal through
|
||||
deterministic resumable names, validates any pre-existing construction artifact byte for
|
||||
byte, and publishes readiness only after the repaired physical state is complete.
|
||||
|
||||
The same amendment tightens manifest authority. Checkpoints are derived and may fall back
|
||||
only among checkpoint rows retained by the selected authoritative manifest; their failure
|
||||
does not authorize a shorter transaction tail. Missing or corrupt authoritative segment
|
||||
bytes refuse readiness. If `CURRENT` is corrupt or missing, recovery may choose the highest
|
||||
valid finalized manifest only when no higher invalid finalized manifest makes the closure
|
||||
ambiguous. Manifest tuple validation includes segment generation/sequence coverage, index
|
||||
generation, and checkpoint sequence. Generation allocation scans immutable names and moves
|
||||
above rejected artifacts within a configured bound, so repair never reuses an ambiguous
|
||||
name. The drive checkpoint path now seals and publishes its checkpoint reference in one
|
||||
manifest installation, keeping the test seam subject to the production authority model.
|
||||
|
||||
The new `roots.rs` and `completion.rs` files are not amendments to Wave A, but their
|
||||
interfaces freeze with this review. The completion state stores exactly
|
||||
`Result<CommitReceipt, StoreError>` behind one mutex shared by outcome and the full waiter
|
||||
set. Publication wakes outside the mutex, a dropped waiter is not a publication failure,
|
||||
and every waiter receives one owned clone of the same result. The committed root uses pure,
|
||||
idempotent subtree merge and carries addressable live pins for every retained artifact so a
|
||||
captured read cannot race reclamation.
|
||||
|
||||
### Phase 1 — storage engine spine
|
||||
|
||||
Lead first defines sealed transaction/frame/snapshot interfaces and file ownership. That deliverable (D0) landed on 2026-07-24 as `crates/levcs-store`: the frozen public API compiling against `StoreError::NotImplemented`, the file-ownership split, strict configuration validation, the single durability syscall funnel with its counters and fault hooks, the failpoint registry in enforced one-to-one correspondence with `oracle::AppendFailpoint`, and the journal-level drive seam that lets the crash harness run in Wave A. The enforced gate is `scripts/check-phase1.sh`, which runs `check-phase0.sh` first so the Phase 0 freeze stays enforced. That work is scoped in `doc/phase1-storage-spine-scope.md`, which realizes this section as a file-ownership matrix, a frozen `levcs-store` API, a physical format and durability/recovery specification, per-package deliverables and acceptance criteria, the Wave A adversarial review charter, and the capacity analysis for P2 on the frozen reference hardware. This plan remains authoritative; that document is the Phase 1 realization of it and lists the decisions that must be resolved before Wave A starts.
|
||||
|
|
|
|||
|
|
@ -5,11 +5,13 @@ The rewrite plan remains authoritative. Where this document is more specific, it
|
|||
lead's Phase 1 realization of the plan; where it appears to contradict the plan, the plan
|
||||
wins and this document is defective.
|
||||
|
||||
Status: **Wave A frozen 2026-07-26 at `5ee9c6b`; Wave B scoped, D0-B not yet started.**
|
||||
Status: **Wave A frozen 2026-07-26 at `5ee9c6b`; Wave B scoped, D0-B implemented and
|
||||
gate-green 2026-07-27.**
|
||||
Sections 9.1-9.6 were resolved on 2026-07-24 and are recorded there with their conditions;
|
||||
9.7 and 9.8 were ruled on 2026-07-26 and are recorded in §6.9, and contract review
|
||||
2026-07-26-A resolved the `EvidenceHandoffFailure` classification (§6.3). D0-B is unblocked;
|
||||
no open decision remains. Contract review 2026-07-24-B
|
||||
2026-07-26-A resolved the `EvidenceHandoffFailure` classification (§6.3). D0-B is complete,
|
||||
its frozen-surface implementation amendments are recorded by contract review 2026-07-27-A,
|
||||
and B1/B3/B4 dispatch is unblocked; no open decision remains. Contract review 2026-07-24-B
|
||||
(sections 9.1 and 9.2) has been applied to `bench/result-schema.json` and
|
||||
`bench/reference-hardware.toml` and recorded in the plan document. `crates/levcs-store`
|
||||
exists with the frozen API, the ownership split, the durability funnel, the failpoint
|
||||
|
|
@ -323,6 +325,7 @@ D0 lands; `Cargo.lock` is a reviewed artifact per §10.
|
|||
|---|---|---|
|
||||
| `arc-swap` | §5.1 mandates `ArcSwap<CommittedRoot>`, explicitly not `RwLock<Arc<_>>` | — |
|
||||
| `crossbeam-channel` | §7 "bounded crossbeam-style channels" to shard threads | `std::sync::mpsc` is unbounded/single-consumer |
|
||||
| `im` | §5.3 requires structurally shared hot publication maps; decision 9.7 applies it to repositories, receipts/tombstones, typed refs, and transient statuses | `Arc<BTreeMap>` clone-on-write is O(entries); a hand-rolled HAMT puts correctness risk in every captured read root |
|
||||
| `rustix` | `renameat2(RENAME_NOREPLACE)`, `O_TMPFILE`, `fdatasync`, `fallocate`, `pwritev`, `flock` without hand-rolled `libc` unsafe | raw `libc` (more unsafe surface), `nix` (heavier) |
|
||||
| `memmap2` | §5.3 memory-mapped immutable index runs | read-based runs; revisit if mmap SIGBUS-on-truncate handling proves worse |
|
||||
| `hdrhistogram` | §10 HDR-style histograms in the result bundle | — |
|
||||
|
|
@ -669,10 +672,13 @@ Normative rules attached to this sequence:
|
|||
|
||||
```text
|
||||
1 acquire LOCK; validate FORMAT and root_uuid
|
||||
2 read CURRENT -> manifests/<generation>.manifest; if CURRENT is missing/corrupt or
|
||||
any referenced file fails validation, fall back to the newest valid manifest
|
||||
generation whose referenced files all validate
|
||||
3 load the newest of >=2 validated checkpoints. Three outcomes, kept distinct:
|
||||
2 read CURRENT -> manifests/<generation>.manifest. If CURRENT is missing/corrupt,
|
||||
select the highest valid finalized manifest only when no higher finalized manifest is
|
||||
invalid. Never move to a shorter committed tail because a newer authoritative segment
|
||||
is missing/corrupt; that is RecoveryRequired, not fallback. Derived checkpoint failure
|
||||
is handled by step 3 and does not invalidate the transaction-authority manifest.
|
||||
3 from the selected manifest's retained checkpoint rows, load the newest of >=2
|
||||
validated checkpoints. Three outcomes, kept distinct:
|
||||
no checkpoint directory entries at all -> a fresh root; replay from the start
|
||||
at least one validates -> load the newest
|
||||
entries exist and all fail validation -> explicit offline rebuild mode
|
||||
|
|
@ -700,6 +706,21 @@ failed" would license a replay from sequence zero on a *corrupt* store, which is
|
|||
failure the rule exists to prevent. Emptiness and total corruption must be distinguishable
|
||||
by the type recovery returns, not by a comment.
|
||||
|
||||
Contract review 2026-07-27-A narrows the original step-2 phrase "any referenced file." It
|
||||
was unsafe as written. If manifest 2 authorizes sealed frames 4–5 and checkpoint 5 is
|
||||
corrupt, falling back to manifest 1 may authorize only frames 0–3 while the active journal
|
||||
correctly begins at 6. Treating the shorter state as recovered manufactures acknowledged
|
||||
loss. Checkpoints are derived: keep manifest 2's transaction tail, load retained checkpoint
|
||||
3 from manifest 2, then replay sealed frames 4–5 and active frame 6. If every retained
|
||||
checkpoint fails, enter explicit offline rebuild.
|
||||
|
||||
The same asymmetry is stricter for authority bytes. A missing or corrupt segment cannot be
|
||||
made safe by selecting an older manifest that omits it; recovery must refuse readiness. A
|
||||
corrupt or missing `CURRENT` may still select the highest valid immutable manifest in the
|
||||
directory, including one installed just before a crash prevented the pointer swap, but it
|
||||
may not skip a higher invalid finalized manifest and guess that the lower committed closure
|
||||
is complete.
|
||||
|
||||
Two rules in there carry most of the risk and must be individually asserted, not asserted
|
||||
in aggregate:
|
||||
|
||||
|
|
@ -791,16 +812,20 @@ Acceptance: for each of the following, a dedicated test, not a shared one — to
|
|||
frame; complete frame after a torn frame (must be discarded); zeroed tail; stale
|
||||
preallocated content in the tail; `EIO` on tail read; corrupt checkpoint (one generation,
|
||||
then both); corrupt segment referenced by the active manifest; **corrupt `CURRENT` with a
|
||||
valid predecessor manifest**; **valid `CURRENT` naming a missing manifest generation**;
|
||||
**valid `CURRENT` naming a manifest that fails its own checksum**; wrong `journal_id`;
|
||||
wrong `root_uuid`; shard-sequence duplicate; repo-sequence gap; `previous_event_digest`
|
||||
mismatch.
|
||||
highest valid immutable manifest and no higher invalid finalized manifest**; **valid
|
||||
`CURRENT` naming a missing manifest generation**; **valid `CURRENT` naming a manifest that
|
||||
fails its own checksum**; wrong `journal_id`; wrong `root_uuid`; shard-sequence duplicate;
|
||||
repo-sequence gap; `previous_event_digest` mismatch.
|
||||
|
||||
The three manifest cases are distinct branches of recovery step 2 and may not be collapsed.
|
||||
Corrupt-`CURRENT` exercises "the pointer does not validate"; the two naming cases exercise
|
||||
"the pointer validates but its referent does not" — a failure the single-`CURRENT` layout
|
||||
could not express and which the pointer design introduces. All three must fall back to the
|
||||
newest valid manifest generation rather than to an error.
|
||||
A corrupt pointer may select the highest valid immutable manifest only when no higher
|
||||
invalid finalized manifest makes the committed closure ambiguous. A valid pointer naming a
|
||||
missing or corrupt manifest, or an authoritative manifest naming a missing or corrupt
|
||||
segment, must refuse readiness rather than silently shorten the acknowledged transaction
|
||||
tail. Checkpoint corruption is different because checkpoints are derived: recovery keeps
|
||||
the selected manifest, falls back among that manifest's retained checkpoint rows, and
|
||||
replays its remaining authoritative segments. These are the safe-fallback constraints of
|
||||
contract review 2026-07-27-A.
|
||||
|
||||
### A3 — StoreHarness
|
||||
|
||||
|
|
@ -1164,7 +1189,7 @@ frozen surface: `format.rs`, `journal.rs`, `segment.rs`, `index.rs`, `checkpoint
|
|||
golden corpus. **No Wave B package may edit any of them.** A change any package believes it
|
||||
needs is an interface request to the lead, arbitrated and — if granted — recorded as a
|
||||
contract review in `doc/instance-throughput-rewrite-plan.md`. D0-B already exercises this:
|
||||
seven of its nine items amend a frozen or signature-frozen file, and each is a recorded
|
||||
nine of its eleven items amend a frozen or signature-frozen file, and each is a recorded
|
||||
amendment rather than an edit.
|
||||
|
||||
The frozen surface is the **library**. Wave A's harness — `src/bin/store-crash-driver.rs`,
|
||||
|
|
@ -1185,7 +1210,7 @@ What Wave A already delivers, so no package rebuilds it:
|
|||
| Object index | `index::{IndexDelta, IndexRunBuilder, IndexRun}` | Namespace-scoped keys, Bloom-filtered sealed runs. |
|
||||
| Namespace catalog | `index::NamespaceCatalog` | `bind`, `advance`, `set_lifecycle`. |
|
||||
| Durable ref/receipt tables | `checkpoint::{RefRecord, ReceiptRecord, Checkpoint}` | Install and prune have production callers as of the freeze. |
|
||||
| Recovery | `recovery::*` | The algorithms are complete. The production **entry point** returning a `RecoveredShard` does not exist yet and is D0-B item 5; `ShardDrive::reopen_through_recovery` is a `store-internals` test seam, not it. |
|
||||
| Recovery | `recovery::*` | Wave A supplies parsing, validation, and logical replay. The production **entry point** returning a `RecoveredShard` is D0-B item 5; the physically complete step-8 repair and manifest-authority corrections are D0-B item 11. `ShardDrive::reopen_through_recovery` is a `store-internals` test seam, not the engine path. |
|
||||
| Retention arithmetic | `oracle::{retained_terminal_status, recovered_receipt_visibility}` | Frozen; compute from these, never restate. |
|
||||
|
||||
### 6.1 Ownership matrix
|
||||
|
|
@ -1226,11 +1251,28 @@ work, and every row touching a frozen Wave A file is a **contract amendment reco
|
|||
| 2 | The completion primitive behind `async fn submit` | `completion.rs` *(new)* | no |
|
||||
| 3 | `pub mod roots;` and `pub mod completion;` plus re-exports | `lib.rs` | **yes** |
|
||||
| 4 | `im` dependency (decision 9.7). **No dependency for item 2** — see below | `Cargo.toml`, workspace `Cargo.toml`, `Cargo.lock` | **yes** |
|
||||
| 5 | `RecoveredShard` and a non-feature-gated production recovery entry point | `recovery.rs` | **yes** |
|
||||
| 5 | `RecoverySession`, `RecoveredShard`, and a non-feature-gated production recovery entry point | `recovery.rs` | **yes** |
|
||||
| 6 | `ShardDrive::reopen_through_recovery` re-pointed onto item 5 | `drive.rs` | **yes** |
|
||||
| 7 | Staging and status-root limits (§6.5, decision 9.8) | `options.rs` | **yes** |
|
||||
| 8 | `StoreError::Overloaded { limit, retry_after_micros }` | `types.rs` | **yes** |
|
||||
| 9 | The adoption seam: `ProjectionAdoption` handle (resolve, pin, three-way outcome, reference proof) and `ValidatedTransactionBuilder::adopt_projection(StagedProjectionInstallV1, ProjectionAdoption)` | `transaction.rs`, `staging.rs` | signature frozen |
|
||||
| 8 | Cloneable exact completion outcomes: `StoreError: Clone`, `Io(Arc<std::io::Error>)` with lossless `From`, and `Overloaded { limit, retry_after_micros }` | `types.rs` (plus mechanical construction call sites) | **yes** |
|
||||
| 9 | The adoption seam: `ProjectionAdoption` handle (resolve, pin, three-way outcome, reference proof), `ValidatedTransactionBuilder::adopt_projection(StagedProjectionInstallV1, ProjectionAdoption)`, and recovery resolution of committed staged artifacts into the same index/generation ownership model | `transaction.rs`, `staging.rs`, `roots.rs`, `recovery.rs`, `index.rs` | **yes** |
|
||||
| 10 | Retain exact `CommitReceipt.refs` in checkpoint receipt rows, populated from the canonical frame by production recovery and the drive seam | `checkpoint.rs`, `recovery.rs`, `drive.rs` | **yes** |
|
||||
| 11 | Complete normative recovery step 8 and manifest authority: deterministically preserve and seal the validated active prefix, install a fresh active journal before readiness, validate authoritative manifest tuples, select only its retained checkpoints, allocate generations above rejected immutable artifacts, and publish drive checkpoints through the manifest | `journal.rs`, `segment.rs`, `recovery.rs`, `drive.rs`, recovery tests | **yes** |
|
||||
|
||||
**Item 10 is a D0-B integration finding, not B1 work.** A replayed canonical frame carries
|
||||
the exact applied refs in `committed.transaction.refs`, but Wave A's `ReceiptRecord` omitted
|
||||
them. Once a checkpoint moves the replay horizon past that frame, the current ref table
|
||||
cannot recover old values, deleted refs, `force`, or which refs belonged to that
|
||||
transaction. Returning an empty projection would make a committed retry differ across
|
||||
reopen.
|
||||
|
||||
New checkpoints therefore set an authenticated checkpoint capability flag and encode the
|
||||
complete bounded applied-ref vector in every retained receipt row. A storage-version-1
|
||||
checkpoint without that flag remains readable when it has no retained receipts. If it has
|
||||
any, it is rejected as `ReceiptRefsUnavailable` and follows the existing explicit offline
|
||||
rebuild path; inventing empty receipt refs is forbidden. Readers predating the amendment
|
||||
already reject the non-zero flag, so compatibility fails closed in both directions without
|
||||
a global storage-version bump. Contract review 2026-07-27-A records the amendment.
|
||||
|
||||
**Item 5 is a blocker discovered in review and is the reason this section was rewritten.**
|
||||
The first draft required `StoreEngine::open` to recover through
|
||||
|
|
@ -1247,6 +1289,13 @@ the engine recover identically by construction rather than by review. If the two
|
|||
again, it must be because somebody changed the shared function, not because a caller quietly
|
||||
grew its own.
|
||||
|
||||
`LOCK` is root-wide, so the production entry point is owned by a `RecoverySession` that
|
||||
holds it continuously while every shard is recovered and then moves into `StoreEngine` for
|
||||
the engine's lifetime. A free one-shard wrapper exists for the drive seam and is implemented
|
||||
by creating that same session. Acquiring and dropping one lock per shard would leave a
|
||||
second process able to enter between shards or immediately after recovery but before
|
||||
readiness, invalidating the recovered root before its first read.
|
||||
|
||||
**`RecoveredShard` must carry everything `CommittedRoot` needs, which is more than the
|
||||
replayed tail.** A first draft listed "an index delta" and that is a defect: an object whose
|
||||
only index entry lives in a sealed `IndexRun` or a checkpointed generation would be present
|
||||
|
|
@ -1256,7 +1305,10 @@ pinned against reclamation. The contents are therefore:
|
|||
- the namespace catalog, ref state, and receipt table;
|
||||
- the **complete layered index** — the replayed delta *over* the ordered set of sealed
|
||||
`IndexRun` references the selected manifest and checkpoint generation retain, in the
|
||||
lookup order `CommittedRoot` will use, not the delta alone;
|
||||
lookup order `CommittedRoot` will use, not the delta alone. For a committed
|
||||
`StagedProjectionInstallV1`, the staging-owned recovery resolver must supply its exact
|
||||
namespace-scoped membership and live artifact/index pins before readiness; a notification
|
||||
without incorporating those objects is not complete recovery;
|
||||
- **every retained generation reference** — segments, index runs, and checkpoint
|
||||
generations — so constructing the root transfers ownership of the things that keep those
|
||||
files alive rather than merely naming them;
|
||||
|
|
@ -2056,7 +2108,7 @@ D0 lead skeleton, frozen API, sys/failpoint shims, deps, decisions 9.1-9.5
|
|||
Wave A frozen 2026-07-26 at 5ee9c6b
|
||||
|
|
||||
D0-B lead: roots.rs, completion.rs, RecoveredShard entry point, adoption handle,
|
||||
lib/deps/options/error amendments (9 items, 7 touching frozen files)
|
||||
lib/deps/options/error/recovery amendments (11 items, 9 touching frozen files)
|
||||
|
|
||||
+-- B1 NamespaceTxn ---+
|
||||
+-- B3 StagingSessions ---+--> Wave B freeze gate + adversarial review
|
||||
|
|
@ -2083,7 +2135,8 @@ matrix that can express the class of defect Wave A shipped.
|
|||
|
||||
Sections 9.1-9.6 are resolved, so Wave A was unblocked once D0 landed; 9.7 and 9.8 were ruled
|
||||
on 2026-07-26 in §6.9, and contract review 2026-07-26-A closed the `EvidenceHandoffFailure`
|
||||
conflict, so D0-B is unblocked. The item that was sequenced ahead of A3 —
|
||||
conflict. D0-B is implemented and gate-green, so B1, B3, and B4 dispatch is unblocked. The
|
||||
item that was sequenced ahead of A3 —
|
||||
**contract review 2026-07-24-B**, the `result-schema.json` per-flag
|
||||
conditional with its re-pin requirement and the top-level `promotable`, the added
|
||||
`workload.generator` field, and `reference-hardware.toml`'s `store_directory_attributes` —
|
||||
|
|
|
|||
Loading…
Reference in New Issue