diff --git a/CHANGELOG.md b/CHANGELOG.md index a59524295..d9f8dc575 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +- **R-15 Gate 4D:** the conversion session enters `CONVERT` and moves the checkpoint cursor (part b-2 of the + owner's steps through the gate). `ConversionSession::enter_convert` (idempotent: a session already in + `CONVERT` writes nothing) and `advance_cursor` (forward only, inside the inventory, in `CONVERT` only) share + one private step with `renew_lease`, which now commits through `commit_lease_renewal_held` or the new + `commit_journal_checkpoint_held`, and every outcome after a landed commit is followed by the admission + check. The cursor-driven iteration is the next part. PR #1015. - **R-15 Gate 4D:** the conversion session renews its lease (part b-1 of the owner's steps through the gate). `ConversionSession::renew_lease` builds the renewal from the session's own snapshot (`renewed_lease`), takes the root event through the held admission, commits it with the key route and active epoch the diff --git a/crates/worldscript-secure-storage/src/authority.rs b/crates/worldscript-secure-storage/src/authority.rs index 75481f8e2..2ff455715 100644 --- a/crates/worldscript-secure-storage/src/authority.rs +++ b/crates/worldscript-secure-storage/src/authority.rs @@ -362,6 +362,20 @@ pub fn commit_journal_checkpoint( check_operation_id(&checkpoint.manifest.operation_id) .map_err(|_| AuthorityError::InvalidOperationId)?; let held = acquire_root_commit(layout)?; + commit_journal_checkpoint_held(fs, provider, layout, checkpoint, &held) +} + +/// As [`commit_journal_checkpoint`], under a root event the caller already holds, as +/// [`commit_lease_renewal_held`] is for the renewal. `held` must guard the root of `layout`. +pub(crate) fn commit_journal_checkpoint_held( + fs: &mut F, + provider: &mut P, + layout: RootLayout<'_>, + checkpoint: JournalCheckpoint<'_>, + held: &RootCommitGuard, +) -> Result { + check_operation_id(&checkpoint.manifest.operation_id) + .map_err(|_| AuthorityError::InvalidOperationId)?; let commit = journal_catalog_commit(&checkpoint); let committed = committed_binding(fs, provider, layout, commit)?; let key = route_journal_key(fs, provider, layout, checkpoint.journal)?; @@ -382,7 +396,7 @@ pub fn commit_journal_checkpoint( provider, layout, commit, - &held, + held, Some(JournalStep { binding: BindingStep::Checkpoint(&advance), key: &key, diff --git a/crates/worldscript-secure-storage/src/conversion.rs b/crates/worldscript-secure-storage/src/conversion.rs index 6b73ba71f..cd5a43e64 100644 --- a/crates/worldscript-secure-storage/src/conversion.rs +++ b/crates/worldscript-secure-storage/src/conversion.rs @@ -31,8 +31,9 @@ //! still the caller's while nobody has taken over (the fence arbitrates). //! //! Beginning writes nothing and holds no key. The session then moves the journal only through the -//! fenced journal-owner operations, one step at a time ([`ConversionSession::renew_lease`] is the -//! first). A step checks the admission and the journal directory, builds the successor from the +//! fenced journal-owner operations, one step at a time ([`ConversionSession::renew_lease`], +//! [`ConversionSession::enter_convert`] and [`ConversionSession::advance_cursor`], which share one +//! private step). A step checks the admission and the journal directory, builds the successor from the //! session's own snapshot, takes the root event through the held admission (so the identity check and //! the root lock are coupled), commits with the key route and active epoch the committed root itself //! names, reads the committed journal back under that same root event and checks the admission again. @@ -47,22 +48,24 @@ //! it names the successor (committed, even if the commit reported an error), or it does not (not //! committed, snapshot kept). If the read-back fails, or names a journal that is neither of the two //! where the commit reported success, the session is spent: every later step is refused and the caller -//! begins again to learn the state. A lost admission is reported even when it was lost after the -//! commit; the snapshot then is the committed journal. +//! begins again to learn the state. After the commit call every outcome is followed by the admission +//! check, and a lost admission is reported first: the step may have committed or left a candidate, and +//! the caller begins again to learn the state. use std::fs::File; use std::path::{Path, PathBuf}; use crate::admission::{AdmissionError, AdmissionScope, ExclusiveAdmissionGuard}; use crate::authority::{ - commit_lease_renewal_held, read_committed_journal, AuthorityError, CommittedJournal, - JournalCheckpoint, JournalSource, + commit_journal_checkpoint_held, commit_lease_renewal_held, read_committed_journal, + AuthorityError, CommittedJournal, JournalCheckpoint, JournalSource, }; use crate::durable::{DurableFs, WriteOperationId}; use crate::error::SealError; use crate::journal::{ - phase_code, renewed_lease, CandidateConflict, JournalManifest, MigrationExecutionError, - MigrationFence, + checkpoint_progress, phase_code, renewed_lease, transition_phase, CandidateConflict, + JournalCheckpointCursor, JournalManifest, MigrationExecutionError, MigrationFence, + MigrationPhase, }; use crate::provider::KeyProvider; use crate::root::LiveMigration; @@ -106,13 +109,15 @@ pub enum ConversionError { FinalInventoryNotCaptured, /// The committed lease is owned by another owner, or by none. ForeignLeaseOwner, - /// A step's successor is not a valid one (a renewal with no lease to renew, or an expiry that does - /// not move strictly forward). + /// A step's successor is not a valid one (a renewal with no lease to renew, an expiry that does not + /// move strictly forward, a cursor that regresses or lies outside the inventory). Migration(MigrationExecutionError), + /// The step belongs to another phase: the cursor moves in `CONVERT` only. + WrongPhase, /// No write-operation identifier could be drawn from the operating system. OperationId(SealError), - /// The step committed, but the journal read back is not the one it committed: the session is - /// spent, begin again. + /// The committed journal is not the one this session holds (a step's read-back named another + /// journal, or another owner advanced it since): the session is spent, begin again. Superseded, /// The step committed, but the committed journal could not be read back: the session is spent, /// begin again. @@ -167,6 +172,14 @@ impl PinnedDirectory { } } +/// Which journal-owner operation commits a step's successor: the lease renewal and the ordinary +/// checkpoint are different operations over the same root event. +#[derive(Debug, Clone, Copy)] +enum StepKind { + Renewal, + Checkpoint, +} + /// The exclusive conversion entry: the committed journal state, read under an admission that no /// ordinary operation can share. #[derive(Debug)] @@ -276,9 +289,7 @@ impl<'a> ConversionSession<'a> { /// /// The expiry must move strictly forward (`Migration(InvalidLeaseRenewal)`); a lease long past its /// expiry is still renewed while nobody has taken over, because a takeover advances the fence and - /// the committed manifest then names another owner. The renewal is committed by - /// [`commit_lease_renewal_held`] under the root event the admission hands out, so a snapshot that - /// went stale is refused before any write. See the module documentation for what a failed step + /// the committed manifest then names another owner. See the module documentation for what a step /// leaves behind. pub fn renew_lease( &mut self, @@ -286,12 +297,104 @@ impl<'a> ConversionSession<'a> { provider: &mut P, expires_unix_ms: u64, ) -> Result { + self.step(fs, provider, StepKind::Renewal, |manifest, fence| { + renewed_lease(manifest, fence, expires_unix_ms).map_err(ConversionError::Migration) + }) + } + + /// Enters `CONVERT` from `ADMIT`, at cursor `(0, 0)` (§10.3), and returns the root commit. A + /// session that already is in `CONVERT` (a resumed conversion) has nothing to do: nothing is + /// written and `None` is returned. + pub fn enter_convert( + &mut self, + fs: &mut F, + provider: &mut P, + ) -> Result, ConversionError> { + self.ready()?; + if self.journal.manifest.phase == phase_code::CONVERT { + // Nothing to write, but the answer must not rest on a snapshot another owner has overtaken. + self.confirm_snapshot(fs, provider)?; + return Ok(None); + } + let convert = MigrationPhase::from_wire(phase_code::CONVERT); + self.step(fs, provider, StepKind::Checkpoint, |manifest, fence| { + transition_phase(manifest, fence, convert).map_err(ConversionError::Migration) + }) + .map(Some) + } + + /// Records durable progress in `CONVERT`: moves the checkpoint cursor to `cursor` and returns the + /// root commit. The cursor never moves backwards and stays inside the manifest's extent, its page + /// count and its total entry count (`Migration(RegressiveCheckpoint)` and the extent refusals, + /// before any write); an equal cursor is accepted and records a revision with the same cursor. The + /// manifest does not carry the entry count of a page, so this does not check the entry index + /// against the selected page: what the index means within a page is fixed by the iteration that + /// reads the pages. In any other phase the step is `WrongPhase`. + pub fn advance_cursor( + &mut self, + fs: &mut F, + provider: &mut P, + cursor: JournalCheckpointCursor, + ) -> Result { + self.step(fs, provider, StepKind::Checkpoint, |manifest, fence| { + if manifest.phase != phase_code::CONVERT { + return Err(ConversionError::WrongPhase); + } + checkpoint_progress(manifest, fence, cursor).map_err(ConversionError::Migration) + }) + } + + /// Requires the committed journal to be the one the session holds. If it is not (another owner + /// advanced it), the session is spent; if it cannot be read, nothing was written and the step may + /// be tried again. + fn confirm_snapshot( + &mut self, + fs: &mut F, + provider: &P, + ) -> Result<(), ConversionError> { + let operation = WriteOperationId::generate().map_err(ConversionError::OperationId)?; + let layout = RootLayout { + root_dir: &self.root, + }; + let journal = JournalSource { + dir: &self.journal_pin.canonical, + operation: &operation, + }; + match read_committed_journal(fs, provider, layout, journal) { + Ok(current) if current.manifest == self.journal.manifest => self.check_admitted(), + Ok(_) => { + self.spent = true; + Err(ConversionError::Superseded) + } + Err(error) => Err(ConversionError::Authority(error)), + } + } + + /// Whether the session may take a step: not spent, and the admission and the pinned journal + /// directory still hold. + fn ready(&self) -> Result<(), ConversionError> { if self.spent { return Err(ConversionError::Spent); } - self.check_admitted()?; - let renewed = renewed_lease(&self.journal.manifest, &self.fence(), expires_unix_ms) - .map_err(ConversionError::Migration)?; + self.check_admitted() + } + + /// The step shared by the public methods: `build` makes the successor from the session's snapshot + /// and `kind` says which journal-owner operation commits it. See the module documentation. + fn step( + &mut self, + fs: &mut F, + provider: &mut P, + kind: StepKind, + build: B, + ) -> Result + where + F: DurableFs, + P: KeyProvider, + B: FnOnce(&JournalManifest, &MigrationFence) -> Result, + { + self.ready()?; + let successor = build(&self.journal.manifest, &self.fence())?; let operation = WriteOperationId::generate().map_err(ConversionError::OperationId)?; let layout = RootLayout { root_dir: &self.root, @@ -300,9 +403,9 @@ impl<'a> ConversionSession<'a> { dir: &self.journal_pin.canonical, operation: &operation, }; - let fence = MigrationFence::from_manifest(&renewed); + let fence = MigrationFence::from_manifest(&successor); let step = JournalCheckpoint { - manifest: &renewed, + manifest: &successor, fence: &fence, journal, root_key_ref: &self.journal.root_key_ref, @@ -316,7 +419,12 @@ impl<'a> ConversionSession<'a> { .map_err(ConversionError::Admission)? .ok_or(ConversionError::RootBusy)?; let root = event.root_guard().map_err(ConversionError::Admission)?; - let committed = commit_lease_renewal_held(fs, provider, layout, step, root); + let committed = match kind { + StepKind::Renewal => commit_lease_renewal_held(fs, provider, layout, step, root), + StepKind::Checkpoint => { + commit_journal_checkpoint_held(fs, provider, layout, step, root) + } + }; // Whatever the commit reported, read the root back while the event is still held: no other // root commit comes between, and the root says whether the step committed. ( @@ -324,11 +432,10 @@ impl<'a> ConversionSession<'a> { read_committed_journal(fs, &*provider, layout, journal), ) }; - let settled = self.settle(&renewed, committed, read_back); - // A step that landed, whatever the commit reported, is followed by the admission check. - if matches!(settled, Ok(_) | Err(ConversionError::Committed(_))) { - self.check_admitted()?; - } + let settled = self.settle(&successor, committed, read_back); + // Every outcome of the commit call is followed by the admission check, and a lost admission is + // reported first: the step may have committed or left a candidate, whatever it reported. + self.check_admitted()?; settled } diff --git a/crates/worldscript-secure-storage/tests/gate4d_conversion_entry_test.rs b/crates/worldscript-secure-storage/tests/gate4d_conversion_entry_test.rs index d0717b535..1f9e04592 100644 --- a/crates/worldscript-secure-storage/tests/gate4d_conversion_entry_test.rs +++ b/crates/worldscript-secure-storage/tests/gate4d_conversion_entry_test.rs @@ -2,7 +2,8 @@ //! needs the installation's exclusive admission, re-reads the journal the committed root vouches for //! and refuses anything but the owner's conversion; it writes nothing. Every scenario therefore //! commits a real root that binds a real journal. C2b-1: the session renews its lease through the -//! fenced journal-owner operation and follows the root. +//! fenced journal-owner operation and follows the root. C2b-2: it enters `CONVERT` and moves the +//! checkpoint cursor through the same step. use std::collections::BTreeMap; use std::ffi::OsString; @@ -20,12 +21,12 @@ use worldscript_secure_storage::{ operation_type, phase_code, write_key_epoch, AdmissionScope, AuthorityError, CandidateConflict, CatalogChange, CatalogCommit, ConversionBegin, ConversionError, ConversionSession, DirectoryDurability, DurableFs, ExclusiveAdmissionGuard, InstallationScopeId, - JournalCheckpoint, JournalDurableError, JournalManifest, JournalRouteError, JournalSource, - JournalTakeoverCommit, Key, KeyEpochCommit, KeyEpochRecord, KeyEpochStatus, KeyProvider, - LiveMigration, MigrationExecutionError, MigrationFence, RecordClass, RecordIdentity, - RecordMeta, RootBody, RootCommitEvidence, RootCommitGuard, RootCommitRequest, RootCommitState, - RootKeyRefV1, RootLayout, StdFs, WriteOperationId, JOURNAL_MANIFEST_RECORD_SCHEMA, - OPERATION_ADMISSION_LOCK_FILE, + JournalCheckpoint, JournalCheckpointCursor, JournalDurableError, JournalError, JournalManifest, + JournalRouteError, JournalSource, JournalTakeoverCommit, Key, KeyEpochCommit, KeyEpochRecord, + KeyEpochStatus, KeyProvider, LiveMigration, MigrationExecutionError, MigrationFence, + RecordClass, RecordIdentity, RecordMeta, RootBody, RootCommitEvidence, RootCommitGuard, + RootCommitRequest, RootCommitState, RootKeyRefV1, RootLayout, StdFs, WriteOperationId, + JOURNAL_MANIFEST_RECORD_SCHEMA, OPERATION_ADMISSION_LOCK_FILE, }; const OPERATION: &str = "conversion-op"; @@ -1091,3 +1092,279 @@ fn an_admission_lost_after_a_commit_that_reported_an_error_is_still_reported() { (Err(ConversionError::NotAdmitted), Ok(Some(RENEWED_EXPIRY))) ); } + +/// A manifest of `phase` over an inventory of two pages and ten entries, so that a cursor can move. +fn with_extent(phase: u32) -> JournalManifest { + let mut manifest = manifest(phase); + (manifest.page_count, manifest.entry_count) = (2, 10); + manifest +} + +/// The manifest the committed `from` becomes after a step that advances its revision. +fn next_revision(from: &JournalManifest) -> JournalManifest { + let mut next = from.clone(); + next.journal_revision += 1; + next +} + +#[test] +fn entering_convert_moves_only_the_phase_and_the_revision_and_a_second_call_writes_nothing() { + let committed = with_extent(phase_code::ADMIT); + let mut expected = next_revision(&committed); + expected.phase = phase_code::CONVERT; + let mut fixture = Fixture::bound(&committed); + let generation = fixture.root_generation(); + let (entered, again, snapshot) = { + let (paths, mut held) = (fixture.paths(), fixture.paths().admission()); + let mut session = open(&fixture, &paths, &mut held); + let entered = session + .enter_convert(&mut StdFs, &mut fixture.provider) + .map(|commit| commit.map(|c| c.root_generation == generation + 1)); + let before = fixture.snapshot(); + let again = session.enter_convert(&mut StdFs, &mut fixture.provider); + let unchanged = fixture.snapshot() == before; + let token = MigrationFence::from_manifest(&expected); + ( + entered, + (again, unchanged), + (session.manifest() == &expected, session.fence() == token), + ) + }; + assert_eq!( + (entered, again, snapshot), + (Ok(Some(true)), (Ok(None), true), (true, true)) + ); + // A fresh gate reads what the root now names: a resumed conversion. + assert_eq!(fixture.committed(OWNER), Ok(expected)); +} + +#[test] +fn a_resumed_session_in_convert_has_nothing_to_enter() { + let mut fixture = Fixture::bound(&with_extent(phase_code::CONVERT)); + let before = fixture.snapshot(); + let outcome = { + let (paths, mut held) = (fixture.paths(), fixture.paths().admission()); + let mut session = open(&fixture, &paths, &mut held); + session.enter_convert(&mut StdFs, &mut fixture.provider) + }; + assert_eq!((outcome, fixture.snapshot() == before), (Ok(None), true)); +} + +#[test] +fn the_cursor_moves_forward_in_convert_and_never_backwards_or_outside_the_inventory() { + let mut fixture = Fixture::bound(&with_extent(phase_code::CONVERT)); + let cursors = [(0, 3), (1, 2), (1, 2), (0, 5), (2, 0), (1, 10)]; + let (outcomes, snapshot) = { + let (paths, mut held) = (fixture.paths(), fixture.paths().admission()); + let mut session = open(&fixture, &paths, &mut held); + let outcomes: Vec<_> = cursors + .iter() + .map(|&(page, entry)| { + let cursor = JournalCheckpointCursor::new(page, entry); + let step = session.advance_cursor(&mut StdFs, &mut fixture.provider, cursor); + step.map(|_| ()) + }) + .collect(); + let manifest = session.manifest(); + ( + outcomes, + ( + manifest.cursor_page_index, + manifest.cursor_entry_index, + manifest.journal_revision, + ), + ) + }; + let refused = |error| Err(ConversionError::Migration(error)); + let expected = vec![ + Ok(()), + Ok(()), + Ok(()), + refused(MigrationExecutionError::RegressiveCheckpoint), + refused(MigrationExecutionError::Journal( + JournalError::InvalidPageIndex, + )), + refused(MigrationExecutionError::Journal( + JournalError::EntryCountMismatch, + )), + ]; + // Three accepted checkpoints, the last at the same cursor as the one before; nothing else moved. + assert_eq!((outcomes, snapshot), (expected, (1, 2, REVISION + 3))); + let committed = fixture.committed(OWNER).unwrap(); + assert_eq!( + (committed.cursor_page_index, committed.cursor_entry_index), + (1, 2) + ); +} + +#[test] +fn the_cursor_does_not_move_outside_convert() { + let mut fixture = Fixture::bound(&with_extent(phase_code::ADMIT)); + let before = fixture.snapshot(); + let outcome = { + let (paths, mut held) = (fixture.paths(), fixture.paths().admission()); + let mut session = open(&fixture, &paths, &mut held); + let cursor = JournalCheckpointCursor::new(0, 1); + session + .advance_cursor(&mut StdFs, &mut fixture.provider, cursor) + .map(|_| ()) + }; + assert_eq!( + (outcome, fixture.snapshot() == before), + (Err(ConversionError::WrongPhase), true) + ); +} + +#[test] +fn a_session_that_another_owner_took_over_from_cannot_move_the_cursor() { + let committed = with_extent(phase_code::CONVERT); + let mut fixture = Fixture::bound(&committed); + let (paths, mut held) = (fixture.paths(), fixture.paths().admission()); + let mut session = open(&fixture, &paths, &mut held); + fixture.take_over(&committed); + let before = fixture.snapshot(); + let cursor = JournalCheckpointCursor::new(0, 1); + let outcome = session + .advance_cursor(&mut StdFs, &mut fixture.provider, cursor) + .map(|_| ()); + let stale = AuthorityError::Journal(JournalDurableError::Authority( + MigrationExecutionError::StaleMigrationOwner, + )); + assert_eq!(outcome, Err(ConversionError::Authority(stale))); + assert_eq!(fixture.snapshot(), before); +} + +#[test] +fn the_lease_is_renewed_after_entering_convert() { + let committed = with_extent(phase_code::ADMIT); + let mut expected = next_revision(&next_revision(&committed)); + expected.phase = phase_code::CONVERT; + expected.lease_expires_unix_ms = Some(RENEWED_EXPIRY); + let mut fixture = Fixture::bound(&committed); + let outcome = { + let (paths, mut held) = (fixture.paths(), fixture.paths().admission()); + let mut session = open(&fixture, &paths, &mut held); + session + .enter_convert(&mut StdFs, &mut fixture.provider) + .unwrap(); + session + .renew_lease(&mut StdFs, &mut fixture.provider, RENEWED_EXPIRY) + .map(|_| ()) + }; + assert_eq!((outcome, fixture.committed(OWNER)), (Ok(()), Ok(expected))); +} + +/// A hook that fails the `total`-th read and, at the same moment, moves the installation away. +#[cfg(unix)] +fn failing_and_moving( + total: usize, + installation: PathBuf, + moved: PathBuf, +) -> impl FnMut(&Path) -> io::Result<()> { + let mut seen = 0; + move |_: &Path| { + seen += 1; + if seen == total { + fs::rename(&installation, &moved).unwrap(); + Err(io::Error::other("injected")) + } else { + Ok(()) + } + } +} + +#[cfg(unix)] +#[test] +fn an_admission_lost_after_an_unreadable_or_unsettled_step_is_reported_first() { + // The step committed (or may have) and the read-back fails while the installation is moved away: + // the caller is told the admission is gone, not only that the session is spent. + let faults = [None, Some(Fault::AfterPersist(AnchorOp::Commit))]; + let mut outcomes = Vec::new(); + let mut committed = Vec::new(); + for fault in faults { + let total = renewal_reads(fault); + let mut fixture = Fixture::bound(&manifest(phase_code::ADMIT)); + if let Some(fault) = fault { + fixture.provider.inject(fault); + } + let installation = fixture.base.0.clone(); + let moved = installation.with_extension("moved"); + let hook = failing_and_moving(total, installation.clone(), moved.clone()); + outcomes.push(renewal_watched(&mut fixture, hook)); + fs::rename(&moved, &installation).unwrap(); + committed.push(fixture.committed(OWNER).map(|m| m.lease_expires_unix_ms)); + } + assert_eq!( + (outcomes, committed), + ( + vec![Err(ConversionError::NotAdmitted); 2], + vec![Ok(Some(RENEWED_EXPIRY)); 2] + ) + ); +} + +#[test] +fn a_spent_session_in_convert_does_not_claim_there_is_nothing_to_enter() { + let total = renewal_reads(None); + let mut fixture = Fixture::bound(&with_extent(phase_code::CONVERT)); + let mut watched = WatchedFs { + on_read: failing_nth_read(total), + }; + let (paths, mut held) = (fixture.paths(), fixture.paths().admission()); + let mut session = open(&fixture, &paths, &mut held); + let spent = session.renew_lease(&mut watched, &mut fixture.provider, RENEWED_EXPIRY); + let entered = session.enter_convert(&mut StdFs, &mut fixture.provider); + let is_unreadable = matches!(spent, Err(ConversionError::Unreadable(_))); + assert_eq!( + (is_unreadable, entered), + (true, Err(ConversionError::Spent)) + ); +} + +#[test] +fn a_no_op_enter_convert_is_not_answered_from_a_snapshot_another_owner_overtook() { + let committed = with_extent(phase_code::CONVERT); + let mut fixture = Fixture::bound(&committed); + let (paths, mut held) = (fixture.paths(), fixture.paths().admission()); + let mut session = open(&fixture, &paths, &mut held); + fixture.take_over(&committed); + let before = fixture.snapshot(); + let first = session.enter_convert(&mut StdFs, &mut fixture.provider); + let later = session.enter_convert(&mut StdFs, &mut fixture.provider); + assert_eq!( + (first, later, fixture.snapshot() == before), + ( + Err(ConversionError::Superseded), + Err(ConversionError::Spent), + true + ) + ); +} + +#[cfg(unix)] +#[test] +fn an_admission_lost_after_a_refusal_that_left_a_candidate_is_reported_first() { + // The anchor refuses the root's preparation: the renewal is already in the journal as revision + // `r + 1`, the root still names `r`, and the step reports a refusal. The installation is moved away + // right after the last read, so the loss is reported instead of the refusal. + let refusal = Fault::BeforePersist(AnchorOp::Prepare); + let total = renewal_reads(Some(refusal)); + let mut fixture = Fixture::bound(&manifest(phase_code::ADMIT)); + fixture.provider.inject(refusal); + let installation = fixture.base.0.clone(); + let moved = installation.with_extension("moved"); + let mut seen = 0; + let outcome = renewal_watched(&mut fixture, |_| { + seen += 1; + if seen == total { + fs::rename(&installation, &moved).unwrap(); + } + Ok(()) + }); + fs::rename(&moved, &installation).unwrap(); + let candidate = generation_path(&fixture.journal_dir(), REVISION + 1).exists(); + assert_eq!( + (outcome, candidate), + (Err(ConversionError::NotAdmitted), true) + ); +} diff --git a/docs/native/R15-SECURE-STORAGE-CONTRACT.md b/docs/native/R15-SECURE-STORAGE-CONTRACT.md index 86dbe323a..1185d6ca0 100644 --- a/docs/native/R15-SECURE-STORAGE-CONTRACT.md +++ b/docs/native/R15-SECURE-STORAGE-CONTRACT.md @@ -2404,10 +2404,16 @@ the snapshot as they were; a retry rebuilds the same successor, which the journa identical to a candidate already written, and a differing candidate of a crashed attempt is moved aside with its bytes preserved, so it never blocks the next step. If the read-back fails, or names a journal that is neither of the two where the commit reported success, the session is spent and every later step -is refused until the caller begins again. The first step is the owner's lease renewal at -an expiry the caller chooses: strictly after the held one, accepted after the held one has lapsed while -nobody has taken over, and refused as a stale owner once another owner has; the root commit, with its -durability result, is returned. A lost admission is reported even when it was lost after the commit. +is refused until the caller begins again. The steps are the owner's lease renewal at an expiry the +caller chooses (strictly after the held one, accepted after the held one has lapsed while nobody has taken +over, and refused as a stale owner once another owner has), entering `CONVERT` from `ADMIT` at cursor +`(0, 0)` (a session that already is in `CONVERT` has nothing to write, but it confirms against the root +that its snapshot is still the committed journal before it says so), and the checkpoint cursor, which in +`CONVERT` only moves forward and stays inside the manifest's extent (its page count and total entry count; +the manifest does not carry the entry count of a page, so what an entry index means within a page is fixed +by the page iteration, the next slice); each returns the root commit with its durability result. After the +commit call every outcome is followed by the admission check, and a lost admission is reported first: the +step may have committed or left a candidate, and the caller begins again to learn the state. A token check performed as a separate preflight is insufficient. Every mutation-capable adapter operation therefore exposes the semantic equivalent of `with_fence(fencing_generation, mutation_and_durability)`: it acquires the cross-process migration diff --git a/docs/native/r15/GATE4D-JOURNAL-DURABLE-EVIDENCE.md b/docs/native/r15/GATE4D-JOURNAL-DURABLE-EVIDENCE.md index cf1dc8832..b9a5893ec 100644 --- a/docs/native/r15/GATE4D-JOURNAL-DURABLE-EVIDENCE.md +++ b/docs/native/r15/GATE4D-JOURNAL-DURABLE-EVIDENCE.md @@ -665,6 +665,37 @@ after authentication (D2b-2), and resolving the journal key through the authenti readability proof for a root already at the target epoch (D2b-3a), used by every journal-owner operation (D2b-3b). Each is described in its own section below. +## Slice C2b-2 — the session enters `CONVERT` and moves the cursor + +C2b-1 gave the conversion session its first step. The other two steps differ from it only in the pure successor +builder and in the journal-owner operation that commits it, so the three share one private step and one +finalisation rule. + +| Step | Rule | +|---|---| +| shared step | spent and admission checks; the successor built from the session's snapshot; root event through the held admission; the committed journal read back under it; the root settles the outcome; canonical paths, the pinned journal, `Quarantine`, the commit returned (all as C2b-1) | +| commit | `StepKind::Renewal` commits through `commit_lease_renewal_held`, `StepKind::Checkpoint` through the new `commit_journal_checkpoint_held`, the body of `commit_journal_checkpoint` under a root event the caller holds (that function acquires the lock and delegates) | +| `enter_convert` | `transition_phase` to `CONVERT` (cursor `(0, 0)`, revision plus one, fence kept, `final_inventory_captured` required by the pure function); a session already in `CONVERT` writes nothing and returns `None`, after a spent check and a read of the committed journal that must still equal its snapshot (otherwise `Superseded` and the session is spent: another owner took over); an unreadable root is the read error and nothing was written | +| `advance_cursor` | `CONVERT` only (`WrongPhase` otherwise), then `checkpoint_progress`: never backwards (`RegressiveCheckpoint`), inside the manifest's extent, its page count and total entry count (`InvalidPageIndex`, `EntryCountMismatch`), an equal cursor accepted as a revision with the same cursor; all refused before any write. The manifest does not carry the entry count of a page, so the entry index is not checked against the selected page: that, and what the index means within a page, is the page iteration's contract (C2c) | +| finalisation | after the commit call every outcome, a refusal included (it may follow a published candidate), is followed by the admission check, `NotAdmitted` taking precedence, which closes the criterion recorded from the review of #1014 | + +Decisions, disclosed: (a) one private step shared by the three public methods, so they cannot drift, with the +renewal's behaviour and tests unchanged; (b) `enter_convert` is idempotent so a resumed session can call it +unconditionally; (c) the cursor moves in `CONVERT` only and an equal cursor is a valid checkpoint, as +`checkpoint_progress` defines it; (d) the admission check follows every outcome of the commit call, `Unsettled` and a refusal included, because whether the step +landed or left a candidate is not always known; (e) a new `_held` function rather than a flag on the existing one. + +Proof (`gate4d_conversion_entry_test`): `ADMIT` -> `CONVERT` moves the phase and the revision only, the session and a +fresh gate agree and the root is one generation further; a second call and a resumed session in `CONVERT` write +nothing; three forward checkpoints (one at the same cursor) are accepted and a regressive cursor, a page past the +extent and an entry past the extent are refused with nothing written; the cursor in `ADMIT` is `WrongPhase`; a session +another owner took over from is refused before any write, and a no-op `enter_convert` on such a session is `Superseded` rather +than a stale `None`; the lease is renewed after entering `CONVERT`; with the +installation moved away at the same moment as the read-back fails, the admission loss is reported first both when the +read-back follows a successful commit and when the commit reported an error, and after a refusal that left a candidate. +Mutation-checked: the idempotence, the spent check and the snapshot confirmation of the no-op, the phase check of the +cursor, the commit dispatch and the finalisation rule, removed one at a time, fail the test that owns them. + ## Slice C2b-1 — the session renews its lease The conversion session of C2a could only be read, and the D3b renewal had no caller. `ConversionSession::renew_lease` @@ -1073,7 +1104,7 @@ this API's reach; the journal key route of D2b-3 fails closed on a `Revoked` or (`DurableFs::read_at_most`) and the page-directory listing is bounded (`DurableFs::list_dir_at_most`; both defaults must be overridden by an adapter over real files, which `StdFs` does), but the Gate 3 post-promotion verify and the page, marker and root reads still use the whole-file `DurableFs::read`. Applying the same size limits to them is a separate slice, recorded as an acceptance criterion on #359. -- Conversion over the verified page set: the entry gate (C2a: exclusive admission by construction, the root-bound manifest re-read, `final_inventory_captured = 1`; maintainer decision D) and the lease renewal through it (C2b-1: `ConversionSession::renew_lease`, the first caller of the D3b renewal) exist, but nothing yet enters `CONVERT` or moves the cursor under the gate: entering `CONVERT` and advancing the cursor (C2b-2), the cursor-driven iteration with a per-entry step and the crash/resume evidence (C2c). +- Conversion over the verified page set: the entry gate (C2a: exclusive admission by construction, the root-bound manifest re-read, `final_inventory_captured = 1`; maintainer decision D) the lease renewal, entering `CONVERT` and the cursor through it (C2b-1 and C2b-2: `ConversionSession::renew_lease`, `enter_convert`, `advance_cursor`) exist, but nothing yet iterates the verified page set: the cursor-driven iteration with a per-entry step and the crash/resume evidence (C2c), then `CONVERT` -> `VERIFY`. - Write barrier of the final capture: `commit_inventory_capture` takes no admission guard; the barrier is the durable `ADMIT` phase the orchestrator establishes by draining writers, and the write path must refuse ordinary mutating writes by that phase (`ordinary_mutating_writes_admitted`) before the final capture has a caller (Gate 4E/5; acceptance criterion on #359). - Inheriting unchanged pages: the C1b-2 reader now returns the authenticated page references, so the store may accept a page that keeps an earlier generation if those references name exactly its bytes (acceptance criterion on #359, a follow-up slice). Until then every page of a capture is rewritten at the new revision. - Reclaiming abandoned pending directories: an attempt that was killed, or a finished capture dropped without `discard`, leaves inert files under `inventory/pending-*`, and every attempt leaves its empty page directories; none is authority or ever read. Reclaiming them needs a directory-removal primitive and a sweep that knows no live attempt owns them, as for the orphaned digest directories of a discarded capture (acceptance criterion on #359).