Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions blueprint/engine.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,9 @@ bytes (FSM1/cipher-box-next#28 D2).
version — a discard, a refused preserved entry, or the preserved-set trim —
journals every row the version charged to the retire ledger first, and the
settle retires them once the name holds a record above the sequence its PUT
was acknowledged at (ADR 0054). A publish that fails **before the record reaches
was acknowledged at (ADR 0054). A drop whose staged root is gone, fails its
own CID, or does not decode journals the same debt from the root CID the op
record names (ADR 0059). A publish that fails **before the record reaches
the transport** — register-first, the floor read, the head-CID echo, or an
upload whose ack never came back — is the mirror case: its head block may
already be pinned under its own charged row, no record can name it, and the
Expand Down Expand Up @@ -1480,7 +1482,9 @@ contract-test suite owned by the testing-strategy blueprint (FSM1/cipher-box-nex
version that falls outside the rule loses that reference, and what it owes the
registry is journaled to the retire ledger before the shortened history
publishes. A version a dead letter drops journals its whole target set, root
and leaves, so the settle needs no gateway read (ADR 0054). A write-rotation name wave registers every version's root and
and leaves, so the settle needs no gateway read (ADR 0054). A version whose
staged root does not read journals its root alone, and the settle fetches the
root (ADR 0059). A write-rotation name wave registers every version's root and
leaves at the node's new name before the record moves (ADR 0047). A version
whose root the name wave cannot fetch carries its root alone. Its leaves lose
their reference edges when the old name retires, and stay pinned only because
Expand Down
143 changes: 130 additions & 13 deletions crates/engine/src/net/retire.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ use super::REGISTRY_BATCH_MAX;
use crate::api::{ApiClient, ApiError};
use crate::content::{
ContentPlane, ContentProfile, Expansion, Gateway, RetireTarget, expand_retire_targets,
read_block,
expand_staged_root, read_block,
};
use crate::net::publish::PublishError;
use crate::net::record_publish::RecordPublishError;
Expand Down Expand Up @@ -508,8 +508,8 @@ impl<St: StagingStore> RetireLedger for StagingRetireLedger<'_, St> {
/// - unversioned, read only: `node(16) | owedBytes | manifestBytes | cid`, a
/// [`DebtOrigin::Prune`] debt;
/// - versioned: `ENTRY_V2 | origin | node(16) | owedBytes | manifestBytes |
/// cid`, and for [`DebtOrigin::DroppedVersion`] one `cid | pinnedBytes` per
/// target after it, the root last.
/// cid`; a [`DebtOrigin::DroppedVersion`] entry adds one `cid | pinnedBytes`
/// per target after it, the root last.
///
/// Figures are big-endian `u64`. `cid` is the binary CID the entry is keyed
/// by, which binds the value to its key.
Expand All @@ -523,6 +523,7 @@ fn encode_entry(entry: &OwedRetire, cid: &[u8]) -> SeamResult<Zeroizing<Vec<u8>>
let (origin, targets) = match &entry.origin {
DebtOrigin::Prune => (ORIGIN_PRUNE, None),
DebtOrigin::DroppedVersion(targets) => (ORIGIN_DROPPED_VERSION, Some(targets.as_slice())),
DebtOrigin::DroppedRoot => (ORIGIN_DROPPED_ROOT, None),
};
let pairs = targets.map_or(0, <[RetireTarget]>::len);
let mut stored = Zeroizing::new(Vec::with_capacity(
Expand Down Expand Up @@ -556,6 +557,7 @@ fn encode_entry(entry: &OwedRetire, cid: &[u8]) -> SeamResult<Zeroizing<Vec<u8>>
const ENTRY_V2: u8 = 2;
const ORIGIN_PRUNE: u8 = 0;
const ORIGIN_DROPPED_VERSION: u8 = 1;
const ORIGIN_DROPPED_ROOT: u8 = 2;

/// Whether a dropped version's target set is one the settle may send: it is not
/// empty, it ends at the entry's own root, and its figures sum to the total.
Expand Down Expand Up @@ -589,6 +591,7 @@ fn decode_entry(stored: &[u8], cid: &[u8]) -> Option<OwedRetire> {
let manifest_bytes = u64::from_be_bytes(*manifest);
let origin = match origin_tag {
ORIGIN_PRUNE if tail.is_empty() => DebtOrigin::Prune,
ORIGIN_DROPPED_ROOT if tail.is_empty() => DebtOrigin::DroppedRoot,
ORIGIN_DROPPED_VERSION => {
let targets = decode_targets(tail, cid.len())?;
let root = is_wellformed_content_cid(cid).then(|| encode_content_cid_str(cid))?;
Expand Down Expand Up @@ -730,7 +733,9 @@ where
};
let owing = match (retired, &entry.origin) {
(true, _) => OwingRecord::Retired,
(false, DebtOrigin::DroppedVersion(_)) => OwingRecord::Unconfirmed,
(false, DebtOrigin::DroppedVersion(_) | DebtOrigin::DroppedRoot) => {
OwingRecord::Unconfirmed
}
(false, DebtOrigin::Prune) => OwingRecord::Published,
};
let node = match live_of.entry((entry.node, owing)) {
Expand Down Expand Up @@ -869,13 +874,13 @@ pub enum ReclaimStallReason {
TargetStillLive,
/// The doomed root itself could not be expanded — no source served the block,
/// or the manifest is not this version's — so what the retire would name is
/// unknown. The figure falls back to the ceiling the prune quoted.
/// unknown. The figure falls back to the figure the entry quoted.
TargetUnexpandable,
}

/// One owed entry's whole expansion: the target set it carries, or else its
/// own fetched root block. `None` leaves the entry owed for the figure the
/// prune quoted: a root no source served, or a manifest that is not this
/// entry quoted: a root no source served, or a manifest that is not this
/// version's.
async fn expand_owed<H: Http>(entry: &OwedRetire, source: &RootSource<'_, H>) -> Option<Expansion> {
if let DebtOrigin::DroppedVersion(targets) = &entry.origin {
Expand All @@ -894,13 +899,20 @@ async fn expand_owed<H: Http>(entry: &OwedRetire, source: &RootSource<'_, H>) ->
)
.await
.ok()?;
expand_retire_targets(
&entry.target,
&root_block,
source.profile,
entry.manifest_bytes,
)
.ok()
match entry.origin {
// The op record this device wrote names the root, and the fetch
// verifies the block against it, so no quoted total bounds it.
DebtOrigin::DroppedRoot => {
expand_staged_root(&entry.target, &root_block, source.profile).ok()
}
DebtOrigin::Prune | DebtOrigin::DroppedVersion(_) => expand_retire_targets(
&entry.target,
&root_block,
source.profile,
entry.manifest_bytes,
)
.ok(),
}
}

/// How one owed entry's registry call ended.
Expand Down Expand Up @@ -1767,6 +1779,111 @@ mod tests {
assert_eq!(owed_entries(&store, OWNER), vec![entry]);
}

/// The bytes the previous release wrote for a dropped version, framed by
/// hand so a change to the encoder cannot move them.
#[test]
fn a_dropped_version_the_previous_release_wrote_still_reads() {
let (entry, _) = dropped_version(&[11u8; 100]);
let DebtOrigin::DroppedVersion(targets) = &entry.origin else {
unreachable!("a dropped version carries its targets");
};
let (_, cid) = encoded(&entry);
let mut stored = vec![2u8, 1u8];
stored.extend_from_slice(&entry.node);
stored.extend_from_slice(&entry.owed_bytes.to_be_bytes());
stored.extend_from_slice(&entry.manifest_bytes.to_be_bytes());
stored.extend_from_slice(&cid);
for target in targets {
stored.extend_from_slice(&decode_content_cid_str(&target.cid).unwrap());
stored.extend_from_slice(&target.pinned_bytes.to_be_bytes());
}
assert_eq!(
decode_entry(&stored, &cid),
Some(OwedRetire {
target: String::new(),
..entry
})
);
}

/// The debt a dead letter journals for a version whose staged root did not
/// read, priced at the op record's size.
fn dropped_root(plaintext: &[u8]) -> (OwedRetire, Vec<u8>, Vec<String>) {
let (entry, root_block, leaf_cids) = owed_version(plaintext);
let entry = OwedRetire {
origin: DebtOrigin::DroppedRoot,
..OwedRetire::whole(entry.node, entry.target, plaintext.len() as u64)
};
(entry, root_block, leaf_cids)
}

#[test]
fn a_dropped_root_round_trips_and_a_tail_reads_as_nothing() {
let (entry, ..) = dropped_root(&[12u8; 100]);
let store = InMemoryStagingStore::default();
owe(&store, OWNER, &entry);
assert_eq!(owed_entries(&store, OWNER), vec![entry.clone()]);

let (stored, cid) = encoded(&entry);
assert_eq!(stored[1], ORIGIN_DROPPED_ROOT);
let tailed = [&stored[..], &cid[..], &[0u8; 8]].concat();
assert_eq!(decode_entry(&tailed, &cid), None);
}

/// The settle fetches a dropped root, reads its node as unconfirmed, and
/// retires the whole version under the node's record, off the fetched
/// manifest rather than the op record's size.
#[test]
fn a_dropped_root_settles_off_its_fetched_root_under_the_nodes_record() {
let (entry, root_block, leaf_cids) = dropped_root(&(0..100u8).collect::<Vec<_>>());
let store = InMemoryStagingStore::default();
owe(&store, OWNER, &entry);
let http = ledger_http(&entry, Some(root_block), Some(1));
let asked = RefCell::new(Vec::new());

let pass = drain_with_live(&store, OWNER, &http, async |_, owing| {
asked.borrow_mut().push(owing);
Some(owning(BTreeSet::new()))
});

assert_eq!(asked.into_inner(), vec![OwingRecord::Unconfirmed]);
assert_eq!(
retire_entries(&http),
vec![
(Some(OWNER_NAME.to_owned()), leaf_cids),
(Some(OWNER_NAME.to_owned()), vec![entry.target.clone()])
]
);
assert_eq!(pass.still_owed, 0);
assert!(owed_entries(&store, OWNER).is_empty());
}

/// A dropped root no source serves stays owed, as a stall the host can
/// see, at the figure the drop quoted.
#[test]
fn a_dropped_root_no_source_serves_stalls_at_its_quoted_figure() {
let (entry, ..) = dropped_root(&[13u8; 100]);
let store = InMemoryStagingStore::default();
owe(&store, OWNER, &entry);
let http = ledger_http(&entry, None, Some(1));

let pass = drain_with_live(&store, OWNER, &http, async |_, _| {
Some(owning(BTreeSet::new()))
});

assert_eq!(pass.still_owed, entry.owed_bytes);
assert_eq!(
pass.stalls,
vec![ReclaimStall {
node: NODE,
target: entry.target.clone(),
reason: ReclaimStallReason::TargetUnexpandable,
}]
);
assert_eq!(owed_entries(&store, OWNER), vec![entry]);
assert!(retire_batches(&http).is_empty());
}

/// An acknowledged sequence keeps its highest value, reads only for the
/// name it was held at, and goes when forgotten.
#[test]
Expand Down
8 changes: 7 additions & 1 deletion crates/engine/src/seams/retire_ledger.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ pub enum OwingRecord {
/// permanently unsettleable against a never-discard ledger.
Retired,
/// The node's record may carry a version a dead letter dropped
/// ([`DebtOrigin::DroppedVersion`]), or may never have published.
/// ([`DebtOrigin::DroppedVersion`], [`DebtOrigin::DroppedRoot`]), or may
/// never have published.
///
/// A record at or below the node's acknowledged sequence, or one the
/// endpoints serve tied with other bytes, stands the entry down: a PUT of
Expand All @@ -47,6 +48,11 @@ pub enum DebtOrigin {
/// the root last, each with its pinned bytes. The owing node reads as
/// [`OwingRecord::Unconfirmed`].
DroppedVersion(Vec<RetireTarget>),
/// A dead letter dropped a staged version whose root did not give a target
/// set, so only the root CID the op record names is journaled, and the
/// settle fetches the root. The owing node reads as
/// [`OwingRecord::Unconfirmed`] (ADR 0059 D1).
DroppedRoot,
}

/// One owed retirement: a doomed version's **root** `contentCid` and the pinned
Expand Down
Loading
Loading