From 936bca08518346146aca45da74c05a1bbf1104c8 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Mon, 26 Oct 2026 16:09:43 +0100 Subject: [PATCH 1/8] mason: v3 registry 2/2 base-reference schema and Windows free-space probe Adds the base_refs, base_builds, strict_rebuild, base_sweeps and sweep_state tables as registry format/schema 2/2 (reserved for slice 4 in docs/design/views-v3-format.md), installed only by an exact-predecessor upgrade from 1/1 after the registry reader floor is raised to 2. Registries created by the 3a copy path stay 1/1. views_v3_registry is now advertised as readable at 2, so reader_floor's advertisement test and the v3 newer-format refusal test (which used 2 as 'newer') move to 2 and 3 respectively. Also: barrier-only registry opens (no covering measurement), a plane-row sweep over the family's v3 plane stores, LedgerReader member/read helpers, and GetDiskFreeSpaceExW for the non-unix free-space probe, which 3a left unsupported. Co-authored-by: alfonso-aft <289616620+alfonso-aft@users.noreply.github.com> --- crates/aft/src/blob_store/v3.rs | 344 +++++++++++++++++++++++++- crates/aft/src/blob_store/v3/tests.rs | 8 +- crates/aft/src/persisted_format.rs | 3 +- crates/aft/src/reader_floor.rs | 10 +- 4 files changed, 352 insertions(+), 13 deletions(-) diff --git a/crates/aft/src/blob_store/v3.rs b/crates/aft/src/blob_store/v3.rs index ea4e4dc7b0..9f53cd3e10 100644 --- a/crates/aft/src/blob_store/v3.rs +++ b/crates/aft/src/blob_store/v3.rs @@ -42,7 +42,15 @@ mod replay; pub use copy::{copy_on_first_use, CopyOutcome, CopySource, SWEEP_LEASE_WAIT}; const META: &str = "CREATE TABLE format_meta (singleton INTEGER PRIMARY KEY CHECK(singleton=1), format_version INTEGER NOT NULL, schema_version INTEGER NOT NULL, layout_tag INTEGER NOT NULL, schema_text TEXT NOT NULL);"; -const REGISTRY_SCHEMA: &str = " +/// Registry format/schema version of the base-reference protocol +/// (`views_v3_registry` 2/2 in docs/design/views-v3-format.md). A version-1 +/// registry is upgraded in place, by the exact-predecessor migration in +/// [`RegistryWriter::upgrade_to_base_refs`], only by a writer that raised the +/// registry reader floor to 2 first. +pub const REGISTRY_BASE_REFS_VERSION: u32 = 2; +macro_rules! registry_v1_schema { + () => { + " CREATE TABLE members ( scope TEXT NOT NULL PRIMARY KEY, kind TEXT NOT NULL CHECK(kind IN ('view','base')), root BLOB, view_dir TEXT NOT NULL, registered_at_ms INTEGER NOT NULL, @@ -57,7 +65,34 @@ CREATE TABLE reservations (id INTEGER PRIMARY KEY AUTOINCREMENT, charge_bytes INTEGER NOT NULL CHECK(charge_bytes>=0), completed_seq INTEGER); CREATE TABLE accounting (singleton INTEGER PRIMARY KEY CHECK(singleton=1), measured_bytes INTEGER NOT NULL, watermark INTEGER NOT NULL); -"; +" + }; +} +macro_rules! base_refs_schema { + () => { + "CREATE TABLE base_refs (worker_scope TEXT NOT NULL, base_scope TEXT NOT NULL, nonce TEXT NOT NULL, + membership_digest TEXT NOT NULL, worker_generation TEXT, + state TEXT NOT NULL CHECK(state IN ('intent','orphaned','published')), + owner_pid INTEGER NOT NULL, owner_start INTEGER NOT NULL, updated_epoch INTEGER NOT NULL, + PRIMARY KEY(worker_scope, base_scope, nonce)); +CREATE INDEX base_refs_by_base ON base_refs(base_scope); +CREATE TABLE base_builds (base_scope TEXT NOT NULL PRIMARY KEY, + owner_pid INTEGER NOT NULL, owner_start INTEGER NOT NULL, owner_token TEXT NOT NULL, + attempt INTEGER NOT NULL CHECK(attempt>=1), + state TEXT NOT NULL CHECK(state IN ('building','serving')), heartbeat_at_ms INTEGER NOT NULL); +CREATE TABLE strict_rebuild (worker_scope TEXT NOT NULL, worker_generation TEXT NOT NULL, + reason TEXT NOT NULL, recorded_epoch INTEGER NOT NULL, PRIMARY KEY(worker_scope, worker_generation)); +CREATE TABLE base_sweeps (base_scope TEXT NOT NULL PRIMARY KEY, + unreferenced_sweeps INTEGER NOT NULL DEFAULT 0); +CREATE TABLE sweep_state (singleton INTEGER PRIMARY KEY CHECK(singleton=1), + gc_epoch INTEGER NOT NULL, lease_pid INTEGER, lease_start INTEGER, reconcile_cursor TEXT, + dangling_base_edges INTEGER NOT NULL DEFAULT 0, live_base_cap_hits INTEGER NOT NULL DEFAULT 0); +" + }; +} +const REGISTRY_SCHEMA: &str = registry_v1_schema!(); +const BASE_REFS_SCHEMA: &str = base_refs_schema!(); +const REGISTRY_SCHEMA_V2: &str = concat!(registry_v1_schema!(), base_refs_schema!()); const PLANE_SCHEMA: &str = " CREATE TABLE store_meta (singleton INTEGER PRIMARY KEY CHECK(singleton=1), plane TEXT NOT NULL, gc_epoch INTEGER NOT NULL); CREATE TABLE blob_payloads (full_key BLOB NOT NULL PRIMARY KEY CHECK(length(full_key)=32), @@ -185,7 +220,39 @@ fn probe_free_space(path: &Path) -> Option<(u64, u64)> { (stat.f_blocks as u64).saturating_mul(stat.f_frsize as u64), )) } -#[cfg(not(unix))] +#[cfg(windows)] +fn probe_free_space(path: &Path) -> Option<(u64, u64)> { + use std::os::windows::ffi::OsStrExt; + // As on unix: probe the nearest existing ancestor without creating it. + let mut existing = path; + while !existing.exists() { + existing = existing + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .unwrap_or(Path::new(".")); + } + let wide: Vec = existing + .as_os_str() + .encode_wide() + .chain(std::iter::once(0)) + .collect(); + let mut available = 0u64; + let mut total = 0u64; + let mut total_free = 0u64; + // SAFETY: `wide` is NUL-terminated and the three out-pointers name live + // u64 locals for the duration of the call. + let ok = unsafe { + windows_sys::Win32::Storage::FileSystem::GetDiskFreeSpaceExW( + wide.as_ptr(), + &mut available, + &mut total, + &mut total_free, + ) + }; + // `available` honours per-user quotas, like statvfs's f_bavail. + (ok != 0).then_some((available, total)) +} +#[cfg(not(any(unix, windows)))] fn probe_free_space(_path: &Path) -> Option<(u64, u64)> { None } @@ -407,7 +474,13 @@ fn validate(c: &Connection, path: &Path, kind: Kind) -> Result<()> { |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)), ) .map_err(|_| incompatible(kind, path, version, IncompatibleReason::SchemaDrift))?; - let Some(compiled_schema) = kind.schema_for(layout) else { + let compiled_schema = match (kind, version, schema) { + // A registry of the base-reference protocol carries its own exact + // text; any other version/schema pair falls through to the drift check. + (Kind::Registry, 2, 2) if layout == 0 => Some(REGISTRY_SCHEMA_V2), + _ => kind.schema_for(layout), + }; + let Some(compiled_schema) = compiled_schema else { return Err(incompatible( kind, path, @@ -418,8 +491,18 @@ fn validate(c: &Connection, path: &Path, kind: Kind) -> Result<()> { let expected = Connection::open_in_memory()?; expected.execute_batch(META)?; expected.execute_batch(compiled_schema)?; - if version != u64::from(FORMAT_VERSION) - || schema != SCHEMA_VERSION + let known_version = match kind { + Kind::Registry => { + (version, schema) == (u64::from(FORMAT_VERSION), SCHEMA_VERSION) + || (version, schema) + == ( + u64::from(REGISTRY_BASE_REFS_VERSION), + REGISTRY_BASE_REFS_VERSION, + ) + } + Kind::Plane(_) => version == u64::from(FORMAT_VERSION) && schema == SCHEMA_VERSION, + }; + if !known_version || text != format!("{META}{compiled_schema}") || schema_rows(c).ok() != Some(schema_rows(&expected)?) || !initialization::has_seed(c, kind) @@ -545,6 +628,24 @@ impl LedgerReader { pub fn accounting(&self) -> Result { accounting(&**self.database.connection()?) } + /// The member row of `scope` as `(kind, state, view_dir)`, read without + /// any lock or barrier. Pin-then-verify readers use it before and after + /// taking their marker. + pub fn member(&self, scope: &str) -> Result> { + Ok(self + .database + .connection()? + .query_row( + "SELECT kind,state,view_dir FROM members WHERE scope=?1", + [scope], + |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)), + ) + .optional()?) + } + /// Runs a read-only query on the ledger connection. + pub fn read(&self, body: impl FnOnce(&Connection) -> Result) -> Result { + body(&**self.database.connection()?) + } fn reservation_owners(&self) -> Result<(u64, bool)> { let connection = self.database.connection()?; let mut statement = connection.prepare( @@ -749,6 +850,78 @@ impl<'a> RegistryWriter<'a> { pub fn accounting(&self) -> Result { accounting(&**self.connection()?) } + /// An existing registry for barrier-only work (base references, build + /// claims, sweeps). It takes no covering measurement: nothing it does + /// changes the namespace inventory, and admission still measures through + /// [`Self::reserve`]. + pub fn open_for_barrier( + storage: &Path, + family: &str, + lock: &'a ReservationLock, + ) -> Result { + Self::open_unmeasured(storage, family, lock, false) + } + pub fn storage(&self) -> &Path { + &self.lock.storage + } + pub fn family(&self) -> Result<&str> { + self.dir + .file_name() + .and_then(|s| s.to_str()) + .ok_or(Error::InvalidIdentity) + } + pub fn family_dir(&self) -> &Path { + &self.dir + } + /// The registry's format version: 1 before the base-reference upgrade, + /// [`REGISTRY_BASE_REFS_VERSION`] after it. + pub fn registry_version(&self) -> Result { + Ok(self.connection()?.query_row( + "SELECT format_version FROM format_meta WHERE singleton=1", + [], + |r| r.get(0), + )?) + } + /// Exact-predecessor migration from registry 1/1 to 2/2: adds the + /// base-reference tables in one barrier transaction and rewrites + /// `format_meta`. The caller must already have raised the registry reader + /// floor to 2. A registry already at 2/2 is left alone; any other state is + /// schema drift. + pub fn upgrade_to_base_refs(&mut self) -> Result<()> { + let path = self.dir.join("members.sqlite"); + self.with_barrier(|tx| { + let (version, schema, text): (u32, u32, String) = tx.query_row( + "SELECT format_version,schema_version,schema_text FROM format_meta WHERE singleton=1", + [], + |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)), + )?; + if (version, schema) == (REGISTRY_BASE_REFS_VERSION, REGISTRY_BASE_REFS_VERSION) + && text == format!("{META}{REGISTRY_SCHEMA_V2}") + { + return Ok(()); + } + if (version, schema) != (FORMAT_VERSION, SCHEMA_VERSION) + || text != format!("{META}{REGISTRY_SCHEMA}") + { + return Err(incompatible( + Kind::Registry, + &path, + u64::from(version), + IncompatibleReason::SchemaDrift, + )); + } + tx.execute_batch(BASE_REFS_SCHEMA)?; + tx.execute( + "INSERT INTO sweep_state(singleton,gc_epoch) VALUES(1,1)", + [], + )?; + tx.execute( + "UPDATE format_meta SET format_version=?1,schema_version=?1,schema_text=?2 WHERE singleton=1", + params![REGISTRY_BASE_REFS_VERSION, format!("{META}{REGISTRY_SCHEMA_V2}")], + )?; + Ok(()) + }) + } fn connection(&self) -> Result> { self.database.connection() } @@ -1054,6 +1227,157 @@ pub fn publish_locked( }) .map_err(|e| Error::Io(std::io::Error::other(e)))? } +/// Per-visit progress of the v3 plane-row sweep: for each plane, the last +/// key (hex) the previous bounded visit examined. +#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct PlaneRowCursor { + pub format_version: u32, + pub after: std::collections::BTreeMap, +} + +/// What one bounded plane-row visit did. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct PlaneRowSweep { + pub examined: u64, + pub deleted: u64, + /// Every plane was scanned to its end in this visit. + pub complete: bool, +} + +impl RegistryWriter<'_> { + /// Deletes plane rows of this family that no marked generation, pin or + /// protected reader names, under the storage-root lock this writer holds. + /// Every v3 plane write happens inside a publication that holds the same + /// lock, so no row can be put or touched between the caller's marking and + /// this deletion. Rows with `ref_epoch >= epoch` are never deleted, and + /// every plane's `gc_epoch` is raised to `epoch` first, so a row written by + /// a later publication carries an epoch this sweep cannot delete. + /// + /// The scan stops after `limit` examined rows or at `deadline` and resumes + /// from `cursor` on the next visit; reaching a bound never counts as having + /// seen the remaining rows. + pub fn sweep_plane_rows( + &mut self, + epoch: i64, + marked: &std::collections::HashSet<[u8; 32]>, + cursor: &mut PlaneRowCursor, + limit: u64, + deadline: Option, + ) -> Result { + self.lock.verify(&self.lock.storage)?; + cursor.format_version = 1; + let mut report = PlaneRowSweep { + complete: true, + ..PlaneRowSweep::default() + }; + for plane in FamilyPlane::ALL { + let path = self.dir.join(format!("{}.sqlite", plane.as_str())); + if !path.try_exists()? { + cursor.after.remove(plane.as_str()); + continue; + } + let database = connections::shared(&path, Kind::Plane(plane), false, false)?; + let mut c = database.connection()?; + { + let tx = c.transaction_with_behavior(TransactionBehavior::Immediate)?; + tx.execute( + "UPDATE store_meta SET gc_epoch=?1 WHERE singleton=1 AND gc_epoch?2 ORDER BY full_key LIMIT 64", + "DELETE FROM blob_payloads WHERE full_key=?1 AND ref_epoch?2 ORDER BY p.full_key LIMIT 64", + "DELETE FROM blob_payloads WHERE full_key=?1 AND id IN (SELECT blob_id FROM blob_liveness WHERE ref_epoch= limit || deadline.is_some_and(|d| Instant::now() >= d) { + report.complete = false; + break; + } + let tx = c.transaction_with_behavior(TransactionBehavior::Immediate)?; + let keys: Vec> = tx + .prepare(select)? + .query_map(params![epoch, after], |r| r.get(0))? + .collect::>()?; + if keys.is_empty() { + tx.commit()?; + after.clear(); + break; + } + for key in &keys { + report.examined += 1; + let protected = <[u8; 32]>::try_from(key.as_slice()) + .map_or(true, |key| marked.contains(&key)); + if !protected { + report.deleted += tx.execute(delete, params![key, epoch])? as u64; + } + } + tx.commit()?; + after = keys.last().cloned().unwrap_or_default(); + } + if after.is_empty() { + cursor.after.remove(plane.as_str()); + } else { + cursor + .after + .insert(plane.as_str().to_owned(), super::v2::to_hex(&after)); + } + if !report.complete { + break; + } + } + Ok(report) + } +} + +/// Opens (creating when absent) the family registry for the base-reference +/// protocol and upgrades it to registry 2/2. The registry floor is raised to +/// 2 before any byte of the new schema exists, and the store floor to 1, +/// through the combined v3 floor boundary every v3 writer uses. +#[doc(hidden)] +pub fn open_base_refs_registry<'a>( + storage: &Path, + family: &str, + lock: &'a ReservationLock, + observer: Option<&dyn DurabilityObserver>, +) -> Result> { + family_dir(storage, family)?; + lock.verify(storage)?; + crate::reader_floor::publish_v3_with_floor( + storage, + REGISTRY_BASE_REFS_VERSION, + FORMAT_VERSION, + || { + observe(observer, DurabilityStep::V3FloorRaised); + let exists = family_dir(storage, family)? + .join("members.sqlite") + .try_exists()?; + let mut registry = if exists { + RegistryWriter::open_for_barrier(storage, family, lock)? + } else { + RegistryWriter::open(storage, family, lock, true)? + }; + registry.upgrade_to_base_refs()?; + Ok(registry) + }, + ) + .map_err(|e| Error::Io(std::io::Error::other(e)))? +} fn publish_under_lock( storage: &Path, family: &str, @@ -1313,6 +1637,14 @@ impl PlaneReader { } } +/// Serializes tests that depend on the process-wide free-space override and +/// sets it to unlimited, so admission in other modules' tests never races the +/// refusal cases in `tests`. +#[cfg(test)] +pub(crate) fn serial_for_test() -> std::sync::MutexGuard<'static, ()> { + tests::serial() +} + #[cfg(test)] mod copy_tests; #[cfg(test)] diff --git a/crates/aft/src/blob_store/v3/tests.rs b/crates/aft/src/blob_store/v3/tests.rs index 64ed8bcf73..fc905f832a 100644 --- a/crates/aft/src/blob_store/v3/tests.rs +++ b/crates/aft/src/blob_store/v3/tests.rs @@ -179,6 +179,7 @@ fn publication_observes_floor_before_files_and_flush_before_commit() { | DurabilityStep::ManifestParentSynced | DurabilityStep::PointerCas | DurabilityStep::PointerSynced => panic!("v2 boundary in v3 publication"), + other => panic!("base-reference boundary {other:?} in a copy publication"), } } } @@ -685,6 +686,7 @@ fn killed_v3_publishers_recover_at_both_new_boundaries_without_losing_orphans() | DurabilityStep::ManifestParentSynced | DurabilityStep::PointerCas | DurabilityStep::PointerSynced => panic!("V3_ONLY contains a v2 boundary"), + other => panic!("V3_ONLY contains the base-path boundary {other:?}"), } } } @@ -859,11 +861,13 @@ fn newer_format_keeps_artifact_refusal_and_malformed_file_reports_found_zero() { let path = db(root.path(), A); Connection::open(&path) .unwrap() - .execute("UPDATE format_meta SET format_version=2", []) + // Registry 2 is the base-reference protocol this build reads; the + // first newer version is 3. + .execute("UPDATE format_meta SET format_version=3", []) .unwrap(); match LedgerReader::open_existing(root.path(), A).err().unwrap() { Error::Incompatible(error) => { - assert_eq!(error.found, 2); + assert_eq!(error.found, 3); assert_eq!(error.source, persisted_format::RefusalSource::Artifact); } error => panic!("{error}"), diff --git a/crates/aft/src/persisted_format.rs b/crates/aft/src/persisted_format.rs index 80a22b952e..1037e120db 100644 --- a/crates/aft/src/persisted_format.rs +++ b/crates/aft/src/persisted_format.rs @@ -111,7 +111,8 @@ impl PersistedStore { PersistedStore::ArtifactOwner => crate::artifact_owner::SCHEMA_VERSION, PersistedStore::BackupMeta => crate::backup::SCHEMA_VERSION, PersistedStore::BashTask => crate::bash_background::persistence::SCHEMA_VERSION, - PersistedStore::ViewsV3Registry | PersistedStore::ViewsV3Store => 1, + PersistedStore::ViewsV3Registry => crate::blob_store::v3::REGISTRY_BASE_REFS_VERSION, + PersistedStore::ViewsV3Store => 1, } } diff --git a/crates/aft/src/reader_floor.rs b/crates/aft/src/reader_floor.rs index f966008fa4..67c786e59d 100644 --- a/crates/aft/src/reader_floor.rs +++ b/crates/aft/src/reader_floor.rs @@ -785,12 +785,14 @@ pub(crate) mod tests { #[test] fn v3_registration_is_advertised_but_not_prepared() { let formats = persisted_format::formats_json(); - for name in ["views_v3_registry", "views_v3_store"] { - assert_eq!(formats["stores"][name], 1, "{name}"); + // The registry reads the base-reference protocol (2/2); plane stores + // keep format 1. Neither appears in the startup baseline floor. + for (name, version) in [("views_v3_registry", 2), ("views_v3_store", 1)] { + assert_eq!(formats["stores"][name], version, "{name}"); let store = PersistedStore::from_name(name).expect("registered store"); assert!(PersistedStore::ALL.contains(&store)); - assert_eq!(store.supported(), 1); - assert_eq!(store.written(), 1); + assert_eq!(store.supported(), version); + assert_eq!(store.written(), version); } let root = tempfile::tempdir().unwrap(); assert!(prepare_uncached(root.path()).is_empty()); From 4641d13dca12957e2e1c5b06ccfb1de1623aa20c Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Mon, 26 Oct 2026 16:09:57 +0100 Subject: [PATCH 2/8] mason: authenticated v3 base identity, referrers and two-sweep reclaim Base identity: tagged, length-prefixed BLAKE3 digests of a base manifest's membership and content (readiness excluded; head_tree and git_oid refused, slice 4 bases are membership-keyed) and of its effective identity (family, producers, ignore fingerprint, platform family, membership). The base scope name derives from the identity digest. resolve_base pins the base's current generation with a read marker, re-verifies pointer and member row, and recomputes both digests from the pinned manifest; only that AuthenticatedBase validates a BaseHeader (bound_generation stays diagnostic). Worker side: publish_worker runs the insertion barrier (BaseRefMarker with a per-acquisition nonce plus an intent row in one IMMEDIATE transaction under the reservation lock), authenticates the base after the barrier, rebinds the delta to the authenticated header, verify_held and a base re-check right before the pointer CAS, then ReferrerPublished. Conflict/EquivalentWinner and failed verifies delete only their own intent row under the barrier before the marker drops; a CAS that may have landed is published, never cleaned up. open_foreign_v3 (and read::open_foreign_v3_generation) does the dual pin-then-verify with three attempts and owns both markers. read::open_foreign_generation keeps refusing a base header, now because a v2 view can never hold an authenticatable base reference (rule 6); its test asserts the new reason. Sweep: marking of current, read-marked and pinned generations under the reservation lock, exact edges, dangling edges counted and recorded in strict_rebuild without aborting, bounded reconciliation (256 rows / 25 ms, updated_epoch < S only, wrap-around position), repair of valid edges, Hold::Referenced via eviction::v3_base_hold, the two-sweep reclaim, and the plane-row sweep. The retention collector runs it per v3 family visit and deletes a deregistered worker's rows with its member row. DurabilityStep gains the seven base-path boundaries (BASE_PATH); the v2 kill test's step list excludes them. Tests use injected EIO/EXDEV/ENOSPC at each boundary through contracts::inject_fault_at. Co-authored-by: alfonso-aft <289616620+alfonso-aft@users.noreply.github.com> --- crates/aft/src/pins/live.rs | 29 + crates/aft/src/root_cache.rs | 42 + crates/aft/src/storage_retention.rs | 126 ++- crates/aft/src/views/base_builder.rs | 166 +++ crates/aft/src/views/base_refs.rs | 964 ++++++++++++++++++ crates/aft/src/views/base_refs_tests.rs | 691 +++++++++++++ crates/aft/src/views/base_sweep.rs | 818 +++++++++++++++ crates/aft/src/views/base_sweep_tests.rs | 566 ++++++++++ crates/aft/src/views/contracts.rs | 95 +- crates/aft/src/views/eviction.rs | 54 + crates/aft/src/views/manifest_v2.rs | 11 + crates/aft/src/views/mod.rs | 3 + crates/aft/src/views/read.rs | 31 +- .../integration/per_checkout_registry.rs | 3 +- 14 files changed, 3589 insertions(+), 10 deletions(-) create mode 100644 crates/aft/src/views/base_builder.rs create mode 100644 crates/aft/src/views/base_refs.rs create mode 100644 crates/aft/src/views/base_refs_tests.rs create mode 100644 crates/aft/src/views/base_sweep.rs create mode 100644 crates/aft/src/views/base_sweep_tests.rs diff --git a/crates/aft/src/pins/live.rs b/crates/aft/src/pins/live.rs index 1b713dca95..fa78a24495 100644 --- a/crates/aft/src/pins/live.rs +++ b/crates/aft/src/pins/live.rs @@ -51,6 +51,35 @@ impl LivePin { }) } + /// Creates an empty live pin in the `pins/` of the v3 member `scope`, + /// inside the v3 registry barrier: the member must still be registered and + /// not `evicting`, so the retention collector's final protection check + /// either sees this pin or has already removed the member. + pub fn create_v3(storage: &Path, family: &str, scope: &str) -> Result { + crate::views::base_refs::with_base_barrier(storage, family, |tx| { + let state: Option = rusqlite::OptionalExtension::optional(tx.query_row( + "SELECT state FROM members WHERE scope=?1", + [scope], + |r| r.get(0), + ))?; + if state.is_none() || state.as_deref() == Some(crate::views::registry::STATE_EVICTING) { + return Err(crate::views::base_refs::BaseRefError::Unavailable(format!( + "v3 member {scope} is not registered" + ))); + } + let view_dir = crate::blob_store::v3::view_dir(storage, family, scope)?; + Self::create_in(&view_dir, family.to_owned(), scope.to_owned()).map_err(|error| { + crate::views::base_refs::BaseRefError::Io(std::io::Error::other(error.to_string())) + }) + }) + .map_err(|error| match error { + crate::views::base_refs::BaseRefError::Unavailable(_) => { + PinError::NotRegistered(scope.to_owned()) + } + other => PinError::Registry(other.to_string()), + }) + } + fn create_in(view_dir: &Path, family: String, view: String) -> Result { let pid = std::process::id(); let now = now_ms(); diff --git a/crates/aft/src/root_cache.rs b/crates/aft/src/root_cache.rs index 68276e4a49..d35a54969b 100644 --- a/crates/aft/src/root_cache.rs +++ b/crates/aft/src/root_cache.rs @@ -761,6 +761,10 @@ pub struct ReadMarkerMetadata { pub pid: u32, pub hostname: String, pub created_at_ms: u64, + /// Per-acquisition identity of a base-reference marker. Ordinary readers + /// leave it out, so their files are unchanged. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub nonce: Option, } #[derive(Debug)] @@ -774,10 +778,45 @@ pub struct ReadMarker { impl ReadMarker { pub fn create(cache_dir: &Path, generation_label: &str) -> io::Result { + Self::create_inner(cache_dir, generation_label, None) + } + + /// A marker whose file also records `nonce`, so its holder can later + /// prove the file at its unique path is still its own acquisition. + pub fn create_with_nonce( + cache_dir: &Path, + generation_label: &str, + nonce: &str, + ) -> io::Result { + Self::create_inner(cache_dir, generation_label, Some(nonce.to_owned())) + } + + /// True when the marker file at this marker's own path still exists and + /// carries this marker's metadata, nonce included. A sweep that judged the + /// owner dead removes the file, and this then reports `false`. + pub fn verify_held(&self) -> io::Result { + use std::io::Read; + let mut file = match self.generation.repair_read_file(&self.name) { + Ok(file) => file, + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(false), + Err(error) => return Err(error), + }; + let mut bytes = Vec::new(); + (&mut file).take(32 * 1024).read_to_end(&mut bytes)?; + Ok(serde_json::from_slice::(&bytes) + .is_ok_and(|stored| stored == self.metadata)) + } + + fn create_inner( + cache_dir: &Path, + generation_label: &str, + nonce: Option, + ) -> io::Result { let metadata = ReadMarkerMetadata { pid: std::process::id(), hostname: current_hostname(), created_at_ms: now_ms(), + nonce, }; let dir = read_marker_dir(cache_dir, generation_label); crate::private_storage::open_dir(cache_dir, &dir)?; @@ -1559,6 +1598,7 @@ mod tests { pid: 0, hostname: current_hostname(), created_at_ms: now_ms(), + nonce: None, }; fs::create_dir_all(marker_path.parent().unwrap()).unwrap(); write_marker_file(&marker_path, &metadata).unwrap(); @@ -1579,6 +1619,7 @@ mod tests { pid, hostname: current_hostname(), created_at_ms: 1_000, + nonce: None, }; fs::create_dir_all(marker_path.parent().unwrap()).unwrap(); write_marker_file(&marker_path, &metadata).unwrap(); @@ -1600,6 +1641,7 @@ mod tests { pid: 123, hostname: format!("other-{}", current_hostname()), created_at_ms: now_ms(), + nonce: None, }; fs::create_dir_all(marker_path.parent().unwrap()).unwrap(); write_marker_file(&marker_path, &metadata).unwrap(); diff --git a/crates/aft/src/storage_retention.rs b/crates/aft/src/storage_retention.rs index 5316ee0964..380739e214 100644 --- a/crates/aft/src/storage_retention.rs +++ b/crates/aft/src/storage_retention.rs @@ -40,7 +40,7 @@ const INTERVAL: Duration = Duration::from_secs(10 * 60); const STARTUP_GRACE: Duration = Duration::from_secs(5 * 60); const PASS_BUDGET: Duration = Duration::from_secs(5); const PASS_ENTRIES: usize = 64; -const TREE_ENTRIES: usize = 4096; +pub(crate) const TREE_ENTRIES: usize = 4096; const TRASH_SCAN_HANDLES: usize = 64; const HISTORY_ENTRIES: usize = 8192; static REPORTED_BINDING_READ_ERRORS: OnceLock>> = OnceLock::new(); @@ -1614,7 +1614,7 @@ fn read_json(path: &Path) -> io::Result { serde_json::from_slice(&fs::read(path)?).map_err(io::Error::other) } -fn bounded_dirs(directory: &Path, cap: usize) -> io::Result> { +pub(crate) fn bounded_dirs(directory: &Path, cap: usize) -> io::Result> { let entries = match fs::read_dir(directory) { Ok(entries) => entries, Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()), @@ -4660,7 +4660,7 @@ enum V3Step { /// way, so they never keep a member. `Err` is uncertainty (an unreadable pin, /// or a `pins/` or `readers/` listing longer than the bound) and keeps the /// member without resetting its count, as the v2 family sweep does. -fn v3_member_protected( +pub(crate) fn v3_member_protected( tx: &rusqlite::Transaction<'_>, scope: &str, view_dir: &Path, @@ -4887,10 +4887,16 @@ fn v3_finish_eviction( crate::views::registry::remove_view_dir(view_dir)?; registry .with_barrier(|tx| { - tx.execute( + let removed = tx.execute( "DELETE FROM members WHERE scope=?1 AND state=?2", rusqlite::params![scope, crate::views::registry::STATE_EVICTING], )?; + // A deregistered worker's referrer and strict-rebuild rows go in + // the same transaction as its member row, after its directory. + if removed > 0 && crate::views::base_refs::has_base_refs(tx)? { + tx.execute("DELETE FROM base_refs WHERE worker_scope=?1", [scope])?; + tx.execute("DELETE FROM strict_rebuild WHERE worker_scope=?1", [scope])?; + } Ok(()) }) .map_err(|error| io::Error::other(error.to_string()))?; @@ -5203,6 +5209,44 @@ fn collect_v3_family( }, )?; } + // The base-reference sweep: referrer reconciliation, the two-sweep base + // reclaim and the plane-row sweep. It needs the registry's base-reference + // tables and runs under the reservation lock this visit already holds. + if !cancelled() + && Instant::now() < deadline + && registry.registry_version().map_err(v3)? + >= crate::blob_store::v3::REGISTRY_BASE_REFS_VERSION + { + let plane_cursor = cursor_root(paths)? + .join("v3-plane-rows") + .join(format!("{family}.json")); + let bounds = crate::views::base_sweep::SweepBounds { + deadline: Some(deadline), + ..Default::default() + }; + match crate::views::base_sweep::sweep_family_locked( + storage, + family, + &lock, + None, + bounds, + Some(&plane_cursor), + ) { + Ok(swept) => { + report.removed_roots += swept.reclaimed_bases.len(); + report.removed_blobs += swept.plane_rows_deleted as usize; + report.examined_blobs += swept.plane_rows_examined as usize; + report.stopped_early |= !swept.plane_rows_complete && swept.skipped.is_none(); + } + Err(error) => { + if report.errors.len() < 16 { + report + .errors + .push(format!("v3 base sweep of family {family}: {error}")); + } + } + } + } // Every collector pass ends with a covering measurement, so committed // usage also reflects files removed by anything else since the last one. // It is skipped once one is already owed: the deadline has passed. @@ -12827,3 +12871,77 @@ mod v3_collector_tests { assert!(member.join("data.bin").exists()); } } + +#[cfg(test)] +mod v3_base_reference_collector_tests { + use super::*; + use crate::views::base_refs::tests::{ + bind, plane_has, publish_test_base, published, put_rows, refs, worker_delta, FAMILY, + }; + use crate::views::base_refs::with_base_barrier; + + fn pass(storage: &Path) -> SweepReport { + let mut report = SweepReport::default(); + collect_one_v3_family( + &StoragePaths::same(storage), + &|| false, + &mut report, + V3Bounds::PASS, + ) + .unwrap(); + assert!(report.errors.is_empty(), "{report:?}"); + report + } + + fn registered(storage: &Path, scope: &str) -> bool { + crate::blob_store::v3::LedgerReader::open_existing(storage, FAMILY) + .unwrap() + .member(scope) + .unwrap() + .is_some() + } + + #[test] + fn the_collector_keeps_a_referenced_base_and_reclaims_it_two_passes_after_its_worker_goes() { + let _serial = crate::blob_store::v3::serial_for_test(); + let storage = tempfile::tempdir().unwrap(); + let (identity, generation) = publish_test_base(storage.path(), &[("src/a.rs", b"alpha")]); + put_rows(storage.path(), &[b"new"]); + let (_, composed) = published( + bind( + storage.path(), + "worker-a", + &identity, + &generation, + worker_delta(&identity, &generation, b"new"), + None, + ) + .unwrap(), + ); + drop(composed); + for _ in 0..3 { + pass(storage.path()); + assert!(registered(storage.path(), &identity.scope)); + assert!(plane_has(storage.path(), b"alpha")); + } + // The worker is deregistered: its rows go with its member row. + with_base_barrier(storage.path(), FAMILY, |tx| { + tx.execute( + "UPDATE members SET state='evicting' WHERE scope='worker-a'", + [], + )?; + Ok(()) + }) + .unwrap(); + pass(storage.path()); + assert!(!registered(storage.path(), "worker-a")); + assert!(refs(storage.path()).is_empty()); + // That same pass counted the base once; the next one reclaims it. + assert!(registered(storage.path(), &identity.scope)); + pass(storage.path()); + assert!(!registered(storage.path(), &identity.scope)); + pass(storage.path()); + assert!(!plane_has(storage.path(), b"alpha")); + assert!(!storage.path().join("views/v3").exists()); + } +} diff --git a/crates/aft/src/views/base_builder.rs b/crates/aft/src/views/base_builder.rs new file mode 100644 index 0000000000..4299a4d471 --- /dev/null +++ b/crates/aft/src/views/base_builder.rs @@ -0,0 +1,166 @@ +//! v3 base scopes: registration, publication and readiness successors, and +//! (with the build claim) single-flight construction. +//! +//! A base is published once its membership and lexical plane are complete. +//! From then on its membership digest, identity and trigram segment are +//! fixed; its pointer moves only to readiness successors, which may turn +//! `Pending` into `Ready` or `Failed`, `Failed` into `Ready`, and nothing +//! else. A `Ready` key never changes. + +use std::path::{Path, PathBuf}; + +use rusqlite::{params, OptionalExtension, Transaction}; + +use crate::blob_store::v2::FamilyPlane; +use crate::blob_store::v3; + +use super::base_refs::{with_base_barrier, BaseIdentity, BaseRefError, Result, KIND_BASE}; +use super::contracts::{boundary, DurabilityObserver, DurabilityStep}; +use super::manifest_v2::{GenerationName, ManifestV2, PublishV2}; +use super::readiness::PlaneState; +use super::registry::STATE_EVICTING; +use super::ViewStore; + +/// Registers the base member row (`kind = base`, no root) inside a barrier. +/// A member in `evicting` is never reused. +pub(crate) fn register_base_in( + tx: &Transaction<'_>, + storage: &Path, + family: &str, + scope: &str, +) -> Result<(PathBuf, bool)> { + let dir = v3::view_dir(storage, family, scope)?; + let existing: Option<(String, String)> = tx + .query_row( + "SELECT kind,state FROM members WHERE scope=?1", + [scope], + |r| Ok((r.get(0)?, r.get(1)?)), + ) + .optional()?; + match existing { + Some((_, state)) if state == STATE_EVICTING => Err(BaseRefError::Unavailable(format!( + "base {scope} is evicting" + ))), + Some((kind, _)) if kind != KIND_BASE => Err(BaseRefError::Corrupt(format!( + "{scope} is registered as a {kind}" + ))), + Some(_) => Ok((dir, false)), + None => { + tx.execute( + "INSERT INTO members(scope,kind,root,view_dir,registered_at_ms,last_bind_ms,state) VALUES(?1,'base',NULL,?2,?3,?3,'active')", + params![scope, dir.to_string_lossy(), crate::pins::now_ms() as i64], + )?; + Ok((dir, true)) + } + } +} + +/// Whether `next` may replace `current` as the base's generation: same +/// identity and segment, and every entry's per-plane state either unchanged +/// or one of the allowed monotone transitions. +pub fn check_successor(family: &str, current: &ManifestV2, next: &ManifestV2) -> Result<()> { + let old = BaseIdentity::of(family, current)?; + let new = BaseIdentity::of(family, next)?; + if old != new { + return Err(BaseRefError::Corrupt( + "a readiness successor changes the base identity".into(), + )); + } + if current.header().segment != next.header().segment { + return Err(BaseRefError::Corrupt( + "a readiness successor replaces the trigram segment".into(), + )); + } + for ((path, before), (_, after)) in current.entries().zip(next.entries()) { + for plane in FamilyPlane::ALL { + let allowed = match (before.plane_state(plane), after.plane_state(plane)) { + (a, b) if a == b => true, + (Some(PlaneState::Pending { .. }), Some(PlaneState::Ready { .. })) => true, + (Some(PlaneState::Pending { .. }), Some(PlaneState::Failed { .. })) => true, + (Some(PlaneState::Failed { .. }), Some(PlaneState::Ready { .. })) => true, + // A retry may record a new reason for a still-pending entry. + (Some(PlaneState::Pending { .. }), Some(PlaneState::Pending { .. })) => true, + _ => false, + }; + if !allowed { + return Err(BaseRefError::Corrupt(format!( + "forbidden {} transition for {:?}", + plane.as_str(), + path + ))); + } + } + } + Ok(()) +} + +/// A first base publication needs complete membership and a complete lexical +/// plane: no trigram entry may still be pending. Graph and semantic entries +/// may be. +pub fn check_lexical_complete(manifest: &ManifestV2) -> Result<()> { + for (path, entry) in manifest.entries() { + if entry + .plane_state(FamilyPlane::Trigram) + .is_some_and(PlaneState::is_pending) + { + return Err(BaseRefError::Corrupt(format!( + "first base publication with a pending lexical entry {path:?}" + ))); + } + } + Ok(()) +} + +/// What a base publication did. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum BasePublication { + /// The pointer names `generation`. + Published { generation: String, first: bool }, + /// Another publisher moved the pointer first; nothing was published. + Lost { current: Option }, +} + +/// Publishes `manifest` as the base's first generation, or as a readiness +/// successor of its current one. Callers own the base's build claim; the +/// manifest's Ready keys must already be durable in the family's v3 plane +/// stores and protected by the caller's pin until this returns. +pub fn publish_base( + storage: &Path, + family: &str, + manifest: &ManifestV2, + observer: Option<&dyn DurabilityObserver>, +) -> Result { + let identity = BaseIdentity::of(family, manifest)?; + let (dir, registered) = with_base_barrier(storage, family, |tx| { + register_base_in(tx, storage, family, &identity.scope) + })?; + if registered { + boundary(observer, DurabilityStep::BaseRegistered)?; + } + let store = ViewStore::open_dir(dir)?; + let current = store.current_generation()?; + match ¤t { + Some(current) => { + let current_manifest = store.load_manifest_v2(current)?; + check_successor(family, ¤t_manifest, manifest)?; + } + None => check_lexical_complete(manifest)?, + } + let name = GenerationName::for_manifest(manifest)?; + let prepared = store.prepare_v2(&name, current.as_deref(), manifest, observer)?; + match store.commit_v2(prepared, observer)? { + PublishV2::Published => { + if current.is_some() { + boundary(observer, DurabilityStep::BaseSuccessorPublished)?; + } + Ok(BasePublication::Published { + generation: name.to_string(), + first: current.is_none(), + }) + } + PublishV2::Conflict { current } => Ok(BasePublication::Lost { current }), + PublishV2::EquivalentWinner { current } => Ok(BasePublication::Lost { + current: Some(current), + }), + } +} diff --git a/crates/aft/src/views/base_refs.rs b/crates/aft/src/views/base_refs.rs new file mode 100644 index 0000000000..24341b43e2 --- /dev/null +++ b/crates/aft/src/views/base_refs.rs @@ -0,0 +1,964 @@ +//! Durable cross-view base references in the v3 namespace. +//! +//! A base is its own registered v3 member (`kind = base`, no root) under +//! `blobs/v3//views/`. Its membership is fixed at first +//! publication, and its scope name is derived from its full effective +//! identity. A worker manifest names a base through its `base` header; that +//! header is never trusted. Every consumer resolves the base through +//! [`resolve_base`], which pins the base's current generation with a read +//! marker, re-verifies the pointer and member row, and recomputes both +//! digests from the pinned manifest. Only the resulting +//! [`AuthenticatedBase`] can validate a header or become the base of a +//! composed generation. +//! +//! The worker side of the protocol ([`publish_worker`]) follows the insertion +//! barrier of the views design: a per-acquisition [`BaseRefMarker`] plus an +//! `intent` row in `base_refs` (one IMMEDIATE transaction under the +//! storage-root reservation lock), base re-verification after the barrier, +//! `verify_held` immediately before the worker's pointer CAS, then the row +//! becomes `published`. Aborts delete their own `intent` row under the +//! barrier before the marker drops. A CAS that may have landed is resolved +//! first and never cleaned up as an abort. +//! +//! Nothing here writes a v1 or v2 row, manifest or pointer. + +use std::fmt; +use std::io; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Arc; + +use rusqlite::{params, OptionalExtension, Transaction}; + +use crate::blob_store::v2::to_hex; +use crate::blob_store::v3::{self, LedgerReader, RegistryWriter, ReservationLock}; +use crate::root_cache::ReadMarker; + +use super::contracts::{boundary, DurabilityObserver, DurabilityStep}; +use super::manifest_v2::{ + BaseHeader, EntryV2, GenerationName, ManifestHeader, ManifestV2, PublishV2, +}; +use super::registry::STATE_EVICTING; +use super::snapshot::{ComposedGeneration, Residency}; +use super::ViewStore; + +/// Domain tag of the membership-and-content digest, version 1. +pub const MEMBERSHIP_DIGEST_TAG: &[u8] = b"aft/views-v3/base-membership/1"; +/// Domain tag of the effective-identity digest, version 1. +pub const IDENTITY_DIGEST_TAG: &[u8] = b"aft/views-v3/base-identity/1"; +/// Membership-keyed base scopes. Slice 4v's tree-keyed scopes will use a +/// different prefix; a scope is never relabelled from one to the other. +pub const BASE_SCOPE_PREFIX: &str = "base-m1-"; +/// Pin-then-verify attempts before a base is reported unavailable. +pub const RESOLVE_ATTEMPTS: usize = 3; +/// `base_refs.state` values. +pub const REF_INTENT: &str = "intent"; +pub const REF_ORPHANED: &str = "orphaned"; +pub const REF_PUBLISHED: &str = "published"; +pub const KIND_BASE: &str = "base"; +pub const KIND_VIEW: &str = "view"; +pub const STATE_ACTIVE: &str = "active"; + +#[derive(Debug)] +pub enum BaseRefError { + Store(v3::Error), + View(super::ViewError), + Io(io::Error), + /// The base (or worker) is absent, evicting or moved during every attempt. + Unavailable(String), + /// A header, manifest or registry row contradicts the authenticated base. + Corrupt(String), +} + +impl fmt::Display for BaseRefError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Store(error) => write!(f, "v3 store: {error}"), + Self::View(error) => write!(f, "{error}"), + Self::Io(error) => write!(f, "{error}"), + Self::Unavailable(reason) => write!(f, "base unavailable: {reason}"), + Self::Corrupt(reason) => write!(f, "corrupt base edge: {reason}"), + } + } +} + +impl std::error::Error for BaseRefError {} + +impl From for BaseRefError { + fn from(error: v3::Error) -> Self { + Self::Store(error) + } +} +impl From for BaseRefError { + fn from(error: super::ViewError) -> Self { + Self::View(error) + } +} +impl From for BaseRefError { + fn from(error: io::Error) -> Self { + Self::Io(error) + } +} +impl From for BaseRefError { + fn from(error: rusqlite::Error) -> Self { + Self::Store(v3::Error::Sqlite(error)) + } +} + +pub type Result = std::result::Result; + +/// Length-prefixed, domain-tagged BLAKE3 encoding. Every field carries its +/// length, so no two different field sequences hash the same bytes. +struct TaggedHasher(blake3::Hasher); + +impl TaggedHasher { + fn new(tag: &[u8]) -> Self { + let mut hasher = blake3::Hasher::new(); + hasher.update(&(tag.len() as u64).to_le_bytes()); + hasher.update(tag); + Self(hasher) + } + fn field(&mut self, bytes: &[u8]) { + self.0.update(&(bytes.len() as u64).to_le_bytes()); + self.0.update(bytes); + } + fn optional(&mut self, value: Option<&[u8]>) { + match value { + None => { + self.0.update(&[0]); + } + Some(bytes) => { + self.0.update(&[1]); + self.field(bytes); + } + } + } + fn byte(&mut self, value: u8) { + self.0.update(&[value]); + } + fn u64(&mut self, value: u64) { + self.0.update(&value.to_le_bytes()); + } + fn finish(self) -> [u8; 32] { + *self.0.finalize().as_bytes() + } +} + +/// The membership-and-content digest of a base manifest: every path with its +/// entry kind, mode, content hash, size and resolution-input flag, in path +/// order. Readiness is excluded, so readiness successors keep the digest. +/// +/// Slice 4 bases are membership-keyed: a manifest that claims a HEAD tree or +/// any `git_oid`, or carries composition sections, is not a valid base. +pub fn membership_digest(manifest: &ManifestV2) -> Result<[u8; 32]> { + if manifest.base_header().is_some() { + return Err(BaseRefError::Corrupt( + "a base manifest names another base".into(), + )); + } + if !manifest.tombstones().is_empty() || !manifest.readiness_overrides().is_empty() { + return Err(BaseRefError::Corrupt( + "a base manifest carries worker composition sections".into(), + )); + } + if manifest.header().head_tree.is_some() { + return Err(BaseRefError::Corrupt( + "a membership-keyed base claims a HEAD tree".into(), + )); + } + let mut hasher = TaggedHasher::new(MEMBERSHIP_DIGEST_TAG); + hasher.u64(manifest.len() as u64); + for (path, entry) in manifest.entries() { + hasher.field(path.as_bytes()); + match entry { + EntryV2::Regular { + mode, + content, + size, + git_oid, + resolution_input, + .. + } => { + if git_oid.is_some() { + return Err(BaseRefError::Corrupt( + "a membership-keyed base carries an unproven git_oid".into(), + )); + } + hasher.byte(1); + hasher.u64(u64::from(*mode)); + hasher.field(content.as_bytes()); + hasher.u64(*size); + hasher.byte(u8::from(*resolution_input)); + } + EntryV2::Symlink { target_bytes } => { + hasher.byte(2); + hasher.field(target_bytes.as_bytes()); + } + EntryV2::Gitlink { oid } => { + hasher.byte(3); + hasher.field(oid.as_bytes()); + } + EntryV2::Synthetic { name, .. } => { + hasher.byte(4); + hasher.field(name.as_bytes()); + } + } + } + Ok(hasher.finish()) +} + +/// The effective-identity digest: family, every plane producer, the ignore +/// fingerprint that decided membership, the platform family (path and +/// line-ending conventions), the explicit absence of a tree identity, and +/// the membership digest. +pub fn identity_digest(family: &str, header: &ManifestHeader, membership: &[u8; 32]) -> [u8; 32] { + let mut hasher = TaggedHasher::new(IDENTITY_DIGEST_TAG); + hasher.field(family.as_bytes()); + hasher.field(header.producers.trigram.as_bytes()); + hasher.optional(header.producers.semantic.as_deref().map(str::as_bytes)); + hasher.field(header.producers.callgraph.as_bytes()); + hasher.optional(header.ignore_fingerprint.as_deref().map(str::as_bytes)); + hasher.field(std::env::consts::FAMILY.as_bytes()); + hasher.field(b"head_tree:none"); + hasher.field(membership); + hasher.finish() +} + +/// The scope name of the base with this identity. +pub fn base_scope_for(identity: &[u8; 32]) -> String { + format!("{BASE_SCOPE_PREFIX}{}", to_hex(identity)) +} + +/// The authenticated identity of one base manifest, without any protection. +/// Builders use it before a base is published; readers use +/// [`AuthenticatedBase`]. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct BaseIdentity { + pub scope: String, + pub membership: [u8; 32], + pub identity: [u8; 32], +} + +impl BaseIdentity { + pub fn of(family: &str, manifest: &ManifestV2) -> Result { + let membership = membership_digest(manifest)?; + let identity = identity_digest(family, manifest.header(), &membership); + Ok(Self { + scope: base_scope_for(&identity), + membership, + identity, + }) + } + + pub fn header(&self, bound_generation: &str) -> BaseHeader { + BaseHeader { + scope: self.scope.clone(), + membership_digest: to_hex(&self.membership), + identity_digest: to_hex(&self.identity), + bound_generation: bound_generation.to_owned(), + } + } + + /// Checks a persisted header against this identity. `bound_generation` + /// is diagnostic only and never compared. + pub fn check_header(&self, header: &BaseHeader) -> Result<()> { + if header.scope != self.scope + || header.membership_digest != to_hex(&self.membership) + || header.identity_digest != to_hex(&self.identity) + { + return Err(BaseRefError::Corrupt(format!( + "header for {} does not match the pinned base {}", + header.scope, self.scope + ))); + } + Ok(()) + } +} + +/// A base generation protected by an exact-generation read marker and +/// authenticated against its own pinned manifest. Only [`resolve_base`] +/// constructs it. +#[derive(Debug)] +pub struct AuthenticatedBase { + identity: BaseIdentity, + generation: String, + view_dir: PathBuf, + manifest: ManifestV2, + marker: ReadMarker, +} + +impl AuthenticatedBase { + pub fn identity(&self) -> &BaseIdentity { + &self.identity + } + pub fn scope(&self) -> &str { + &self.identity.scope + } + pub fn generation(&self) -> &str { + &self.generation + } + pub fn view_dir(&self) -> &Path { + &self.view_dir + } + pub fn manifest(&self) -> &ManifestV2 { + &self.manifest + } + /// The header a worker publishes for this base, bound to this generation. + pub fn header(&self) -> BaseHeader { + self.identity.header(&self.generation) + } + pub fn check_header(&self, header: &BaseHeader) -> Result<()> { + self.identity.check_header(header) + } + /// The base side of a composed generation. It owns the marker, so the base + /// generation stays protected for as long as any bundle holds it. + pub fn into_generation(self) -> Arc { + Arc::new(ComposedGeneration::new( + self.generation, + self.manifest, + Some(Residency::Marker(self.marker)), + )) + } +} + +/// Reads a member row without any lock. `None` means no row. +fn member_row( + storage: &Path, + family: &str, + scope: &str, +) -> Result> { + let registry = v3::family_dir(storage, family)?.join("members.sqlite"); + if !registry.try_exists()? { + return Ok(None); + } + Ok(LedgerReader::open_existing(storage, family)?.member(scope)?) +} + +fn expected_dir(storage: &Path, family: &str, scope: &str, stored: &str) -> Result { + let dir = v3::view_dir(storage, family, scope)?; + if Path::new(stored) != dir { + return Err(BaseRefError::Corrupt(format!( + "member {scope} names an unexpected view directory" + ))); + } + Ok(dir) +} + +/// Durable pin-then-verify resolution of `scope`'s current base generation: +/// marker on `readers//`, then the pointer must still name G and the +/// member must still be a non-evicting `kind = base`, then the manifest's +/// digests are recomputed and must derive `scope`. Retries +/// [`RESOLVE_ATTEMPTS`] times when the pointer or row moves; a missing base is +/// reported unavailable, never replaced by another generation. +pub fn resolve_base(storage: &Path, family: &str, scope: &str) -> Result { + if !scope.starts_with(BASE_SCOPE_PREFIX) { + return Err(BaseRefError::Corrupt(format!( + "{scope} is not a membership-keyed base scope" + ))); + } + for _ in 0..RESOLVE_ATTEMPTS { + let Some((kind, state, stored_dir)) = member_row(storage, family, scope)? else { + return Err(BaseRefError::Unavailable(format!( + "{scope} is not registered" + ))); + }; + if kind != KIND_BASE { + return Err(BaseRefError::Corrupt(format!( + "{scope} is not a base member" + ))); + } + if state == STATE_EVICTING { + return Err(BaseRefError::Unavailable(format!("{scope} is evicting"))); + } + let view_dir = expected_dir(storage, family, scope, &stored_dir)?; + let Some(store) = ViewStore::existing_dir(view_dir.clone()) else { + return Err(BaseRefError::Unavailable(format!("{scope} has no pointer"))); + }; + let Some(generation) = store.current_generation_read_only()? else { + return Err(BaseRefError::Unavailable(format!("{scope} is unpublished"))); + }; + let marker = ReadMarker::create(&view_dir, &generation)?; + if store.current_generation_read_only()?.as_deref() != Some(generation.as_str()) { + continue; + } + match member_row(storage, family, scope)? { + Some((kind, state, _)) if kind == KIND_BASE && state != STATE_EVICTING => {} + _ => continue, + } + let manifest = store.load_manifest_v2(&generation)?; + let identity = BaseIdentity::of(family, &manifest)?; + if identity.scope != scope { + return Err(BaseRefError::Corrupt(format!( + "{scope} holds a manifest of identity {}", + identity.scope + ))); + } + return Ok(AuthenticatedBase { + identity, + generation, + view_dir, + manifest, + marker, + }); + } + Err(BaseRefError::Unavailable(format!( + "{scope} moved during {RESOLVE_ATTEMPTS} attempts" + ))) +} + +static NONCE_SEQ: AtomicU64 = AtomicU64::new(0); + +/// A process-unique, unguessable-enough acquisition identity. +pub(crate) fn fresh_nonce() -> String { + let mut hasher = blake3::Hasher::new(); + hasher.update(&std::process::id().to_le_bytes()); + hasher.update( + &std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() + .to_le_bytes(), + ); + hasher.update(&NONCE_SEQ.fetch_add(1, Ordering::Relaxed).to_le_bytes()); + to_hex(&hasher.finalize().as_bytes()[..16]) +} + +/// The insertion-barrier marker: a read marker in the base's `readers//` +/// whose file records a per-acquisition nonce. Its path is unique to this +/// acquisition, so another holder's release never removes it. +#[derive(Debug)] +pub struct BaseRefMarker { + marker: ReadMarker, + nonce: String, + generation: String, +} + +impl BaseRefMarker { + fn acquire(view_dir: &Path, generation: &str) -> io::Result { + let nonce = fresh_nonce(); + Ok(Self { + marker: ReadMarker::create_with_nonce(view_dir, generation, &nonce)?, + nonce, + generation: generation.to_owned(), + }) + } + /// True while the marker file at its own path still carries this + /// acquisition's nonce. + pub fn verify_held(&self) -> io::Result { + self.marker.verify_held() + } + pub fn touch_if_due(&self) -> io::Result<()> { + self.marker.touch_if_due() + } + pub fn nonce(&self) -> &str { + &self.nonce + } + pub fn generation(&self) -> &str { + &self.generation + } + pub fn path(&self) -> &Path { + self.marker.path() + } +} + +/// True when the registry has the base-reference tables. +pub(crate) fn has_base_refs(tx: &rusqlite::Connection) -> rusqlite::Result { + tx.query_row( + "SELECT EXISTS(SELECT 1 FROM sqlite_schema WHERE type='table' AND name='base_refs')", + [], + |r| r.get(0), + ) +} + +pub(crate) fn gc_epoch(tx: &rusqlite::Connection) -> rusqlite::Result { + tx.query_row( + "SELECT gc_epoch FROM sweep_state WHERE singleton=1", + [], + |r| r.get(0), + ) +} + +/// The base barrier: the storage-root reservation lock, then one IMMEDIATE +/// transaction on the family registry, upgraded to the base-reference +/// schema. A base has no view session, so this is its equivalent of +/// `ViewRegistration::under_pin_barrier`. Deregistration and reclaim take +/// the same barrier. +pub fn with_base_barrier( + storage: &Path, + family: &str, + body: impl FnOnce(&Transaction<'_>) -> Result, +) -> Result { + let lock = ReservationLock::acquire(storage)?; + let mut registry = v3::open_base_refs_registry(storage, family, &lock, None)?; + barrier(&mut registry, body) +} + +/// One barrier transaction on an already-open registry. An error from `body` +/// rolls the transaction back. +pub(crate) fn barrier( + registry: &mut RegistryWriter<'_>, + body: impl FnOnce(&Transaction<'_>) -> Result, +) -> Result { + let mut outcome = None; + let result = registry.with_barrier(|tx| match body(tx) { + Ok(value) => { + outcome = Some(Ok(value)); + Ok(()) + } + Err(error) => { + outcome = Some(Err(error)); + // Any refusal rolls back: nothing the closure wrote survives. + Err(v3::Error::Refused("base_barrier_body_failed")) + } + }); + match (result, outcome) { + (_, Some(Err(error))) => Err(error), + (Err(error), _) => Err(error.into()), + (Ok(()), Some(value)) => value, + (Ok(()), None) => unreachable!("the barrier ran its body"), + } +} + +/// Registers (or re-binds) a v3 worker view member inside a barrier. A member +/// in `evicting` is never reused: its directory is about to disappear. +pub(crate) fn register_view_in( + tx: &Transaction<'_>, + storage: &Path, + family: &str, + scope: &str, + root: &Path, +) -> Result { + if scope.starts_with(BASE_SCOPE_PREFIX) { + return Err(BaseRefError::Corrupt(format!( + "{scope} is reserved for bases" + ))); + } + let dir = v3::view_dir(storage, family, scope)?; + let existing: Option<(String, String)> = tx + .query_row( + "SELECT kind,state FROM members WHERE scope=?1", + [scope], + |r| Ok((r.get(0)?, r.get(1)?)), + ) + .optional()?; + match existing { + Some((_, state)) if state == STATE_EVICTING => { + return Err(BaseRefError::Unavailable(format!( + "worker member {scope} is evicting" + ))); + } + Some((kind, _)) if kind != KIND_VIEW => { + return Err(BaseRefError::Corrupt(format!( + "{scope} is not a view member" + ))); + } + _ => {} + } + tx.execute( + "INSERT INTO members(scope,kind,root,view_dir,registered_at_ms,last_bind_ms,state) VALUES(?1,'view',?2,?3,?4,?4,'active') ON CONFLICT(scope) DO UPDATE SET last_bind_ms=excluded.last_bind_ms,root=excluded.root,missing_sweeps=0", + params![scope, encode_root(root), dir.to_string_lossy(), crate::pins::now_ms() as i64], + )?; + Ok(dir) +} + +fn encode_root(root: &Path) -> Option> { + #[cfg(unix)] + { + use std::os::unix::ffi::OsStrExt; + Some(root.as_os_str().as_bytes().to_vec()) + } + #[cfg(not(unix))] + { + root.to_str().map(|text| text.as_bytes().to_vec()) + } +} + +/// Deletes one acquisition's `intent` row. Never touches a `published` row: +/// a landed reference is not abort cleanup. +pub(crate) fn delete_intent( + tx: &Transaction<'_>, + worker_scope: &str, + base_scope: &str, + nonce: &str, +) -> rusqlite::Result { + tx.execute( + "DELETE FROM base_refs WHERE worker_scope=?1 AND base_scope=?2 AND nonce=?3 AND state='intent'", + params![worker_scope, base_scope, nonce], + ) +} + +/// Turns one acquisition's row into the durable reference of +/// `worker_generation`, re-inserting it if a sweep already retired it. +pub(crate) fn publish_referrer( + tx: &Transaction<'_>, + worker_scope: &str, + base_scope: &str, + nonce: &str, + membership_digest: &str, + worker_generation: &str, +) -> rusqlite::Result<()> { + let epoch = gc_epoch(tx)?; + let owner = super::registry::current_owner(); + tx.execute( + "INSERT INTO base_refs(worker_scope,base_scope,nonce,membership_digest,worker_generation,state,owner_pid,owner_start,updated_epoch) VALUES(?1,?2,?3,?4,?5,'published',?6,?7,?8) ON CONFLICT(worker_scope,base_scope,nonce) DO UPDATE SET state='published',worker_generation=excluded.worker_generation,membership_digest=excluded.membership_digest,updated_epoch=excluded.updated_epoch", + params![ + worker_scope, + base_scope, + nonce, + membership_digest, + worker_generation, + owner.pid, + owner.start_time as i64, + epoch + ], + )?; + // A republication supersedes this worker's strict-rebuild record. + tx.execute( + "DELETE FROM strict_rebuild WHERE worker_scope=?1", + [worker_scope], + )?; + Ok(()) +} + +/// One worker publication that references a base. +pub struct WorkerPublication<'a> { + pub storage: &'a Path, + pub family: &'a str, + pub worker_scope: &'a str, + pub root: &'a Path, + /// The base header the delta was derived from. Its scope and digests are + /// checked against the pinned base; its generation is diagnostic. + pub base: &'a BaseHeader, + /// The delta: entries, tombstones and readiness overrides. Its `base` + /// header is replaced with the authenticated one before it is written. + pub delta: ManifestV2, + /// The durable worker generation the pointer CAS expects (`Wold`). + pub previous: Option, +} + +/// What a worker publication did. +#[derive(Debug)] +pub enum WorkerOutcome { + /// The pointer names the new generation and its referrer row is + /// `published`. The composed generation owns exact-generation markers on + /// both the worker and the base generation. + Published { + generation: String, + composed: Arc, + }, + /// Another publisher won the CAS. This job's intent row is gone; the caller + /// schedules a fresh, revalidated job against the winner. + Lost { + current: Option, + equivalent: bool, + }, +} + +// Test seam: runs after the worker's pointer CAS was attempted and before +// its outcome is handled, so tests can fail or observe that window. +#[cfg(test)] +thread_local! { + pub(crate) static AFTER_WORKER_CAS: std::cell::RefCell Option>>> = + const { std::cell::RefCell::new(None) }; +} + +/// Publishes a worker generation that references a base, following the +/// insertion barrier and publication order of the views design. +pub fn publish_worker( + request: WorkerPublication<'_>, + observer: Option<&dyn DurabilityObserver>, +) -> Result { + let WorkerPublication { + storage, + family, + worker_scope, + root, + base, + mut delta, + previous, + } = request; + let base_scope = base.scope.as_str(); + // (1) Inside the barrier: confirm the base, create the marker, then the + // intent row. File first: a rollback leaves only an orphan marker, which + // a sweep reclaims once this process is gone. + let (worker_dir, marker) = with_base_barrier(storage, family, |tx| { + let worker_dir = register_view_in(tx, storage, family, worker_scope, root)?; + let (kind, state, stored): (String, String, String) = tx + .query_row( + "SELECT kind,state,view_dir FROM members WHERE scope=?1", + [base_scope], + |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)), + ) + .optional()? + .ok_or_else(|| BaseRefError::Unavailable(format!("{base_scope} is not registered")))?; + if kind != KIND_BASE { + return Err(BaseRefError::Corrupt(format!("{base_scope} is not a base"))); + } + if state == STATE_EVICTING { + return Err(BaseRefError::Unavailable(format!( + "{base_scope} is evicting" + ))); + } + let base_dir = expected_dir(storage, family, base_scope, &stored)?; + let generation = ViewStore::existing_dir(base_dir.clone()) + .map(|store| store.current_generation_read_only()) + .transpose()? + .flatten() + .ok_or_else(|| BaseRefError::Unavailable(format!("{base_scope} has no pointer")))?; + let marker = BaseRefMarker::acquire(&base_dir, &generation)?; + let owner = super::registry::current_owner(); + tx.execute( + "INSERT INTO base_refs(worker_scope,base_scope,nonce,membership_digest,worker_generation,state,owner_pid,owner_start,updated_epoch) VALUES(?1,?2,?3,?4,NULL,'intent',?5,?6,?7)", + params![ + worker_scope, + base_scope, + marker.nonce(), + base.membership_digest, + owner.pid, + owner.start_time as i64, + gc_epoch(tx)? + ], + )?; + Ok((worker_dir, marker)) + })?; + let marked = boundary(observer, DurabilityStep::BaseRefMarked); + // Terminal cleanup: this acquisition's row goes under the barrier before + // its marker drops. + let cleanup = |marker: BaseRefMarker| -> Result<()> { + let result = with_base_barrier(storage, family, |tx| { + delete_intent(tx, worker_scope, base_scope, marker.nonce())?; + Ok(()) + }); + drop(marker); + result + }; + let abort = |error: BaseRefError, marker: BaseRefMarker| -> BaseRefError { + match cleanup(marker) { + Ok(()) => error, + Err(cleanup) => { + BaseRefError::Unavailable(format!("{error}; intent cleanup failed: {cleanup}")) + } + } + }; + + if let Err(error) = marked { + return Err(abort(error.into(), marker)); + } + + // (2) After the barrier: authenticate the base (pin-then-verify) and + // check that it is the marked generation or a readiness successor with + // the expected membership. + let authenticated = match resolve_base(storage, family, base_scope) + .and_then(|resolved| resolved.check_header(base).map(|()| resolved)) + { + Ok(resolved) => resolved, + Err(error) => return Err(abort(error, marker)), + }; + let header = authenticated.header(); + if let Err(error) = delta.set_base_header(header.clone()) { + return Err(abort(error.into(), marker)); + } + let base_generation = authenticated.into_generation(); + let worker_store = match ViewStore::open_dir(worker_dir.clone()) { + Ok(store) => store, + Err(error) => return Err(abort(error.into(), marker)), + }; + let name = match GenerationName::for_manifest(&delta) { + Ok(name) => name, + Err(error) => return Err(abort(error.into(), marker)), + }; + let generation = name.to_string(); + let prepared = match worker_store.prepare_v2(&name, previous.as_deref(), &delta, observer) { + Ok(prepared) => prepared, + Err(error) => return Err(abort(error.into(), marker)), + }; + // The bundle's own marker on the new worker generation exists before the + // CAS, so the generation is protected from the moment it is published. + let worker_marker = match ReadMarker::create(&worker_dir, &generation) { + Ok(marker) => marker, + Err(error) => return Err(abort(error.into(), marker)), + }; + let composed = match ComposedGeneration::compose( + generation.clone(), + delta, + base_generation, + Some(Residency::Marker(worker_marker)), + ) { + Ok(composed) => Arc::new(composed), + Err(error) => return Err(abort(error.into(), marker)), + }; + + // (3) Immediately before the CAS: the acquisition marker is still ours, + // and the base is still a registered, non-evicting base. + let verified = marker + .verify_held() + .map_err(BaseRefError::from) + .and_then(|held| { + if !held { + return Err(BaseRefError::Unavailable( + "the base marker was reclaimed before publication".into(), + )); + } + with_base_barrier(storage, family, |tx| { + let state: Option<(String, String)> = tx + .query_row( + "SELECT kind,state FROM members WHERE scope=?1", + [base_scope], + |r| Ok((r.get(0)?, r.get(1)?)), + ) + .optional()?; + match state { + Some((kind, state)) if kind == KIND_BASE && state != STATE_EVICTING => Ok(()), + _ => Err(BaseRefError::Unavailable(format!( + "{base_scope} left the registry before publication" + ))), + } + }) + }); + if let Err(error) = verified { + return Err(abort(error, marker)); + } + + // (4) The CAS. From here on the reference may be durable. + let outcome = worker_store.commit_v2(prepared, observer); + #[cfg(test)] + let outcome = + match AFTER_WORKER_CAS.with(|hook| hook.borrow_mut().as_mut().and_then(|hook| hook())) { + Some(error) => Err(super::ViewError::Io(error)), + None => outcome, + }; + let landed = match outcome { + Ok(PublishV2::Published) => Ok(()), + Ok(PublishV2::Conflict { current }) => { + cleanup(marker)?; + return Ok(WorkerOutcome::Lost { + current, + equivalent: false, + }); + } + Ok(PublishV2::EquivalentWinner { current }) => { + cleanup(marker)?; + return Ok(WorkerOutcome::Lost { + current: Some(current), + equivalent: true, + }); + } + Err(error) => { + // The CAS may have landed. Resolve it while keeping protection: a + // landed reference becomes `published`; only a pointer that + // certainly names something else allows abort cleanup; an + // unreadable pointer keeps the intent row for reconciliation. + match worker_store.current_generation_read_only() { + Ok(Some(current)) if current == generation => Err(error), + Ok(_) => return Err(abort(error.into(), marker)), + Err(_) => { + drop(marker); + return Err(error.into()); + } + } + } + }; + // (5) ReferrerPublished, under the barrier, before the marker drops. + let published = with_base_barrier(storage, family, |tx| { + publish_referrer( + tx, + worker_scope, + base_scope, + marker.nonce(), + &header.membership_digest, + &generation, + )?; + Ok(()) + }); + match published.and_then(|()| Ok(boundary(observer, DurabilityStep::ReferrerPublished)?)) { + Ok(()) => {} + Err(error) => { + // The reference stays protected: either the row is already + // `published`, or it is still `intent` and reconciliation repairs + // it from the published edge after this owner is gone. + drop(marker); + return Err(error); + } + } + // (6) The bundle owns its exact-generation markers; the insertion marker + // can go. + drop(marker); + landed.map_err(BaseRefError::from)?; + Ok(WorkerOutcome::Published { + generation, + composed, + }) +} + +/// Opens a v3 worker's published generation for a foreign or parent-folder +/// reader with a dual pin-then-verify: the worker's current W, then, when W +/// names a base, the base's current generation through [`resolve_base`], +/// checked against W's header. On any failed verify both markers drop and the +/// whole open retries, at most [`RESOLVE_ATTEMPTS`] times; then the base is +/// reported unavailable. The returned bundle owns both markers. Its answers are +/// those of the worker's last publication, labelled by +/// [`ComposedGeneration::published`]. +pub fn open_foreign_v3( + storage: &Path, + family: &str, + scope: &str, + producers: &super::manifest_v2::Producers, +) -> Result>> { + let mut last = None; + for _ in 0..RESOLVE_ATTEMPTS { + let Some((kind, state, stored)) = member_row(storage, family, scope)? else { + return Ok(None); + }; + if kind != KIND_VIEW { + return Err(BaseRefError::Corrupt(format!( + "{scope} is not a view member" + ))); + } + if state == STATE_EVICTING { + return Ok(None); + } + let view_dir = expected_dir(storage, family, scope, &stored)?; + let Some(store) = ViewStore::existing_dir(view_dir.clone()) else { + return Ok(None); + }; + let Some(generation) = store.current_generation_read_only()? else { + return Ok(None); + }; + let marker = ReadMarker::create(&view_dir, &generation)?; + if store.current_generation_read_only()?.as_deref() != Some(generation.as_str()) { + continue; + } + let manifest = store.load_manifest_v2(&generation)?; + manifest.ensure_producers(producers)?; + let Some(header) = manifest.base_header().cloned() else { + return Ok(Some(Arc::new(ComposedGeneration::new( + generation, + manifest, + Some(Residency::Marker(marker)), + )))); + }; + let base = match resolve_base(storage, family, &header.scope) + .and_then(|base| base.check_header(&header).map(|()| base)) + { + Ok(base) => base, + Err(error @ BaseRefError::Unavailable(_)) => { + last = Some(error); + continue; + } + Err(error) => return Err(error), + }; + return Ok(Some(Arc::new(ComposedGeneration::compose( + generation, + manifest, + base.into_generation(), + Some(Residency::Marker(marker)), + )?))); + } + Err(last.unwrap_or_else(|| { + BaseRefError::Unavailable(format!("{scope} moved during {RESOLVE_ATTEMPTS} attempts")) + })) +} + +#[cfg(test)] +#[path = "base_refs_tests.rs"] +pub(crate) mod tests; diff --git a/crates/aft/src/views/base_refs_tests.rs b/crates/aft/src/views/base_refs_tests.rs new file mode 100644 index 0000000000..55a562e3c2 --- /dev/null +++ b/crates/aft/src/views/base_refs_tests.rs @@ -0,0 +1,691 @@ +//! Tests of base identity, the worker insertion protocol and the foreign +//! reader. Fixture helpers are shared with the sweep and builder tests. + +use std::path::Path; +use std::sync::Arc; + +use super::*; +use crate::blob_store::v2::{ContentHash, FamilyKey, FamilyPlane}; +use crate::views::base_builder::{publish_base, BasePublication}; +use crate::views::manifest_v2::{EntryPlanes, ManifestHeader, Producers, PublishedFrontier}; +use crate::views::readiness::PlaneState; +use crate::views::RelPath; + +pub(crate) const FAMILY: &str = "00112233445566ff"; + +pub(crate) fn producers() -> Producers { + Producers { + trigram: "tri".into(), + semantic: Some("sem".into()), + callgraph: "graph".into(), + } +} + +pub(crate) fn header() -> ManifestHeader { + ManifestHeader { + producers: producers(), + head_tree: None, + ignore_fingerprint: None, + segment: None, + } +} + +/// The trigram key the fixtures use for `bytes`. +pub(crate) fn key_of(bytes: &[u8]) -> FamilyKey { + FamilyKey::new(FamilyPlane::Trigram, *blake3::hash(bytes).as_bytes()) +} + +pub(crate) fn path(name: &str) -> RelPath { + RelPath::new(name.as_bytes().to_vec()).unwrap() +} + +/// A regular entry whose trigram plane is Ready under `key_of(bytes)` and +/// whose semantic plane is still pending. +pub(crate) fn entry(bytes: &[u8]) -> EntryV2 { + EntryV2::regular( + ContentHash::of(bytes), + bytes.len() as u64, + EntryPlanes { + trigram: Some(PlaneState::ready(&key_of(bytes))), + semantic: Some(PlaneState::pending("awaiting the base owner")), + callgraph: None, + }, + ) +} + +pub(crate) fn base_manifest(files: &[(&str, &[u8])]) -> ManifestV2 { + let mut manifest = ManifestV2::new_v3(header(), None, PublishedFrontier::default()); + for (name, bytes) in files { + manifest.insert(path(name), entry(bytes)).unwrap(); + } + manifest +} + +/// Puts each payload into the family's v3 trigram store. +pub(crate) fn put_rows(storage: &Path, payloads: &[&[u8]]) { + v3::publish(storage, FAMILY, 1 << 20, None, |publication| { + for payload in payloads { + publication.put(&key_of(payload), payload, 1)?; + } + Ok(()) + }) + .unwrap(); +} + +pub(crate) fn plane_has(storage: &Path, payload: &[u8]) -> bool { + v3::PlaneReader::open_existing(storage, FAMILY, FamilyPlane::Trigram) + .unwrap() + .get(&key_of(payload)) + .unwrap() + .is_some() +} + +/// Publishes a base over `files` (rows first) and returns its identity and +/// generation. +pub(crate) fn publish_test_base(storage: &Path, files: &[(&str, &[u8])]) -> (BaseIdentity, String) { + put_rows( + storage, + &files.iter().map(|(_, bytes)| *bytes).collect::>(), + ); + let manifest = base_manifest(files); + let identity = BaseIdentity::of(FAMILY, &manifest).unwrap(); + let BasePublication::Published { generation, .. } = + publish_base(storage, FAMILY, &manifest, None).unwrap() + else { + panic!("first base publication lost") + }; + (identity, generation) +} + +pub(crate) fn frontier() -> PublishedFrontier { + PublishedFrontier { + delta_cut_version: 7, + watcher_epoch: 3, + published_at_ms: 1_000, + verified_frontier: None, + } +} + +/// A worker delta over the base: one changed file and one deleted base file. +pub(crate) fn worker_delta( + identity: &BaseIdentity, + generation: &str, + changed: &[u8], +) -> ManifestV2 { + let mut delta = ManifestV2::new_v3(header(), Some(identity.header(generation)), frontier()); + delta + .insert(path("src/changed.rs"), entry(changed)) + .unwrap(); + delta +} + +pub(crate) fn bind( + storage: &Path, + scope: &str, + identity: &BaseIdentity, + generation: &str, + delta: ManifestV2, + previous: Option, +) -> Result { + let root = storage.join("roots").join(scope); + std::fs::create_dir_all(&root).unwrap(); + let header = identity.header(generation); + publish_worker( + WorkerPublication { + storage, + family: FAMILY, + worker_scope: scope, + root: &root, + base: &header, + delta, + previous, + }, + None, + ) +} + +/// `(worker, base, state, worker_generation)` of every referrer row. +pub(crate) fn refs(storage: &Path) -> Vec<(String, String, String, Option)> { + LedgerReader::open_existing(storage, FAMILY) + .unwrap() + .read(|c| { + Ok(c.prepare("SELECT worker_scope,base_scope,state,worker_generation FROM base_refs ORDER BY worker_scope,base_scope,nonce")? + .query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)))? + .collect::>>()?) + }) + .unwrap() +} + +pub(crate) fn marker_files(dir: &Path) -> usize { + let readers = dir.join("readers"); + let Ok(generations) = std::fs::read_dir(readers) else { + return 0; + }; + generations + .flatten() + .map(|generation| std::fs::read_dir(generation.path()).map_or(0, |files| files.count())) + .sum() +} + +pub(crate) fn published(outcome: WorkerOutcome) -> (String, Arc) { + match outcome { + WorkerOutcome::Published { + generation, + composed, + } => (generation, composed), + other => panic!("expected a publication, got {other:?}"), + } +} + +#[test] +fn membership_digest_ignores_readiness_and_binds_content_and_paths() { + let a = base_manifest(&[("a.rs", b"one"), ("b.rs", b"two")]); + let mut ready = a.clone(); + if let Some(EntryV2::Regular { planes, .. }) = ready.get_mut(&path("a.rs")) { + planes.semantic = Some(PlaneState::ready(&FamilyKey::new( + FamilyPlane::Semantic, + [9; 32], + ))); + } + assert_eq!( + membership_digest(&a).unwrap(), + membership_digest(&ready).unwrap() + ); + let edited = base_manifest(&[("a.rs", b"one!"), ("b.rs", b"two")]); + assert_ne!( + membership_digest(&a).unwrap(), + membership_digest(&edited).unwrap() + ); + let renamed = base_manifest(&[("a2.rs", b"one"), ("b.rs", b"two")]); + assert_ne!( + membership_digest(&a).unwrap(), + membership_digest(&renamed).unwrap() + ); + let identity = BaseIdentity::of(FAMILY, &a).unwrap(); + assert!(identity.scope.starts_with(BASE_SCOPE_PREFIX)); + assert_ne!(identity, BaseIdentity::of("ffeeddccbbaa9988", &a).unwrap()); + let mut other_producer = a.clone(); + other_producer.header_mut().producers.semantic = None; + assert_ne!(identity, BaseIdentity::of(FAMILY, &other_producer).unwrap()); +} + +#[test] +fn a_membership_keyed_base_never_claims_tree_identity() { + let mut tree = base_manifest(&[("a.rs", b"one")]); + tree.header_mut().head_tree = Some("caller-supplied".into()); + assert!(matches!( + membership_digest(&tree), + Err(BaseRefError::Corrupt(_)) + )); + let mut oid = base_manifest(&[("a.rs", b"one")]); + if let Some(EntryV2::Regular { git_oid, .. }) = oid.get_mut(&path("a.rs")) { + *git_oid = Some("0".repeat(40)); + } + assert!(matches!( + membership_digest(&oid), + Err(BaseRefError::Corrupt(_)) + )); +} + +#[test] +fn resolution_authenticates_the_pinned_base_and_rejects_forged_headers() { + let _serial = v3::serial_for_test(); + let storage = tempfile::tempdir().unwrap(); + let (identity, generation) = publish_test_base(storage.path(), &[("a.rs", b"alpha")]); + let base = resolve_base(storage.path(), FAMILY, &identity.scope).unwrap(); + assert_eq!(base.generation(), generation); + assert_eq!(base.identity(), &identity); + assert_eq!(marker_files(base.view_dir()), 1); + base.check_header(&identity.header("any generation: diagnostic only")) + .unwrap(); + let mut forged = identity.header(&generation); + forged.membership_digest = "11".repeat(32); + assert!(matches!( + base.check_header(&forged), + Err(BaseRefError::Corrupt(_)) + )); + let mut forged = identity.header(&generation); + forged.identity_digest = "22".repeat(32); + assert!(matches!( + base.check_header(&forged), + Err(BaseRefError::Corrupt(_)) + )); + let dir = base.view_dir().to_path_buf(); + drop(base); + assert_eq!(marker_files(&dir), 0); + assert!(matches!( + resolve_base(storage.path(), FAMILY, &base_scope_for(&[3; 32])), + Err(BaseRefError::Unavailable(_)) + )); +} + +#[test] +fn a_manifest_stored_under_another_identity_is_never_authenticated() { + let _serial = v3::serial_for_test(); + let storage = tempfile::tempdir().unwrap(); + let (_identity, _) = publish_test_base(storage.path(), &[("a.rs", b"alpha")]); + // Register a base scope and give it a manifest whose identity derives a + // different scope: the stored strings are not proof. + let scope = base_scope_for(&[5; 32]); + let dir = with_base_barrier(storage.path(), FAMILY, |tx| { + Ok(crate::views::base_builder::register_base_in(tx, storage.path(), FAMILY, &scope)?.0) + }) + .unwrap(); + let store = ViewStore::open_dir(dir).unwrap(); + let manifest = base_manifest(&[("a.rs", b"beta")]); + let name = GenerationName::for_manifest(&manifest).unwrap(); + let prepared = store.prepare_v2(&name, None, &manifest, None).unwrap(); + assert_eq!( + store.commit_v2(prepared, None).unwrap(), + PublishV2::Published + ); + assert!(matches!( + resolve_base(storage.path(), FAMILY, &scope), + Err(BaseRefError::Corrupt(_)) + )); +} + +#[test] +fn a_worker_publication_records_a_published_referrer_and_composes_over_the_base() { + let _serial = v3::serial_for_test(); + let storage = tempfile::tempdir().unwrap(); + let (identity, generation) = publish_test_base( + storage.path(), + &[("src/a.rs", b"alpha"), ("src/changed.rs", b"old")], + ); + put_rows(storage.path(), &[b"new"]); + let (worker_generation, composed) = published( + bind( + storage.path(), + "worker-a", + &identity, + &generation, + worker_delta(&identity, &generation, b"new"), + None, + ) + .unwrap(), + ); + assert_eq!( + refs(storage.path()), + vec![( + "worker-a".to_string(), + identity.scope.clone(), + REF_PUBLISHED.to_string(), + Some(worker_generation.clone()) + )] + ); + // Lookups: the delta's changed entry, the inherited base entry. + assert_eq!( + composed.entry(&path("src/changed.rs")), + Some(&entry(b"new")) + ); + assert_eq!(composed.entry(&path("src/a.rs")), Some(&entry(b"alpha"))); + assert_eq!(composed.published(), Some(&frontier())); + // The bundle owns one marker on the base generation and one on its own; + // the insertion marker is gone. + let base_dir = v3::view_dir(storage.path(), FAMILY, &identity.scope).unwrap(); + let worker_dir = v3::view_dir(storage.path(), FAMILY, "worker-a").unwrap(); + assert_eq!(marker_files(&base_dir), 1); + assert_eq!(marker_files(&worker_dir), 1); + drop(composed); + assert_eq!(marker_files(&base_dir), 0); + assert_eq!(marker_files(&worker_dir), 0); + // Nothing under the unassigned namespace. + assert!(!storage.path().join("views/v3").exists()); +} + +#[test] +fn a_lost_cas_deletes_only_this_jobs_intent_row() { + let _serial = v3::serial_for_test(); + let storage = tempfile::tempdir().unwrap(); + let (identity, generation) = publish_test_base(storage.path(), &[("src/changed.rs", b"old")]); + put_rows(storage.path(), &[b"one", b"two"]); + let (first, _composed) = published( + bind( + storage.path(), + "worker-a", + &identity, + &generation, + worker_delta(&identity, &generation, b"one"), + None, + ) + .unwrap(), + ); + // A stale Wold: the CAS must lose, and only the losing job's row goes. + let outcome = bind( + storage.path(), + "worker-a", + &identity, + &generation, + worker_delta(&identity, &generation, b"two"), + None, + ) + .unwrap(); + assert!( + matches!(&outcome, WorkerOutcome::Lost { current: Some(current), equivalent: false } if *current == first), + "{outcome:?}" + ); + assert_eq!( + refs(storage.path()), + vec![( + "worker-a".into(), + identity.scope.clone(), + REF_PUBLISHED.into(), + Some(first) + )] + ); +} + +#[test] +fn a_failed_verify_cleans_up_its_intent_before_any_pointer_moves() { + let _serial = v3::serial_for_test(); + let storage = tempfile::tempdir().unwrap(); + let (identity, generation) = publish_test_base(storage.path(), &[("src/changed.rs", b"old")]); + let mut forged = identity.clone(); + forged.membership = [1; 32]; + let error = bind( + storage.path(), + "worker-a", + &forged, + &generation, + worker_delta(&identity, &generation, b"x"), + None, + ) + .unwrap_err(); + assert!(matches!(error, BaseRefError::Corrupt(_)), "{error}"); + assert!(refs(storage.path()).is_empty()); + let worker = v3::view_dir(storage.path(), FAMILY, "worker-a").unwrap(); + let pointer = + ViewStore::existing_dir(worker).map(|store| store.current_generation_read_only().unwrap()); + assert!(matches!(pointer, None | Some(None)), "{pointer:?}"); + let base_dir = v3::view_dir(storage.path(), FAMILY, &identity.scope).unwrap(); + assert_eq!(marker_files(&base_dir), 0); +} + +#[test] +fn a_landed_cas_is_published_and_never_erased_as_abort_cleanup() { + let _serial = v3::serial_for_test(); + let storage = tempfile::tempdir().unwrap(); + let (identity, generation) = publish_test_base(storage.path(), &[("src/changed.rs", b"old")]); + put_rows(storage.path(), &[b"landed"]); + AFTER_WORKER_CAS.with(|hook| { + *hook.borrow_mut() = Some(Box::new(|| Some(io::Error::other("sync failed after CAS")))) + }); + let result = bind( + storage.path(), + "worker-a", + &identity, + &generation, + worker_delta(&identity, &generation, b"landed"), + None, + ); + AFTER_WORKER_CAS.with(|hook| hook.borrow_mut().take()); + assert!(result.is_err()); + let worker = v3::view_dir(storage.path(), FAMILY, "worker-a").unwrap(); + let current = ViewStore::existing_dir(worker) + .unwrap() + .current_generation_read_only() + .unwrap() + .unwrap(); + assert_eq!( + refs(storage.path()), + vec![( + "worker-a".into(), + identity.scope.clone(), + REF_PUBLISHED.into(), + Some(current) + )] + ); + for _ in 0..3 { + let report = crate::views::base_sweep::sweep_family(storage.path(), FAMILY, None).unwrap(); + assert!(report.reclaimed_bases.is_empty(), "{report:?}"); + } + let reader = open_foreign_v3(storage.path(), FAMILY, "worker-a", &producers()) + .unwrap() + .unwrap(); + assert_eq!( + reader.entry(&path("src/changed.rs")), + Some(&entry(b"landed")) + ); + assert!(plane_has(storage.path(), b"old")); +} + +#[test] +fn a_foreign_reader_owns_both_markers_and_reports_the_published_frontier() { + let _serial = v3::serial_for_test(); + let storage = tempfile::tempdir().unwrap(); + let (identity, generation) = publish_test_base(storage.path(), &[("src/a.rs", b"alpha")]); + put_rows(storage.path(), &[b"new"]); + drop( + bind( + storage.path(), + "worker-a", + &identity, + &generation, + worker_delta(&identity, &generation, b"new"), + None, + ) + .unwrap(), + ); + let reader = crate::views::read::open_foreign_v3_generation( + storage.path(), + FAMILY, + "worker-a", + &producers(), + ) + .unwrap() + .unwrap(); + assert_eq!(reader.published(), Some(&frontier())); + assert_eq!(reader.entry(&path("src/a.rs")), Some(&entry(b"alpha"))); + let base_dir = v3::view_dir(storage.path(), FAMILY, &identity.scope).unwrap(); + let worker_dir = v3::view_dir(storage.path(), FAMILY, "worker-a").unwrap(); + assert_eq!(marker_files(&base_dir), 1); + assert_eq!(marker_files(&worker_dir), 1); + // The base cannot be reclaimed while the reader holds it, even after the + // worker's referrer row is gone. + with_base_barrier(storage.path(), FAMILY, |tx| { + tx.execute("DELETE FROM base_refs", [])?; + Ok(()) + }) + .unwrap(); + for _ in 0..3 { + let report = crate::views::base_sweep::sweep_family(storage.path(), FAMILY, None).unwrap(); + assert!(report.reclaimed_bases.is_empty(), "{report:?}"); + } + assert_eq!(reader.entry(&path("src/a.rs")), Some(&entry(b"alpha"))); + drop(reader); + assert_eq!(marker_files(&base_dir), 0); + assert_eq!(marker_files(&worker_dir), 0); +} + +#[test] +fn a_foreign_reader_reports_a_missing_base_unavailable_after_its_retries() { + let _serial = v3::serial_for_test(); + let storage = tempfile::tempdir().unwrap(); + let (identity, generation) = publish_test_base(storage.path(), &[("src/a.rs", b"alpha")]); + drop( + bind( + storage.path(), + "worker-a", + &identity, + &generation, + worker_delta(&identity, &generation, b"alpha"), + None, + ) + .unwrap(), + ); + // Remove the base behind the protocol's back (a damaged store). + with_base_barrier(storage.path(), FAMILY, |tx| { + tx.execute("DELETE FROM members WHERE kind='base'", [])?; + Ok(()) + }) + .unwrap(); + let error = open_foreign_v3(storage.path(), FAMILY, "worker-a", &producers()).unwrap_err(); + assert!(matches!(error, BaseRefError::Unavailable(_)), "{error}"); + let worker_dir = v3::view_dir(storage.path(), FAMILY, "worker-a").unwrap(); + assert_eq!( + marker_files(&worker_dir), + 0, + "every attempt dropped its marker" + ); +} + +#[test] +fn the_first_base_publication_needs_a_complete_lexical_plane() { + let _serial = v3::serial_for_test(); + let storage = tempfile::tempdir().unwrap(); + let mut manifest = base_manifest(&[("a.rs", b"alpha")]); + if let Some(EntryV2::Regular { planes, .. }) = manifest.get_mut(&path("a.rs")) { + planes.trigram = Some(PlaneState::pending("not tokenized")); + } + assert!(matches!( + publish_base(storage.path(), FAMILY, &manifest, None), + Err(BaseRefError::Corrupt(_)) + )); +} + +#[test] +fn readiness_successors_are_monotone_and_keep_identity_and_segment() { + use crate::views::base_builder::check_successor; + let base = base_manifest(&[("a.rs", b"alpha")]); + let set = |state: PlaneState| { + let mut next = base.clone(); + if let Some(EntryV2::Regular { planes, .. }) = next.get_mut(&path("a.rs")) { + planes.semantic = Some(state); + } + next + }; + let ready = set(PlaneState::ready(&FamilyKey::new( + FamilyPlane::Semantic, + [4; 32], + ))); + let failed = set(PlaneState::Failed { + reason: "model outage".into(), + producer: "sem".into(), + }); + check_successor(FAMILY, &base, &ready).unwrap(); + check_successor(FAMILY, &base, &failed).unwrap(); + check_successor(FAMILY, &failed, &ready).unwrap(); + assert!( + check_successor(FAMILY, &ready, &base).is_err(), + "Ready -> Pending" + ); + assert!( + check_successor(FAMILY, &ready, &failed).is_err(), + "Ready -> Failed" + ); + let other_ready = set(PlaneState::ready(&FamilyKey::new( + FamilyPlane::Semantic, + [5; 32], + ))); + assert!( + check_successor(FAMILY, &ready, &other_ready).is_err(), + "a Ready key changed" + ); + let mut segment = ready.clone(); + segment.header_mut().segment = Some("ab".repeat(32)); + assert!( + check_successor(FAMILY, &ready, &segment).is_err(), + "segment replaced" + ); + let edited = base_manifest(&[("a.rs", b"alpha!")]); + assert!( + check_successor(FAMILY, &base, &edited).is_err(), + "membership changed" + ); +} + +#[test] +fn an_injected_failure_at_each_publication_boundary_keeps_references_safe() { + use crate::views::base_sweep::sweep_family; + use crate::views::contracts::{inject_fault_at, InjectedFault}; + for fault in InjectedFault::ALL { + // BaseRegistered: the member row exists without a pointer. With no + // live claim it is an empty, unreferenced base and goes after two + // sweeps. + { + let _serial = v3::serial_for_test(); + let storage = tempfile::tempdir().unwrap(); + put_rows(storage.path(), &[b"alpha"]); + let manifest = base_manifest(&[("a.rs", b"alpha")]); + let identity = BaseIdentity::of(FAMILY, &manifest).unwrap(); + inject_fault_at(DurabilityStep::BaseRegistered, fault); + assert!(publish_base(storage.path(), FAMILY, &manifest, None).is_err()); + assert!(matches!( + resolve_base(storage.path(), FAMILY, &identity.scope), + Err(BaseRefError::Unavailable(_)) + )); + assert_eq!( + sweep_family(storage.path(), FAMILY, None) + .unwrap() + .counted_bases + .len(), + 1 + ); + assert_eq!( + sweep_family(storage.path(), FAMILY, None) + .unwrap() + .reclaimed_bases + .len(), + 1 + ); + } + // BaseSuccessorPublished: the pointer already names the successor. + { + let _serial = v3::serial_for_test(); + let storage = tempfile::tempdir().unwrap(); + let (identity, first) = publish_test_base(storage.path(), &[("a.rs", b"alpha")]); + let mut next = base_manifest(&[("a.rs", b"alpha")]); + if let Some(EntryV2::Regular { planes, .. }) = next.get_mut(&path("a.rs")) { + planes.semantic = Some(PlaneState::Failed { + reason: "model outage".into(), + producer: "sem".into(), + }); + } + inject_fault_at(DurabilityStep::BaseSuccessorPublished, fault); + assert!(publish_base(storage.path(), FAMILY, &next, None).is_err()); + let base = resolve_base(storage.path(), FAMILY, &identity.scope).unwrap(); + assert_ne!(base.generation(), first); + assert_eq!(base.manifest(), &next); + } + // BaseRefMarked: the job aborts and cleans up its own intent. + // ReferrerPublished: the row is already published and stays. + for step in [ + DurabilityStep::BaseRefMarked, + DurabilityStep::ReferrerPublished, + ] { + let _serial = v3::serial_for_test(); + let storage = tempfile::tempdir().unwrap(); + let (identity, generation) = + publish_test_base(storage.path(), &[("src/a.rs", b"alpha")]); + inject_fault_at(step, fault); + assert!(bind( + storage.path(), + "worker-a", + &identity, + &generation, + worker_delta(&identity, &generation, b"alpha"), + None, + ) + .is_err()); + let base_dir = v3::view_dir(storage.path(), FAMILY, &identity.scope).unwrap(); + assert_eq!(marker_files(&base_dir), 0, "{step:?} {fault:?}"); + let rows = refs(storage.path()); + if step == DurabilityStep::BaseRefMarked { + assert!(rows.is_empty(), "{step:?} {fault:?}: {rows:?}"); + } else { + assert_eq!(rows.len(), 1, "{step:?} {fault:?}: {rows:?}"); + assert_eq!(rows[0].2, REF_PUBLISHED); + for _ in 0..2 { + let report = sweep_family(storage.path(), FAMILY, None).unwrap(); + assert!(report.counted_bases.is_empty(), "{report:?}"); + } + assert!( + open_foreign_v3(storage.path(), FAMILY, "worker-a", &producers()) + .unwrap() + .is_some() + ); + } + } + } +} diff --git a/crates/aft/src/views/base_sweep.rs b/crates/aft/src/views/base_sweep.rs new file mode 100644 index 0000000000..6a9f146e60 --- /dev/null +++ b/crates/aft/src/views/base_sweep.rs @@ -0,0 +1,818 @@ +//! The v3 family sweep of the base-reference protocol: marking, dangling-edge +//! records, bounded referrer reconciliation, the two-sweep base reclaim and +//! the plane-row sweep. +//! +//! The whole sweep runs under the storage-root reservation lock. Every v3 +//! registry barrier and every v3 plane write takes that lock first, so no +//! referrer row, member row or plane row changes while the sweep marks. +//! Pointer compare-and-swaps and reader markers do not take the lock; the +//! protocol stays safe against them because: +//! +//! - a worker's `intent` row exists before its CAS, and an `intent` row whose +//! owner is live is never touched, so a reference that lands after marking +//! is still held by its row; +//! - rows written after the sweep began carry `updated_epoch >= S` and are not +//! visited by this sweep; +//! - readers mark first and verify the pointer after, so a reader either sees +//! a generation this sweep marked, or retries. +//! +//! Only I/O uncertainty aborts a sweep with nothing deleted. A dangling or +//! corrupt edge is counted, recorded in `strict_rebuild`, and the sweep goes +//! on. + +use std::collections::{BTreeMap, BTreeSet, HashSet}; +use std::io; +use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant}; + +use rusqlite::{params, OptionalExtension, Transaction}; + +use crate::blob_store::v3::{self, PlaneRowCursor, RegistryWriter, ReservationLock}; +use crate::pins::PinOwner; + +use super::base_refs::{ + barrier, gc_epoch, BaseIdentity, BaseRefError, Result, KIND_BASE, REF_INTENT, REF_ORPHANED, + REF_PUBLISHED, +}; +use super::contracts::{boundary, DurabilityObserver, DurabilityStep}; +use super::eviction::{v3_base_hold, Hold}; +use super::manifest_v2::ManifestV2; +use super::registry::STATE_EVICTING; +use super::ViewStore; + +/// Ruled referrer-reconciliation caps per family pass (pre-slice-4 ruling): +/// 256 rows or 25 ms, whichever comes first, cumulatively across +/// transactions. +pub const RECONCILE_ROWS: usize = 256; +pub const RECONCILE_TIME: Duration = Duration::from_millis(25); +/// Consecutive unreferenced sweeps before a base is reclaimed, mirroring +/// `MISSING_ROOT_SWEEPS`. +pub const UNREFERENCED_SWEEPS: i64 = 2; +/// Rows the plane-row sweep examines per visit before resuming next time. +pub const PLANE_ROWS_PER_VISIT: u64 = 100_000; + +/// Bounds of one sweep. Tests shrink them to reach a bound with a few rows. +#[derive(Clone, Copy, Debug)] +pub struct SweepBounds { + pub reconcile_rows: usize, + pub reconcile_time: Duration, + pub plane_rows: u64, + pub deadline: Option, +} + +impl Default for SweepBounds { + fn default() -> Self { + Self { + reconcile_rows: RECONCILE_ROWS, + reconcile_time: RECONCILE_TIME, + plane_rows: PLANE_ROWS_PER_VISIT, + deadline: None, + } + } +} + +/// An exact reference edge seen while marking. +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct Edge { + pub worker_scope: String, + pub worker_generation: String, + pub base_scope: String, + pub membership_digest: String, +} + +#[derive(Clone, Debug, Default)] +pub struct V3SweepReport { + pub epoch: i64, + pub marked_generations: usize, + pub marked_keys: usize, + pub valid_edges: usize, + /// Edges to a missing, non-base, chained, evicting or different-membership + /// base, counted on every sweep that sees them. + pub dangling_base_edges: usize, + pub reconciled_rows: usize, + pub kept_rows: usize, + pub deleted_rows: usize, + pub orphaned_rows: usize, + pub repaired_rows: usize, + /// Every row older than the sweep was visited in this pass. + pub reconcile_complete: bool, + pub counted_bases: Vec, + pub reclaimed_bases: Vec, + pub held_bases: Vec<(String, String)>, + pub plane_rows_examined: u64, + pub plane_rows_deleted: u64, + pub plane_rows_complete: bool, + /// Why nothing was swept (no registry, a version-1 registry, or a live + /// sweeper holding the lease). + pub skipped: Option, +} + +struct Member { + scope: String, + kind: String, + state: String, + view_dir: PathBuf, +} + +#[derive(Default)] +struct Marks { + keys: HashSet<[u8; 32]>, + generations: usize, + edges: BTreeSet, + /// Each base member's current identity; `None` when its current manifest + /// is not a valid base (a chain, a git_oid claim, a mismatched scope). + base_current: BTreeMap>, +} + +fn not_found(error: &super::ViewError) -> bool { + match error { + super::ViewError::IoAt { source, .. } | super::ViewError::Io(source) => { + source.kind() == io::ErrorKind::NotFound + } + _ => false, + } +} + +/// Generations of one member directory that must stay readable: current, +/// read-marked by a live owner, and pinned by a live owner. Pinned key lists +/// are marked directly. Markers and pins of dead owners are reclaimed on the +/// way. An unreadable pin or an over-long listing is uncertainty. +fn protected_generations( + view_dir: &Path, + keys: &mut HashSet<[u8; 32]>, +) -> Result> { + let mut generations = BTreeSet::new(); + if let Some(store) = ViewStore::existing_dir(view_dir.to_path_buf()) { + if let Some(current) = store.current_generation_read_only()? { + generations.insert(current); + } + } + let cap = crate::storage_retention::TREE_ENTRIES; + for entry in crate::storage_retention::bounded_dirs(&view_dir.join("readers"), cap)? { + if !entry.file_type()?.is_dir() { + continue; + } + let Some(generation) = entry.file_name().to_str().map(str::to_owned) else { + return Err(BaseRefError::Io(io::Error::other( + "non-UTF-8 reader directory", + ))); + }; + if crate::root_cache::sweep_read_markers(view_dir, &generation).protected { + generations.insert(generation); + } + } + for entry in crate::storage_retention::bounded_dirs(&view_dir.join("pins"), cap)? { + let path = entry.path(); + if path.extension().and_then(|extension| extension.to_str()) != Some("json") { + continue; + } + let metadata = crate::pins::read_metadata_strict(&path) + .map_err(|error| BaseRefError::Io(io::Error::other(error.to_string())))?; + let (metadata_path, keys_path) = crate::pins::pin_paths(view_dir, &metadata.generation); + if metadata_path != path { + return Err(BaseRefError::Io(io::Error::other(format!( + "pin {} names another generation", + path.display() + )))); + } + if !crate::pins::owner_is_live(&metadata.owner) { + let _ = std::fs::remove_file(&metadata_path); + let _ = std::fs::remove_file(&keys_path); + continue; + } + keys.extend( + crate::pins::read_keys(&keys_path) + .map_err(|error| BaseRefError::Io(io::Error::other(error.to_string())))?, + ); + generations.insert(metadata.generation); + } + Ok(generations) +} + +fn load(view_dir: &Path, generation: &str) -> Result> { + let Some(store) = ViewStore::existing_dir(view_dir.to_path_buf()) else { + return Ok(None); + }; + match store.load_manifest_v2(generation) { + Ok(manifest) => Ok(Some(manifest)), + // Nothing to mark, exactly as `mark_generation_if_present` treats a + // missing v2 manifest (for example, a private name pinned before its + // manifest was written). + Err(error) if not_found(&error) => Ok(None), + Err(super::ViewError::GenerationMismatch(_)) => Ok(None), + Err(error) => Err(error.into()), + } +} + +fn mark_member(family: &str, member: &Member, marks: &mut Marks) -> Result<()> { + let current = ViewStore::existing_dir(member.view_dir.clone()) + .map(|store| store.current_generation_read_only()) + .transpose()? + .flatten(); + for generation in protected_generations(&member.view_dir, &mut marks.keys)? { + let Some(manifest) = load(&member.view_dir, &generation)? else { + continue; + }; + marks.generations += 1; + marks + .keys + .extend(manifest.ready_keys().map(|key| *key.as_bytes())); + if member.kind == KIND_BASE { + if current.as_deref() == Some(generation.as_str()) { + let identity = BaseIdentity::of(family, &manifest) + .ok() + .filter(|identity| identity.scope == member.scope); + marks.base_current.insert(member.scope.clone(), identity); + } + continue; + } + if let Some(header) = manifest.base_header() { + marks.edges.insert(Edge { + worker_scope: member.scope.clone(), + worker_generation: generation, + base_scope: header.scope.clone(), + membership_digest: header.membership_digest.clone(), + }); + } + } + Ok(()) +} + +/// Why an edge protects nothing, or `None` for a valid edge. +fn dangling_reason( + edge: &Edge, + members: &BTreeMap, + marks: &Marks, +) -> Option<&'static str> { + let Some(base) = members.get(&edge.base_scope) else { + return Some("base_not_registered"); + }; + if base.kind != KIND_BASE { + return Some("not_a_base"); + } + if base.state == STATE_EVICTING { + return Some("base_evicting"); + } + match marks.base_current.get(&edge.base_scope) { + None => Some("base_unpublished"), + Some(None) => Some("base_corrupt"), + Some(Some(identity)) + if crate::blob_store::v2::to_hex(&identity.membership) != edge.membership_digest => + { + Some("membership_changed") + } + Some(Some(_)) => None, + } +} + +/// Runs one sweep, taking the storage-root reservation lock. +pub fn sweep_family( + storage: &Path, + family: &str, + observer: Option<&dyn DurabilityObserver>, +) -> Result { + // A standalone sweep treats `storage` as both the artifact and the + // durable root, and declares it so a reclaimed base directory may be + // removed. The retention collector declares split roots itself. + crate::root_cache::register_durable_owner(&crate::root_cache::StoragePaths::same(storage))?; + let lock = ReservationLock::acquire(storage)?; + sweep_family_locked( + storage, + family, + &lock, + observer, + SweepBounds::default(), + None, + ) +} + +/// Runs one sweep under the caller's reservation lock. `plane_cursor` is the +/// file that keeps the plane-row sweep's resume position between visits; a +/// sweep without one restarts the plane scan from the beginning. +pub fn sweep_family_locked( + storage: &Path, + family: &str, + lock: &ReservationLock, + observer: Option<&dyn DurabilityObserver>, + bounds: SweepBounds, + plane_cursor: Option<&Path>, +) -> Result { + let mut report = V3SweepReport::default(); + if !v3::family_dir(storage, family)? + .join("members.sqlite") + .try_exists()? + { + report.skipped = Some("no_registry".into()); + return Ok(report); + } + let mut registry = RegistryWriter::open_for_barrier(storage, family, lock)?; + if registry.registry_version()? < v3::REGISTRY_BASE_REFS_VERSION { + report.skipped = Some("registry_without_base_refs".into()); + return Ok(report); + } + let me = super::registry::current_owner(); + // Begin: take the sweep lease and raise the epoch. Everything written + // from here on carries `updated_epoch >= S`. + let begun = barrier(&mut registry, |tx| { + let lease: (Option, Option) = tx.query_row( + "SELECT lease_pid,lease_start FROM sweep_state WHERE singleton=1", + [], + |r| Ok((r.get(0)?, r.get(1)?)), + )?; + if let (Some(pid), Some(start)) = lease { + let owner = PinOwner { + pid, + start_time: start as u64, + }; + if owner != me && crate::pins::owner_is_live(&owner) { + return Ok(None); + } + } + tx.execute( + "UPDATE sweep_state SET gc_epoch=gc_epoch+1,lease_pid=?1,lease_start=?2 WHERE singleton=1", + params![me.pid, me.start_time as i64], + )?; + Ok(Some(gc_epoch(tx)?)) + })?; + let Some(epoch) = begun else { + report.skipped = Some("sweep_lease_held".into()); + return Ok(report); + }; + report.epoch = epoch; + let result = sweep_inner( + storage, + family, + &mut registry, + epoch, + observer, + bounds, + plane_cursor, + &mut report, + ); + let released = barrier(&mut registry, |tx| { + tx.execute( + "UPDATE sweep_state SET lease_pid=NULL,lease_start=NULL WHERE singleton=1 AND lease_pid=?1", + [me.pid], + )?; + Ok(()) + }); + result?; + released?; + Ok(report) +} + +#[allow(clippy::too_many_arguments)] +fn sweep_inner( + storage: &Path, + family: &str, + registry: &mut RegistryWriter<'_>, + epoch: i64, + observer: Option<&dyn DurabilityObserver>, + bounds: SweepBounds, + plane_cursor: Option<&Path>, + report: &mut V3SweepReport, +) -> Result<()> { + let members: Vec = barrier(registry, |tx| { + let mut statement = + tx.prepare("SELECT scope,kind,state,view_dir FROM members ORDER BY scope")?; + let rows = statement + .query_map([], |r| { + Ok(( + r.get::<_, String>(0)?, + r.get::<_, String>(1)?, + r.get::<_, String>(2)?, + r.get::<_, String>(3)?, + )) + })? + .collect::>>()?; + Ok(rows) + })? + .into_iter() + .filter_map(|(scope, kind, state, stored)| { + // A row naming any other directory is never followed or deleted. + let dir = v3::view_dir(storage, family, &scope).ok()?; + (Path::new(&stored) == dir).then_some(Member { + scope, + kind, + state, + view_dir: dir, + }) + }) + .collect(); + + // Finish base reclaims a crash interrupted after `evicting` committed. + for member in members + .iter() + .filter(|member| member.kind == KIND_BASE && member.state == STATE_EVICTING) + { + finish_base_reclaim(registry, member)?; + report.reclaimed_bases.push(member.scope.clone()); + } + + // Mark outside the barrier. Any I/O uncertainty aborts with nothing + // deleted. + let mut marks = Marks::default(); + for member in members + .iter() + .filter(|member| member.state != STATE_EVICTING) + { + mark_member(family, member, &mut marks)?; + } + report.marked_generations = marks.generations; + report.marked_keys = marks.keys.len(); + let by_scope: BTreeMap = members + .iter() + .map(|member| (member.scope.clone(), member)) + .collect(); + let mut valid = BTreeSet::new(); + let mut dangling = Vec::new(); + for edge in &marks.edges { + match dangling_reason(edge, &by_scope, &marks) { + None => { + valid.insert(edge.clone()); + } + Some(reason) => dangling.push((edge.clone(), reason)), + } + } + report.valid_edges = valid.len(); + report.dangling_base_edges = dangling.len(); + if !dangling.is_empty() { + barrier(registry, |tx| { + for (edge, reason) in &dangling { + tx.execute( + "INSERT INTO strict_rebuild(worker_scope,worker_generation,reason,recorded_epoch) VALUES(?1,?2,?3,?4) ON CONFLICT(worker_scope,worker_generation) DO UPDATE SET reason=excluded.reason,recorded_epoch=excluded.recorded_epoch", + params![edge.worker_scope, edge.worker_generation, reason, epoch], + )?; + } + tx.execute( + "UPDATE sweep_state SET dangling_base_edges=dangling_base_edges+?1 WHERE singleton=1", + [dangling.len() as i64], + )?; + Ok(()) + })?; + } + + reconcile(registry, epoch, &valid, bounds, report)?; + repair_missing(registry, epoch, &valid, report)?; + let referenced: BTreeSet<&str> = valid.iter().map(|edge| edge.base_scope.as_str()).collect(); + for member in members + .iter() + .filter(|member| member.kind == KIND_BASE && member.state != STATE_EVICTING) + { + count_or_reclaim(registry, member, &referenced, observer, report)?; + } + + // Plane rows last: the markers and pins listed above may have been the + // only protection of rows that a reclaimed base named. + let mut cursor = plane_cursor + .and_then(|path| std::fs::read(path).ok()) + .and_then(|bytes| serde_json::from_slice::(&bytes).ok()) + .unwrap_or_default(); + let swept = registry.sweep_plane_rows( + epoch, + &marks.keys, + &mut cursor, + bounds.plane_rows, + bounds.deadline, + )?; + report.plane_rows_examined = swept.examined; + report.plane_rows_deleted = swept.deleted; + report.plane_rows_complete = swept.complete; + if let Some(path) = plane_cursor { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + let bytes = serde_json::to_vec(&cursor).map_err(io::Error::other)?; + let temporary = path.with_extension("json.tmp"); + std::fs::write(&temporary, bytes)?; + std::fs::rename(&temporary, path)?; + } + Ok(()) +} + +type Row = ( + String, + String, + String, + String, + Option, + String, + u32, + i64, +); + +fn row_key(row: &Row) -> String { + format!("{}\u{1f}{}\u{1f}{}", row.1, row.0, row.2) +} + +/// Visits `base_refs` rows older than the sweep (`updated_epoch < S`) in key +/// order from the saved position, within the ruled row and time caps. Rows +/// written during this sweep are never visited, so never deleted by it. +fn reconcile( + registry: &mut RegistryWriter<'_>, + epoch: i64, + valid: &BTreeSet, + bounds: SweepBounds, + report: &mut V3SweepReport, +) -> Result<()> { + let started = Instant::now(); + let saved: Option = barrier(registry, |tx| { + Ok(tx.query_row( + "SELECT reconcile_cursor FROM sweep_state WHERE singleton=1", + [], + |r| r.get(0), + )?) + })?; + // One lap: from the saved position to the end, then from the start up to + // and including the saved position. + let mut after = saved.clone().unwrap_or_default(); + let mut wrapped = saved.is_none(); + let mut complete = false; + let edge_of = |row: &Row| Edge { + worker_scope: row.0.clone(), + worker_generation: row.4.clone().unwrap_or_default(), + base_scope: row.1.clone(), + membership_digest: row.3.clone(), + }; + // Any valid edge for this worker/base/membership: the repair target for a + // dead owner's row, whose generation the row itself may not record. + let repair_target = |row: &Row| -> Option { + valid + .iter() + .filter(|edge| { + edge.worker_scope == row.0 + && edge.base_scope == row.1 + && edge.membership_digest == row.3 + }) + .map(|edge| edge.worker_generation.clone()) + .next_back() + }; + while report.reconciled_rows < bounds.reconcile_rows + && started.elapsed() < bounds.reconcile_time + { + let room = (bounds.reconcile_rows - report.reconciled_rows).min(64); + let stop_at = (wrapped).then(|| saved.clone()).flatten(); + let batch_started = Instant::now(); + let done = barrier(registry, |tx| { + let rows: Vec = tx + .prepare( + "SELECT worker_scope,base_scope,nonce,membership_digest,worker_generation,state,owner_pid,owner_start FROM base_refs WHERE updated_epoch?2 ORDER BY (base_scope||char(31)||worker_scope||char(31)||nonce) LIMIT ?3", + )? + .query_map(params![epoch, after, room as i64], |r| { + Ok(( + r.get(0)?, + r.get(1)?, + r.get(2)?, + r.get(3)?, + r.get(4)?, + r.get(5)?, + r.get(6)?, + r.get(7)?, + )) + })? + .collect::>()?; + if rows.is_empty() { + return Ok(true); + } + for row in rows { + let key = row_key(&row); + if stop_at.as_deref().is_some_and(|stop| key.as_str() > stop) { + return Ok(true); + } + after = key; + report.reconciled_rows += 1; + let id = params![row.0, row.1, row.2]; + let owner_live = crate::pins::owner_is_live(&PinOwner { + pid: row.6, + start_time: row.7 as u64, + }); + match row.5.as_str() { + REF_PUBLISHED => { + let edge = edge_of(&row); + // Coalesce duplicate acquisitions of one edge: keep + // the first refreshed row, retire the rest. + let duplicate: bool = tx.query_row( + "SELECT EXISTS(SELECT 1 FROM base_refs WHERE worker_scope=?1 AND base_scope=?2 AND worker_generation=?3 AND membership_digest=?4 AND state='published' AND updated_epoch>=?5)", + params![row.0, row.1, edge.worker_generation, row.3, epoch], + |r| r.get(0), + )?; + if valid.contains(&edge) && !duplicate { + tx.execute( + "UPDATE base_refs SET updated_epoch=?4 WHERE worker_scope=?1 AND base_scope=?2 AND nonce=?3", + params![row.0, row.1, row.2, epoch], + )?; + report.kept_rows += 1; + } else { + tx.execute( + "DELETE FROM base_refs WHERE worker_scope=?1 AND base_scope=?2 AND nonce=?3", + id, + )?; + report.deleted_rows += 1; + } + } + REF_INTENT if owner_live => report.kept_rows += 1, + REF_INTENT | REF_ORPHANED => match repair_target(&row) { + Some(generation) => { + tx.execute( + "UPDATE base_refs SET state='published',worker_generation=?4,updated_epoch=?5 WHERE worker_scope=?1 AND base_scope=?2 AND nonce=?3", + params![row.0, row.1, row.2, generation, epoch], + )?; + report.repaired_rows += 1; + } + // A dead owner's intent may describe a CAS that landed + // after this sweep marked its worker. Keep it one more + // sweep, whose marking starts after the owner died. + None if row.5 == REF_INTENT => { + tx.execute( + "UPDATE base_refs SET state='orphaned',updated_epoch=?4 WHERE worker_scope=?1 AND base_scope=?2 AND nonce=?3", + params![row.0, row.1, row.2, epoch], + )?; + report.orphaned_rows += 1; + } + None => { + tx.execute( + "DELETE FROM base_refs WHERE worker_scope=?1 AND base_scope=?2 AND nonce=?3", + id, + )?; + report.deleted_rows += 1; + } + }, + // Unknown states are kept: an unknown row is a hold. + _ => report.kept_rows += 1, + } + if batch_started.elapsed() >= bounds.reconcile_time { + break; + } + } + Ok(false) + })?; + if done { + if wrapped { + complete = true; + break; + } + wrapped = true; + after.clear(); + } + } + report.reconcile_complete = complete; + let next = (!complete).then_some(after); + barrier(registry, |tx| { + tx.execute( + "UPDATE sweep_state SET reconcile_cursor=?1 WHERE singleton=1", + [next], + )?; + Ok(()) + }) +} + +/// Inserts a `published` row for every valid edge that has none, so a +/// reference whose owner died before `ReferrerPublished` (and whose intent +/// row was already retired) is held again. Dangling observations never +/// create rows. +fn repair_missing( + registry: &mut RegistryWriter<'_>, + epoch: i64, + valid: &BTreeSet, + report: &mut V3SweepReport, +) -> Result<()> { + if valid.is_empty() { + return Ok(()); + } + let me = super::registry::current_owner(); + barrier(registry, |tx| { + for edge in valid { + let present: bool = tx.query_row( + "SELECT EXISTS(SELECT 1 FROM base_refs WHERE worker_scope=?1 AND base_scope=?2 AND worker_generation=?3 AND membership_digest=?4 AND state='published')", + params![edge.worker_scope, edge.base_scope, edge.worker_generation, edge.membership_digest], + |r| r.get(0), + )?; + if present { + continue; + } + tx.execute( + "INSERT INTO base_refs(worker_scope,base_scope,nonce,membership_digest,worker_generation,state,owner_pid,owner_start,updated_epoch) VALUES(?1,?2,?3,?4,?5,'published',?6,?7,?8)", + params![ + edge.worker_scope, + edge.base_scope, + format!("repair-{}", super::base_refs::fresh_nonce()), + edge.membership_digest, + edge.worker_generation, + me.pid, + me.start_time as i64, + epoch + ], + )?; + report.repaired_rows += 1; + } + Ok(()) + }) +} + +/// Hysteresis counter and reclaim of one base. The barrier re-check is the +/// safety authority; the count only delays deletion by one sweep. +fn count_or_reclaim( + registry: &mut RegistryWriter<'_>, + member: &Member, + referenced: &BTreeSet<&str>, + observer: Option<&dyn DurabilityObserver>, + report: &mut V3SweepReport, +) -> Result<()> { + enum Step { + Held(Hold), + Counted, + Reclaim, + } + let step = barrier(registry, |tx| { + let hold = if referenced.contains(member.scope.as_str()) { + Some(Hold::Referenced) + } else { + v3_base_hold(tx, &member.scope, &member.view_dir) + }; + if let Some(hold) = hold { + tx.execute( + "DELETE FROM base_sweeps WHERE base_scope=?1", + [&member.scope], + )?; + return Ok(Step::Held(hold)); + } + let count: i64 = tx + .query_row( + "SELECT unreferenced_sweeps FROM base_sweeps WHERE base_scope=?1", + [&member.scope], + |r| r.get(0), + ) + .optional()? + .unwrap_or(0) + + 1; + if count < UNREFERENCED_SWEEPS { + tx.execute( + "INSERT INTO base_sweeps(base_scope,unreferenced_sweeps) VALUES(?1,?2) ON CONFLICT(base_scope) DO UPDATE SET unreferenced_sweeps=excluded.unreferenced_sweeps", + params![member.scope, count], + )?; + return Ok(Step::Counted); + } + // Second consecutive unreferenced sweep: commit `evicting` in the + // same transaction as the re-check. + tx.execute( + "UPDATE members SET state=?2 WHERE scope=?1 AND kind='base'", + params![member.scope, STATE_EVICTING], + )?; + Ok(Step::Reclaim) + })?; + match step { + Step::Held(hold) => report + .held_bases + .push((member.scope.clone(), hold.to_string())), + Step::Counted => { + report.counted_bases.push(member.scope.clone()); + boundary(observer, DurabilityStep::BaseUnreferencedCounted)?; + } + Step::Reclaim => { + remove_pointer(&member.view_dir)?; + boundary(observer, DurabilityStep::BaseReclaimBegun)?; + finish_base_reclaim(registry, member)?; + report.reclaimed_bases.push(member.scope.clone()); + } + } + Ok(()) +} + +/// Removes the base pointer first, so a binder that finds the directory +/// without a pointer treats the base as absent. +fn remove_pointer(view_dir: &Path) -> Result<()> { + for suffix in ["", "-wal", "-shm"] { + match std::fs::remove_file(view_dir.join(format!("{}{suffix}", super::POINTER_DATABASE))) { + Ok(()) => {} + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(error) => return Err(error.into()), + } + } + Ok(()) +} + +/// Removes an `evicting` base: pointer, directory, then its rows and member +/// row in one transaction. Safe to repeat after a crash. +fn finish_base_reclaim(registry: &mut RegistryWriter<'_>, member: &Member) -> Result<()> { + remove_pointer(&member.view_dir)?; + super::registry::remove_view_dir(&member.view_dir)?; + barrier(registry, |tx| { + delete_base_rows(tx, &member.scope)?; + Ok(()) + }) +} + +pub(crate) fn delete_base_rows(tx: &Transaction<'_>, scope: &str) -> rusqlite::Result<()> { + tx.execute("DELETE FROM base_refs WHERE base_scope=?1", [scope])?; + tx.execute("DELETE FROM base_builds WHERE base_scope=?1", [scope])?; + tx.execute("DELETE FROM base_sweeps WHERE base_scope=?1", [scope])?; + tx.execute( + "DELETE FROM members WHERE scope=?1 AND state=?2", + params![scope, STATE_EVICTING], + )?; + Ok(()) +} + +#[cfg(test)] +#[path = "base_sweep_tests.rs"] +mod tests; diff --git a/crates/aft/src/views/base_sweep_tests.rs b/crates/aft/src/views/base_sweep_tests.rs new file mode 100644 index 0000000000..5c2773d8cb --- /dev/null +++ b/crates/aft/src/views/base_sweep_tests.rs @@ -0,0 +1,566 @@ +//! Safety tests of the v3 base-reference sweep: referenced bases survive, +//! unreferenced bases go after two sweeps, dangling edges never abort, dead +//! owners' rows are retired or repaired, rows do not grow with +//! republication, and plane rows follow the marked closure. + +use std::path::Path; + +use super::*; +use crate::views::base_refs::tests::{ + base_manifest, bind, entry, frontier, header, key_of, path, plane_has, publish_test_base, + published, put_rows, refs, worker_delta, FAMILY, +}; +use crate::views::base_refs::{with_base_barrier, BaseIdentity, WorkerOutcome}; +use crate::views::manifest_v2::{GenerationName, PublishV2}; + +fn sweep(storage: &Path) -> V3SweepReport { + sweep_family(storage, FAMILY, None).unwrap() +} + +fn base_dir(storage: &Path, identity: &BaseIdentity) -> PathBuf { + v3::view_dir(storage, FAMILY, &identity.scope).unwrap() +} + +fn base_registered(storage: &Path, identity: &BaseIdentity) -> bool { + v3::LedgerReader::open_existing(storage, FAMILY) + .unwrap() + .member(&identity.scope) + .unwrap() + .is_some() +} + +/// Publishes a self-contained v3 generation for `scope`, dropping its base +/// reference (a worker that rebuilt on the strict path). +fn republish_without_base(storage: &Path, scope: &str, previous: &str) -> String { + let dir = v3::view_dir(storage, FAMILY, scope).unwrap(); + let store = ViewStore::open_dir(dir).unwrap(); + let manifest = base_manifest(&[("own.rs", b"own")]); + put_rows(storage, &[b"own"]); + let name = GenerationName::for_manifest(&manifest).unwrap(); + let prepared = store + .prepare_v2(&name, Some(previous), &manifest, None) + .unwrap(); + assert_eq!( + store.commit_v2(prepared, None).unwrap(), + PublishV2::Published + ); + crate::views::wait_for_generation_maintenance_for_test(store.view_dir()); + name.to_string() +} + +#[test] +fn a_referenced_base_survives_every_sweep_until_its_last_reference_goes() { + let _serial = v3::serial_for_test(); + let storage = tempfile::tempdir().unwrap(); + let (identity, generation) = publish_test_base(storage.path(), &[("src/a.rs", b"alpha")]); + put_rows(storage.path(), &[b"new"]); + let (worker_generation, composed) = published( + bind( + storage.path(), + "worker-a", + &identity, + &generation, + worker_delta(&identity, &generation, b"new"), + None, + ) + .unwrap(), + ); + drop(composed); + for _ in 0..4 { + let report = sweep(storage.path()); + assert!(report.reclaimed_bases.is_empty(), "{report:?}"); + assert!(report.counted_bases.is_empty(), "{report:?}"); + assert_eq!(report.valid_edges, 1, "{report:?}"); + assert!(plane_has(storage.path(), b"alpha")); + assert!(plane_has(storage.path(), b"new")); + } + // The worker rebuilds without the base: one sweep counts, the next + // reclaims, and never in a single sweep. + republish_without_base(storage.path(), "worker-a", &worker_generation); + let first = sweep(storage.path()); + assert_eq!( + first.counted_bases, + vec![identity.scope.clone()], + "{first:?}" + ); + assert!(first.reclaimed_bases.is_empty()); + assert!(base_registered(storage.path(), &identity)); + assert!(base_dir(storage.path(), &identity).exists()); + let second = sweep(storage.path()); + assert_eq!( + second.reclaimed_bases, + vec![identity.scope.clone()], + "{second:?}" + ); + assert!(!base_registered(storage.path(), &identity)); + assert!(!base_dir(storage.path(), &identity).exists()); + assert!(refs(storage.path()).is_empty()); + // Its rows went with the plane sweep of the next pass; the worker's own + // rows stay. + sweep(storage.path()); + assert!(!plane_has(storage.path(), b"alpha")); + assert!(plane_has(storage.path(), b"own")); + assert!(!storage.path().join("views/v3").exists()); +} + +#[test] +fn an_unreferenced_base_is_reclaimed_after_two_consecutive_sweeps_and_a_hold_resets_the_count() { + let _serial = v3::serial_for_test(); + let storage = tempfile::tempdir().unwrap(); + let (identity, generation) = publish_test_base(storage.path(), &[("a.rs", b"alpha")]); + assert_eq!( + sweep(storage.path()).counted_bases, + vec![identity.scope.clone()] + ); + // A reader appears between the sweeps: the count resets. + let marker = + crate::root_cache::ReadMarker::create(&base_dir(storage.path(), &identity), &generation) + .unwrap(); + let held = sweep(storage.path()); + assert!( + held.reclaimed_bases.is_empty() && held.counted_bases.is_empty(), + "{held:?}" + ); + drop(marker); + assert_eq!( + sweep(storage.path()).counted_bases, + vec![identity.scope.clone()] + ); + assert!(base_registered(storage.path(), &identity)); + assert_eq!( + sweep(storage.path()).reclaimed_bases, + vec![identity.scope.clone()] + ); +} + +#[test] +fn dangling_edges_are_counted_and_recorded_and_the_sweep_continues() { + let _serial = v3::serial_for_test(); + let storage = tempfile::tempdir().unwrap(); + let (identity, generation) = publish_test_base(storage.path(), &[("a.rs", b"alpha")]); + put_rows(storage.path(), &[b"garbage"]); + // A worker whose manifest names a base that does not exist. + let ghost = BaseIdentity { + scope: crate::views::base_refs::base_scope_for(&[8; 32]), + membership: [8; 32], + identity: [8; 32], + }; + let dir = with_base_barrier(storage.path(), FAMILY, |tx| { + crate::views::base_refs::register_view_in( + tx, + storage.path(), + FAMILY, + "dangling", + storage.path(), + ) + }) + .unwrap(); + let store = ViewStore::open_dir(dir).unwrap(); + let delta = worker_delta(&ghost, "g", b"alpha"); + let name = GenerationName::for_manifest(&delta).unwrap(); + let prepared = store.prepare_v2(&name, None, &delta, None).unwrap(); + store.commit_v2(prepared, None).unwrap(); + let report = sweep(storage.path()); + assert_eq!(report.dangling_base_edges, 1, "{report:?}"); + assert_eq!(report.valid_edges, 0); + // The rest of the sweep ran: the unreferenced base was counted and the + // garbage row deleted, while the dangling worker's own key survived. + assert_eq!(report.counted_bases, vec![identity.scope.clone()]); + assert!(!plane_has(storage.path(), b"garbage")); + assert!(plane_has(storage.path(), b"alpha")); + let recorded: Vec<(String, String, String)> = + v3::LedgerReader::open_existing(storage.path(), FAMILY) + .unwrap() + .read(|c| { + Ok( + c.prepare("SELECT worker_scope,worker_generation,reason FROM strict_rebuild")? + .query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)))? + .collect::>>()?, + ) + }) + .unwrap(); + assert_eq!( + recorded, + vec![( + "dangling".into(), + name.to_string(), + "base_not_registered".into() + )] + ); + // A dangling observation never creates a hold. + assert!(refs(storage.path()).is_empty()); + let _ = generation; +} + +/// Inserts an `intent` row owned by a process that is certainly gone. +fn dead_intent(storage: &Path, worker: &str, identity: &BaseIdentity) { + with_base_barrier(storage, FAMILY, |tx| { + tx.execute( + "INSERT INTO base_refs VALUES(?1,?2,'dead-nonce',?3,NULL,'intent',?4,1,?5)", + params![ + worker, + identity.scope, + crate::blob_store::v2::to_hex(&identity.membership), + i32::MAX as u32 - 7, + gc_epoch(tx)? + ], + )?; + Ok(()) + }) + .unwrap(); +} + +#[test] +fn a_dead_owners_intent_is_orphaned_then_retired_and_holds_the_base_meanwhile() { + let _serial = v3::serial_for_test(); + let storage = tempfile::tempdir().unwrap(); + let (identity, _generation) = publish_test_base(storage.path(), &[("a.rs", b"alpha")]); + dead_intent(storage.path(), "gone-worker", &identity); + let first = sweep(storage.path()); + assert_eq!(first.orphaned_rows, 1, "{first:?}"); + assert!( + first.counted_bases.is_empty(), + "the orphaned row still holds: {first:?}" + ); + assert_eq!(refs(storage.path())[0].2, REF_ORPHANED); + let second = sweep(storage.path()); + assert_eq!(second.deleted_rows, 1, "{second:?}"); + assert_eq!(second.counted_bases, vec![identity.scope.clone()]); + assert!(base_registered(storage.path(), &identity)); + assert_eq!( + sweep(storage.path()).reclaimed_bases, + vec![identity.scope.clone()] + ); +} + +#[test] +fn an_owner_that_died_between_its_cas_and_referrer_published_is_repaired() { + let _serial = v3::serial_for_test(); + let storage = tempfile::tempdir().unwrap(); + let (identity, generation) = publish_test_base(storage.path(), &[("a.rs", b"alpha")]); + // The pointer landed with the base header, but the row is still the dead + // owner's intent. + let dir = with_base_barrier(storage.path(), FAMILY, |tx| { + crate::views::base_refs::register_view_in( + tx, + storage.path(), + FAMILY, + "worker-d", + storage.path(), + ) + }) + .unwrap(); + let store = ViewStore::open_dir(dir).unwrap(); + let delta = worker_delta(&identity, &generation, b"alpha"); + let name = GenerationName::for_manifest(&delta).unwrap(); + let prepared = store.prepare_v2(&name, None, &delta, None).unwrap(); + store.commit_v2(prepared, None).unwrap(); + dead_intent(storage.path(), "worker-d", &identity); + for _ in 0..3 { + let report = sweep(storage.path()); + assert!( + report.reclaimed_bases.is_empty() && report.counted_bases.is_empty(), + "{report:?}" + ); + } + assert_eq!( + refs(storage.path()), + vec![( + "worker-d".into(), + identity.scope.clone(), + REF_PUBLISHED.into(), + Some(name.to_string()) + )] + ); +} + +#[test] +fn repeated_same_base_republications_do_not_grow_the_rows() { + let _serial = v3::serial_for_test(); + let storage = tempfile::tempdir().unwrap(); + let (identity, generation) = publish_test_base(storage.path(), &[("src/a.rs", b"alpha")]); + let mut previous = None; + for round in 0..6u8 { + let bytes = [b'v', round]; + put_rows(storage.path(), &[&bytes]); + let (worker_generation, composed) = published( + bind( + storage.path(), + "worker-a", + &identity, + &generation, + worker_delta(&identity, &generation, &bytes), + previous.clone(), + ) + .unwrap(), + ); + drop(composed); + previous = Some(worker_generation); + } + crate::views::wait_for_generation_maintenance_for_test( + &v3::view_dir(storage.path(), FAMILY, "worker-a").unwrap(), + ); + assert_eq!(refs(storage.path()).len(), 6); + let report = sweep(storage.path()); + assert!(report.reconcile_complete, "{report:?}"); + let rows = refs(storage.path()); + assert_eq!(rows.len(), 1, "{rows:?}"); + assert_eq!(rows[0].3, previous); +} + +#[test] +fn reconciliation_stays_within_its_row_cap_and_resumes_on_the_next_pass() { + let _serial = v3::serial_for_test(); + let storage = tempfile::tempdir().unwrap(); + let (identity, _generation) = publish_test_base(storage.path(), &[("a.rs", b"alpha")]); + with_base_barrier(storage.path(), FAMILY, |tx| { + for index in 0..10 { + tx.execute( + "INSERT INTO base_refs VALUES(?1,?2,?3,'00','g-superseded','published',1,1,0)", + params![ + format!("worker-{index}"), + identity.scope, + format!("n{index}") + ], + )?; + } + Ok(()) + }) + .unwrap(); + let lock = ReservationLock::acquire(storage.path()).unwrap(); + let bounds = SweepBounds { + reconcile_rows: 4, + // Only the row cap may cut these passes. + reconcile_time: Duration::from_secs(60), + ..SweepBounds::default() + }; + let mut passes = 0; + loop { + let report = + sweep_family_locked(storage.path(), FAMILY, &lock, None, bounds, None).unwrap(); + passes += 1; + assert!(report.reconciled_rows <= 4, "{report:?}"); + if refs(storage.path()).is_empty() { + // Until the last stale row went, the base stayed held. + assert!(report.counted_bases.is_empty() || report.reconcile_complete); + break; + } + assert!( + report.counted_bases.is_empty(), + "held while rows remain: {report:?}" + ); + assert!(passes < 10); + } + assert_eq!(passes, 3); +} + +#[test] +fn the_plane_row_sweep_reclaims_unreferenced_rows_and_keeps_referenced_ones() { + let _serial = v3::serial_for_test(); + let storage = tempfile::tempdir().unwrap(); + let (_identity, _generation) = publish_test_base(storage.path(), &[("a.rs", b"referenced")]); + put_rows(storage.path(), &[b"unreferenced"]); + let report = sweep(storage.path()); + assert!(report.plane_rows_complete, "{report:?}"); + assert_eq!(report.plane_rows_deleted, 1, "{report:?}"); + assert!(plane_has(storage.path(), b"referenced")); + assert!(!plane_has(storage.path(), b"unreferenced")); +} + +#[test] +fn override_ready_key_survives_fold_and_gc() { + let _serial = v3::serial_for_test(); + let storage = tempfile::tempdir().unwrap(); + let (identity, generation) = publish_test_base(storage.path(), &[("src/a.rs", b"alpha")]); + // A checkout-specific fill of an inherited path: its key lives only in + // the worker's live pin until a published generation protects it. + let fill = crate::blob_store::v2::FamilyKey::new( + crate::blob_store::v2::FamilyPlane::Semantic, + [42; 32], + ); + let (first, composed) = published( + bind( + storage.path(), + "worker-a", + &identity, + &generation, + worker_delta(&identity, &generation, b"alpha"), + None, + ) + .unwrap(), + ); + drop(composed); + let worker_dir = v3::view_dir(storage.path(), FAMILY, "worker-a").unwrap(); + let mut live = crate::pins::LivePin::create_v3(storage.path(), FAMILY, "worker-a").unwrap(); + live.protect(&[fill]).unwrap(); + v3::publish(storage.path(), FAMILY, 1 << 20, None, |publication| { + publication.put(&fill, b"fill", 1) + }) + .unwrap(); + let fill_present = || { + v3::PlaneReader::open_existing( + storage.path(), + FAMILY, + crate::blob_store::v2::FamilyPlane::Semantic, + ) + .unwrap() + .get(&fill) + .unwrap() + .is_some() + }; + sweep(storage.path()); + assert!(fill_present(), "the live pin protects the unpublished fill"); + // Fold: the next worker generation carries the override. + let mut delta = worker_delta(&identity, &generation, b"alpha"); + delta + .set_readiness_override( + path("src/a.rs"), + crate::views::manifest_v2::EntryPlanes { + semantic: Some(crate::views::readiness::PlaneState::ready(&fill)), + ..Default::default() + }, + ) + .unwrap(); + let (_second, composed) = published( + bind( + storage.path(), + "worker-a", + &identity, + &generation, + delta, + Some(first), + ) + .unwrap(), + ); + // Post-fold trimming: the override key leaves the pin only because the + // published generation's ready keys (entries and overrides) protect it. + let published_keys = composed + .ready_keys() + .into_iter() + .map(|key| key.to_hex()) + .collect(); + live.trim_published(&published_keys, |_| false).unwrap(); + assert!(live.is_empty(), "the published override key was trimmed"); + drop(composed); + crate::views::wait_for_generation_maintenance_for_test(&worker_dir); + for _ in 0..2 { + sweep(storage.path()); + assert!(fill_present(), "the published override key was collected"); + } + let reader = crate::views::base_refs::open_foreign_v3( + storage.path(), + FAMILY, + "worker-a", + &header().producers, + ) + .unwrap() + .unwrap(); + assert_eq!( + reader.plane_readiness( + &path("src/a.rs"), + crate::blob_store::v2::FamilyPlane::Semantic + ), + Some(&crate::views::readiness::PlaneState::ready(&fill)) + ); + let _ = (key_of(b"alpha"), entry(b"alpha"), frontier()); +} + +#[test] +fn concurrent_binders_in_one_process_keep_every_published_reference() { + let _serial = v3::serial_for_test(); + let storage = tempfile::tempdir().unwrap(); + let (identity, generation) = publish_test_base(storage.path(), &[("src/a.rs", b"alpha")]); + let payloads: Vec> = (0..4u8).map(|index| vec![b'w', index]).collect(); + put_rows( + storage.path(), + &payloads.iter().map(Vec::as_slice).collect::>(), + ); + let outcomes: Vec<_> = std::thread::scope(|scope| { + let handles: Vec<_> = (0..4u8) + .map(|index| { + let storage = storage.path(); + let identity = identity.clone(); + let generation = generation.clone(); + let bytes = payloads[index as usize].clone(); + scope.spawn(move || { + let mut forged = identity.clone(); + // One binder aborts at verification. + if index == 3 { + forged.identity = [0; 32]; + } + bind( + storage, + &format!("worker-{index}"), + &forged, + &generation, + worker_delta(&identity, &generation, &bytes), + None, + ) + }) + }) + .collect(); + handles + .into_iter() + .map(|handle| handle.join().unwrap()) + .collect() + }); + let mut kept = Vec::new(); + for (index, outcome) in outcomes.into_iter().enumerate() { + match (index, outcome) { + (3, Err(_)) => {} + (_, Ok(WorkerOutcome::Published { composed, .. })) => kept.push(composed), + (index, other) => panic!("binder {index}: {other:?}"), + } + } + let rows = refs(storage.path()); + assert_eq!(rows.len(), 3, "{rows:?}"); + assert!(rows + .iter() + .all(|row| row.2 == REF_PUBLISHED && row.0 != "worker-3")); + drop(kept); + for _ in 0..3 { + let report = sweep(storage.path()); + assert!( + report.reclaimed_bases.is_empty() && report.counted_bases.is_empty(), + "{report:?}" + ); + assert_eq!(report.valid_edges, 3); + } + assert!(plane_has(storage.path(), b"alpha")); +} + +#[test] +fn an_injected_failure_at_each_sweep_boundary_never_reclaims_early_and_recovers() { + use crate::views::contracts::{inject_fault_at, InjectedFault}; + for step in [ + DurabilityStep::BaseUnreferencedCounted, + DurabilityStep::BaseReclaimBegun, + ] { + for fault in InjectedFault::ALL { + let _serial = v3::serial_for_test(); + let storage = tempfile::tempdir().unwrap(); + let (identity, _) = publish_test_base(storage.path(), &[("a.rs", b"alpha")]); + if step == DurabilityStep::BaseReclaimBegun { + sweep(storage.path()); + } + inject_fault_at(step, fault); + assert!( + sweep_family(storage.path(), FAMILY, None).is_err(), + "{step:?} {fault:?}" + ); + // The failed sweep released its lease; later sweeps finish the work + // without ever reclaiming before the second unreferenced sweep. + let mut reclaimed = false; + for _ in 0..3 { + let report = sweep(storage.path()); + if report.reclaimed_bases.contains(&identity.scope) { + reclaimed = true; + break; + } + } + assert!(reclaimed, "{step:?} {fault:?}"); + assert!(!base_registered(storage.path(), &identity)); + assert!(!base_dir(storage.path(), &identity).exists()); + } + } +} diff --git a/crates/aft/src/views/contracts.rs b/crates/aft/src/views/contracts.rs index 96824547e9..2556c1aa71 100644 --- a/crates/aft/src/views/contracts.rs +++ b/crates/aft/src/views/contracts.rs @@ -59,11 +59,36 @@ pub enum DurabilityStep { V3FloorRaised, /// All plane writes in a v3 publication are durable before its ledger commit. ClosureFlushed, + /// A `base_builds` claim row names this process as the base's builder. + BaseClaimed, + /// The base's member row (`kind = base`, no pointer yet) is committed. + BaseRegistered, + /// The base pointer moved to a readiness successor with the same membership. + BaseSuccessorPublished, + /// A worker's per-acquisition marker and `intent` referrer row exist. + BaseRefMarked, + /// The worker's referrer row is `published` with its worker generation. + ReferrerPublished, + /// A sweep found the base unreferenced and set its counter to 1. + BaseUnreferencedCounted, + /// A sweep committed `evicting` for an unreferenced base before removing it. + BaseReclaimBegun, } impl DurabilityStep { pub const V3_ONLY: [Self; 2] = [Self::V3FloorRaised, Self::ClosureFlushed]; - pub const ALL: [Self; 10] = [ + /// Boundaries of the base-reference protocol, in the order one base build, + /// worker bind and reclaim reaches them. + pub const BASE_PATH: [Self; 7] = [ + Self::BaseClaimed, + Self::BaseRegistered, + Self::BaseSuccessorPublished, + Self::BaseRefMarked, + Self::ReferrerPublished, + Self::BaseUnreferencedCounted, + Self::BaseReclaimBegun, + ]; + pub const ALL: [Self; 17] = [ Self::BlobCommitted, Self::SegmentRowRecorded, Self::SegmentFileSynced, @@ -74,6 +99,13 @@ impl DurabilityStep { Self::PointerSynced, Self::V3FloorRaised, Self::ClosureFlushed, + Self::BaseClaimed, + Self::BaseRegistered, + Self::BaseSuccessorPublished, + Self::BaseRefMarked, + Self::ReferrerPublished, + Self::BaseUnreferencedCounted, + Self::BaseReclaimBegun, ]; pub const fn as_str(self) -> &'static str { @@ -88,6 +120,13 @@ impl DurabilityStep { Self::PointerSynced => "pointer-synced", Self::V3FloorRaised => "v3-floor-raised", Self::ClosureFlushed => "closure-flushed", + Self::BaseClaimed => "base-claimed", + Self::BaseRegistered => "base-registered", + Self::BaseSuccessorPublished => "base-successor-published", + Self::BaseRefMarked => "base-ref-marked", + Self::ReferrerPublished => "referrer-published", + Self::BaseUnreferencedCounted => "base-unreferenced-counted", + Self::BaseReclaimBegun => "base-reclaim-begun", } } @@ -107,6 +146,60 @@ pub(crate) fn observe(observer: Option<&dyn DurabilityObserver>, step: Durabilit } } +/// A failure a test injects at one durability boundary. The boundary's +/// durable action has already happened; the caller sees the error a failed +/// `fsync`, a failed `rename` or a full disk would give it right after. +#[cfg(test)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum InjectedFault { + Fsync, + Rename, + NoSpace, +} + +#[cfg(test)] +impl InjectedFault { + pub(crate) const ALL: [Self; 3] = [Self::Fsync, Self::Rename, Self::NoSpace]; + fn error(self) -> std::io::Error { + match self { + // EIO, EXDEV and ENOSPC on unix. Windows maps these codes to other + // errors; callers handle every boundary failure the same way. + Self::Fsync => std::io::Error::from_raw_os_error(5), + Self::Rename => std::io::Error::from_raw_os_error(18), + Self::NoSpace => std::io::Error::from_raw_os_error(28), + } + } +} + +#[cfg(test)] +thread_local! { + static INJECTED: std::cell::Cell> = + const { std::cell::Cell::new(None) }; +} + +/// Makes the next [`boundary`] at `step` on this thread fail with `fault`. +#[cfg(test)] +pub(crate) fn inject_fault_at(step: DurabilityStep, fault: InjectedFault) { + INJECTED.with(|slot| slot.set(Some((step, fault)))); +} + +/// [`observe`], for boundaries whose callers handle a failure there. Tests can +/// make one boundary fail with [`inject_fault_at`]; production never fails here. +pub(crate) fn boundary( + observer: Option<&dyn DurabilityObserver>, + step: DurabilityStep, +) -> std::io::Result<()> { + observe(observer, step); + #[cfg(test)] + if let Some((at, fault)) = INJECTED.with(|slot| slot.get()) { + if at == step { + INJECTED.with(|slot| slot.set(None)); + return Err(fault.error()); + } + } + Ok(()) +} + /// Who is using a view. #[derive(Clone, Debug)] pub enum ViewAccess { diff --git a/crates/aft/src/views/eviction.rs b/crates/aft/src/views/eviction.rs index de87445e4a..e688b75fb8 100644 --- a/crates/aft/src/views/eviction.rs +++ b/crates/aft/src/views/eviction.rs @@ -112,6 +112,9 @@ pub enum Hold { OpenInProcess, /// Its sessions, protection or files could not be read with certainty. Uncertain(String), + /// A v3 base that a `base_refs` row, a live `base_builds` claim or this + /// sweep's marked worker edges still reference. + Referenced, } impl fmt::Display for Hold { @@ -121,6 +124,7 @@ impl fmt::Display for Hold { Self::Protected => write!(f, "protected generation"), Self::OpenInProcess => write!(f, "open in this process"), Self::Uncertain(reason) => write!(f, "uncertain: {reason}"), + Self::Referenced => write!(f, "referenced base"), } } } @@ -689,6 +693,56 @@ fn hold_inside_barrier( } } +/// Why the v3 base `scope` cannot be reclaimed now, read inside the v3 +/// registry barrier `tx`. Every deleter of a v3 base (the reference sweep's +/// two-step reclaim, the retention collector and any future LRU eviction) +/// goes through this check; none parses a manifest inside the barrier. +/// +/// One indexed `base_refs` query, the `base_builds` claim, then the bounded +/// `readers/`/`pins/`/session listing. A query error or an over-long listing +/// is [`Hold::Uncertain`]. +pub(crate) fn v3_base_hold( + tx: &rusqlite::Transaction<'_>, + scope: &str, + view_dir: &Path, +) -> Option { + use rusqlite::OptionalExtension; + let referenced = tx + .query_row( + "SELECT 1 FROM base_refs WHERE base_scope=?1 LIMIT 1", + [scope], + |_| Ok(()), + ) + .optional(); + match referenced { + Ok(Some(())) => return Some(Hold::Referenced), + Ok(None) => {} + Err(error) => return Some(Hold::Uncertain(error.to_string())), + } + let claim = tx + .query_row( + "SELECT owner_pid,owner_start FROM base_builds WHERE base_scope=?1", + [scope], + |r| { + Ok(crate::pins::PinOwner { + pid: r.get(0)?, + start_time: r.get(1)?, + }) + }, + ) + .optional(); + match claim { + Ok(Some(owner)) if crate::pins::owner_is_live(&owner) => return Some(Hold::Referenced), + Ok(_) => {} + Err(error) => return Some(Hold::Uncertain(error.to_string())), + } + match crate::storage_retention::v3_member_protected(tx, scope, view_dir) { + Ok(false) => None, + Ok(true) => Some(Hold::Protected), + Err(reason) => Some(Hold::Uncertain(reason)), + } +} + fn hold_of( registry: &FamilyRegistry, member: &MemberRecord, diff --git a/crates/aft/src/views/manifest_v2.rs b/crates/aft/src/views/manifest_v2.rs index 09bcf46e44..48031a6845 100644 --- a/crates/aft/src/views/manifest_v2.rs +++ b/crates/aft/src/views/manifest_v2.rs @@ -232,6 +232,17 @@ impl ManifestV2 { pub fn base_header(&self) -> Option<&BaseHeader> { self.base.as_ref() } + /// Rebinds a v3 worker delta to `header`. The worker publisher calls this + /// with the header of the base it authenticated, never a caller's string. + pub fn set_base_header(&mut self, header: BaseHeader) -> Result<()> { + if !self.v3 || self.base.is_none() { + return Err(ViewError::InvalidManifest( + "only a v3 worker delta can be rebound to a base".into(), + )); + } + self.base = Some(header); + Ok(()) + } pub fn published(&self) -> Option<&PublishedFrontier> { self.published.as_ref() } diff --git a/crates/aft/src/views/mod.rs b/crates/aft/src/views/mod.rs index 70b21a2af5..ac5b1d59ee 100644 --- a/crates/aft/src/views/mod.rs +++ b/crates/aft/src/views/mod.rs @@ -11,6 +11,9 @@ use base64::{engine::general_purpose::STANDARD, Engine as _}; use rusqlite::{params, Connection, TransactionBehavior}; use serde::{de::Error as _, Deserialize, Deserializer, Serialize, Serializer}; pub mod assembly; +pub mod base_builder; +pub mod base_refs; +pub mod base_sweep; mod generation; #[cfg(test)] pub(crate) use generation::{ diff --git a/crates/aft/src/views/read.rs b/crates/aft/src/views/read.rs index 96e7b7e207..ce019fcd58 100644 --- a/crates/aft/src/views/read.rs +++ b/crates/aft/src/views/read.rs @@ -590,9 +590,13 @@ pub fn open_foreign_generation( let manifest = store.load_manifest_v2(protected.generation())?; manifest.ensure_producers(producers)?; if manifest.base_header().is_some() { - return Err(super::ViewError::InvalidManifest( - "durable v3 base resolution is not wired".into(), - )); + // A base reference is valid only in a v3 worker manifest under + // `blobs/v3//views/`, opened through + // `open_foreign_v3_generation`. A v2 view naming a base is a corrupt + // edge: its header cannot be authenticated, and it is never trusted. + return Err(super::ViewError::InvalidManifest(format!( + "foreign view {scope} names a base outside the v3 namespace; refusing an unauthenticated base header" + ))); } Ok(Some(std::sync::Arc::new( super::snapshot::ComposedGeneration::new( @@ -603,6 +607,25 @@ pub fn open_foreign_generation( ))) } +/// Opens a v3 worker's published generation for a foreign or parent-folder +/// reader: a dual pin-then-verify of the worker and, through the authenticated +/// base resolver, of the base it names. The bundle owns both markers and its +/// answers carry the worker's `PublishedFrontier`. A base that stays missing or +/// moving through every attempt is reported unavailable, never replaced. +// No production caller until the parent-folder and foreign-reader routing +// slices select v3 workers; tests exercise it now. +#[cfg_attr(not(test), allow(dead_code))] +pub fn open_foreign_v3_generation( + storage: &std::path::Path, + family: &str, + scope: &str, + producers: &super::manifest_v2::Producers, +) -> super::Result>> { + super::base_refs::open_foreign_v3(storage, family, scope, producers).map_err(|error| { + super::ViewError::InvalidManifest(format!("foreign view {scope} unavailable: {error}")) + }) +} + #[cfg(test)] mod producer_tests { @@ -652,7 +675,7 @@ mod producer_tests { assert!( error .to_string() - .contains("durable v3 base resolution is not wired"), + .contains("refusing an unauthenticated base header"), "{error}" ); assert_eq!( diff --git a/crates/aft/tests/integration/per_checkout_registry.rs b/crates/aft/tests/integration/per_checkout_registry.rs index 05fa3955b3..612cad2d79 100644 --- a/crates/aft/tests/integration/per_checkout_registry.rs +++ b/crates/aft/tests/integration/per_checkout_registry.rs @@ -1100,7 +1100,8 @@ fn a_publisher_killed_at_any_durability_boundary_leaves_protected_bytes_readable v2_steps.to_vec(), DurabilityStep::ALL .into_iter() - .filter(|step| !DurabilityStep::V3_ONLY.contains(step)) + .filter(|step| !DurabilityStep::V3_ONLY.contains(step) + && !DurabilityStep::BASE_PATH.contains(step)) .collect::>() ); for step in v2_steps { From 68e35c0080e165b0506eefc602858a22f5f5061f Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Mon, 26 Oct 2026 16:19:48 +0100 Subject: [PATCH 3/8] mason: base build claim, live-base cap and v3 DiskBudget admission base_builds claim with the owner-token CAS: insert when absent, Busy while the owner lives (running, stopped or uncertain), takeover of a dead owner with attempt+1 and a fresh token, after which the dead attempt's staging/a--* directories are removed. Staging and derived names carry (attempt, owner_token). The live-base cap (64 per family, counting claimed-but-unregistered bases) refuses new claims and counts the hit in sweep_state.live_base_cap_hits. plan_binder: only the claim owner builds; others wait (ACK + direct scan) and go strict only on a failed claim, the cap, or the ruled 600 s wait. initial_reservation implements the ruled formula with the 1 GiB cap. DiskBudget::admit_v3/extend_v3 admit v3 reservations against the family hard limit (refused as storage_gap:v3_family_hard_limit, no legacy fallback) and flag usage over the 2 GiB target; RegistryWriter::extend grows a live reservation under the floor and namespace checks. No limit or budget constant changes. Co-authored-by: alfonso-aft <289616620+alfonso-aft@users.noreply.github.com> --- crates/aft/src/blob_store/v3.rs | 45 ++++ crates/aft/src/views/base_builder.rs | 299 +++++++++++++++++++++ crates/aft/src/views/base_builder_tests.rs | 247 +++++++++++++++++ crates/aft/src/views/eviction.rs | 93 +++++++ 4 files changed, 684 insertions(+) create mode 100644 crates/aft/src/views/base_builder_tests.rs diff --git a/crates/aft/src/blob_store/v3.rs b/crates/aft/src/blob_store/v3.rs index 9f53cd3e10..c153892f54 100644 --- a/crates/aft/src/blob_store/v3.rs +++ b/crates/aft/src/blob_store/v3.rs @@ -186,6 +186,11 @@ pub fn set_free_space_for_test(free: u64, capacity: u64) { .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) = Some((free, capacity)); } +/// The platform probe itself, bypassing the test override. +#[cfg(test)] +pub(crate) fn probe_free_space_for_test(path: &Path) -> Option<(u64, u64)> { + probe_free_space(path) +} fn free_space(path: &Path) -> Option<(u64, u64)> { if let Some(value) = *FREE_SPACE .lock() @@ -1100,6 +1105,45 @@ impl<'a> RegistryWriter<'a> { registry, }) } + /// Grows a live reservation by `additional` bytes under the same + /// admission checks as [`Self::reserve`]: the device floor and the + /// namespace budget. An owner extends before it writes past its + /// reservation, never after. + pub fn extend(&mut self, reservation: &Reservation, additional: u64) -> Result<()> { + if reservation.registry != self.registry_identity()? { + return Err(Error::ReservationRegistryMismatch); + } + let storage = self.lock.storage.clone(); + let path = self.dir.join("members.sqlite"); + let id = reservation.id; + self.with_barrier(|tx| { + check_floor(&storage, additional, Some((&path, tx)))?; + if accounting(tx)? + .usage() + .checked_add(additional) + .is_none_or(|n| n > NAMESPACE_BUDGET) + { + return Err(Error::Refused("v3_namespace_budget")); + } + let updated = tx.execute( + "UPDATE reservations SET reserved_bytes=reserved_bytes+?1 WHERE id=?2 AND state='reserved'", + params![additional, id], + )?; + if updated != 1 { + return Err(Error::Refused("reservation_not_live")); + } + Ok(()) + }) + } + /// Bytes still reserved by a live reservation (0 once committed or + /// released). + pub fn reserved_bytes(&self, reservation: &Reservation) -> Result { + Ok(self.connection()?.query_row( + "SELECT CASE WHEN state='reserved' THEN reserved_bytes ELSE 0 END FROM reservations WHERE id=?1", + [reservation.id], + |r| r.get(0), + )?) + } fn registry_identity(&self) -> Result { let path = fs::canonicalize(self.dir.join("members.sqlite"))?; Ok(RegistryIdentity { @@ -1113,6 +1157,7 @@ struct RegistryIdentity { path: PathBuf, file: Option, } +#[derive(Debug)] pub struct Reservation { id: i64, before: u64, diff --git a/crates/aft/src/views/base_builder.rs b/crates/aft/src/views/base_builder.rs index 4299a4d471..92cb552df3 100644 --- a/crates/aft/src/views/base_builder.rs +++ b/crates/aft/src/views/base_builder.rs @@ -164,3 +164,302 @@ pub fn publish_base( }), } } + +/// Ruled live-claim wait (item 24): a binder that finds a live claim waits at +/// most this long, from first encountering it, for the base's first +/// lexical-ready publication, then takes the strict path. Heartbeats and +/// takeovers never reset that clock. +pub const LIVE_CLAIM_WAIT: std::time::Duration = std::time::Duration::from_secs(600); +/// Builder heartbeat cadence and the age at which a binder inspects the +/// owner's liveness. Staleness alone never permits a takeover. +pub const CLAIM_HEARTBEAT: std::time::Duration = std::time::Duration::from_secs(5); +pub const CLAIM_STALE: std::time::Duration = std::time::Duration::from_secs(15); +/// Ruled cap of live bases per family, counting claimed or registered but +/// unpublished bases. +pub const LIVE_BASES_PER_FAMILY: i64 = 64; +/// Initial build reservation cap and extension step (pre-slice-4 ruling). +pub const INITIAL_RESERVATION_CAP: u64 = 1 << 30; +pub const RESERVATION_STEP: u64 = 256 << 20; + +/// The ruled initial reservation for a base of `files` members and +/// `source_bytes` bytes: `ceil_MiB(64 MiB + max(64 KiB × N, 2 × B, +/// 1.25 × P × max(N/Np, B/Bp)))`, capped at 1 GiB. `history` is a comparable +/// base's `(peak allocated build bytes P, Np, Bp)`; it is ignored when absent +/// or when `Np` or `Bp` is zero. +pub fn initial_reservation(files: u64, source_bytes: u64, history: Option<(u64, u64, u64)>) -> u64 { + const MIB: u128 = 1 << 20; + let mut term = (64u128 * 1024 * u128::from(files)).max(2 * u128::from(source_bytes)); + if let Some((peak, np, bp)) = history.filter(|(_, np, bp)| *np > 0 && *bp > 0) { + // 1.25 × P × max(N/Np, B/Bp), in exact integer arithmetic. + let by_files = 5 * u128::from(peak) * u128::from(files) / (4 * u128::from(np)); + let by_bytes = 5 * u128::from(peak) * u128::from(source_bytes) / (4 * u128::from(bp)); + term = term.max(by_files).max(by_bytes); + } + let total = 64 * MIB + term; + let rounded = total.div_ceil(MIB) * MIB; + rounded.min(u128::from(INITIAL_RESERVATION_CAP)) as u64 +} + +/// The holder of a base's build claim. Every staging and derived name it +/// creates includes `(attempt, owner_token)`, so a takeover never reuses a +/// dead attempt's names. +#[derive(Debug)] +pub struct BuildClaim { + storage: PathBuf, + family: String, + scope: String, + token: String, + attempt: i64, +} + +/// What [`claim_base`] found. +#[derive(Debug)] +pub enum ClaimOutcome { + /// This process now owns the build, at `attempt`. + Claimed(BuildClaim), + /// A live owner (running, stopped, or of uncertain liveness) holds it. + Busy { owner_pid: u32, attempt: i64 }, + /// The family already has the ruled number of live bases; the cap hit is + /// counted in `sweep_state.live_base_cap_hits`. + CapReached { live_bases: i64 }, +} + +fn live_base_count(tx: &Transaction<'_>) -> rusqlite::Result { + tx.query_row( + "SELECT (SELECT COUNT(*) FROM members WHERE kind='base' AND state!='evicting') + (SELECT COUNT(*) FROM base_builds WHERE base_scope NOT IN (SELECT scope FROM members))", + [], + |r| r.get(0), + ) +} + +/// Claims the build of base `scope` with the import ledger's owner-token CAS: +/// insert when absent; `Busy` while the owner lives; a dead owner's row is +/// taken over with `attempt + 1` and a fresh token, and the dead attempt's +/// staging becomes reclaimable. +pub fn claim_base( + storage: &Path, + family: &str, + scope: &str, + observer: Option<&dyn DurabilityObserver>, +) -> Result { + let me = super::registry::current_owner(); + let token = super::base_refs::fresh_nonce(); + let (outcome, dead_attempt) = with_base_barrier(storage, family, |tx| { + let existing: Option<(u32, i64, String, i64)> = tx + .query_row( + "SELECT owner_pid,owner_start,owner_token,attempt FROM base_builds WHERE base_scope=?1", + [scope], + |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)), + ) + .optional()?; + let now = crate::pins::now_ms() as i64; + match existing { + None => { + let registered: bool = tx.query_row( + "SELECT EXISTS(SELECT 1 FROM members WHERE scope=?1 AND state!='evicting')", + [scope], + |r| r.get(0), + )?; + let live = live_base_count(tx)?; + if !registered && live >= LIVE_BASES_PER_FAMILY { + tx.execute( + "UPDATE sweep_state SET live_base_cap_hits=live_base_cap_hits+1 WHERE singleton=1", + [], + )?; + return Ok((ClaimOutcome::CapReached { live_bases: live }, None)); + } + tx.execute( + "INSERT INTO base_builds VALUES(?1,?2,?3,?4,1,'building',?5)", + params![scope, me.pid, me.start_time as i64, token, now], + )?; + Ok(( + ClaimOutcome::Claimed(BuildClaim::new(storage, family, scope, &token, 1)), + None, + )) + } + Some((pid, start, old_token, attempt)) => { + let owner = crate::pins::PinOwner { + pid, + start_time: start as u64, + }; + if crate::pins::owner_is_live(&owner) { + return Ok(( + ClaimOutcome::Busy { + owner_pid: pid, + attempt, + }, + None, + )); + } + let updated = tx.execute( + "UPDATE base_builds SET owner_pid=?1,owner_start=?2,owner_token=?3,attempt=attempt+1,state='building',heartbeat_at_ms=?4 WHERE base_scope=?5 AND owner_token=?6", + params![me.pid, me.start_time as i64, token, now, scope, old_token], + )?; + if updated != 1 { + return Err(BaseRefError::Unavailable(format!("claim of {scope} moved"))); + } + Ok(( + ClaimOutcome::Claimed(BuildClaim::new( + storage, + family, + scope, + &token, + attempt + 1, + )), + Some((attempt, old_token)), + )) + } + } + })?; + if let ClaimOutcome::Claimed(claim) = &outcome { + if let Some((attempt, token)) = dead_attempt { + claim.remove_dead_staging(attempt, &token)?; + } + boundary(observer, DurabilityStep::BaseClaimed)?; + } + Ok(outcome) +} + +impl BuildClaim { + fn new(storage: &Path, family: &str, scope: &str, token: &str, attempt: i64) -> Self { + Self { + storage: storage.to_path_buf(), + family: family.to_owned(), + scope: scope.to_owned(), + token: token.to_owned(), + attempt, + } + } + pub fn scope(&self) -> &str { + &self.scope + } + pub fn attempt(&self) -> i64 { + self.attempt + } + pub fn token(&self) -> &str { + &self.token + } + /// `M/staging/a--/`, created fresh. + pub fn staging_dir(&self) -> Result { + let dir = v3::view_dir(&self.storage, &self.family, &self.scope)? + .join("staging") + .join(format!( + "a{}-{}-{}", + self.attempt, + self.token, + super::base_refs::fresh_nonce() + )); + std::fs::create_dir_all(&dir)?; + Ok(dir) + } + /// `derived--a-.sqlite`. + pub fn derived_name(&self, generation: &str) -> String { + format!( + "derived-{generation}-a{}-{}.sqlite", + self.attempt, self.token + ) + } + /// Deletes the staging directories of a dead attempt. Called only after + /// the claim CAS confirmed that attempt's owner dead. + fn remove_dead_staging(&self, attempt: i64, token: &str) -> Result<()> { + let staging = v3::view_dir(&self.storage, &self.family, &self.scope)?.join("staging"); + let prefix = format!("a{attempt}-{token}-"); + let entries = match std::fs::read_dir(&staging) { + Ok(entries) => entries, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(error) => return Err(error.into()), + }; + for entry in entries { + let entry = entry?; + if entry.file_name().to_string_lossy().starts_with(&prefix) { + std::fs::remove_dir_all(entry.path())?; + } + } + Ok(()) + } + /// True while the claim row still carries this owner's token. + pub fn verify_held(&self) -> Result { + with_base_barrier(&self.storage, &self.family, |tx| { + Ok(tx.query_row( + "SELECT EXISTS(SELECT 1 FROM base_builds WHERE base_scope=?1 AND owner_token=?2)", + params![self.scope, self.token], + |r| r.get(0), + )?) + }) + } + pub fn heartbeat(&self) -> Result<()> { + with_base_barrier(&self.storage, &self.family, |tx| { + let updated = tx.execute( + "UPDATE base_builds SET heartbeat_at_ms=?1 WHERE base_scope=?2 AND owner_token=?3", + params![crate::pins::now_ms() as i64, self.scope, self.token], + )?; + if updated != 1 { + return Err(BaseRefError::Unavailable( + "the build claim was taken over".into(), + )); + } + Ok(()) + }) + } + /// Marks the base serving after its first publication. The owner keeps + /// the claim, and with it the base's readiness successors, until + /// [`Self::release`]. + pub fn mark_serving(&self) -> Result<()> { + with_base_barrier(&self.storage, &self.family, |tx| { + tx.execute( + "UPDATE base_builds SET state='serving' WHERE base_scope=?1 AND owner_token=?2", + params![self.scope, self.token], + )?; + Ok(()) + }) + } + /// Releases the claim once the base is complete. + pub fn release(self) -> Result<()> { + with_base_barrier(&self.storage, &self.family, |tx| { + tx.execute( + "DELETE FROM base_builds WHERE base_scope=?1 AND owner_token=?2", + params![self.scope, self.token], + )?; + Ok(()) + }) + } +} + +/// What a binder that needs base `scope` does next. +#[derive(Debug)] +pub enum BinderPlan { + /// Build the base under this claim. + Build(BuildClaim), + /// Another owner is building it: ACK the bind at once, serve the bounded + /// direct scan with honest coverage, and wait for the first publication. + Wait { remaining: std::time::Duration }, + /// Take the strict path to an own generation, for this reason. + Strict(&'static str), +} + +/// The binder decision of the views design: only the claim owner builds; +/// everyone else waits for the first lexical-ready publication, and goes +/// strict only on a failed claim, a refused admission (decided by the caller +/// after `Build`), the live-base cap, or an exhausted wait. +pub fn plan_binder( + storage: &Path, + family: &str, + scope: &str, + waited: std::time::Duration, +) -> BinderPlan { + match claim_base(storage, family, scope, None) { + Ok(ClaimOutcome::Claimed(claim)) => BinderPlan::Build(claim), + Ok(ClaimOutcome::Busy { .. }) if waited >= LIVE_CLAIM_WAIT => { + BinderPlan::Strict("live_claim_wait_exhausted") + } + Ok(ClaimOutcome::Busy { .. }) => BinderPlan::Wait { + remaining: LIVE_CLAIM_WAIT - waited, + }, + Ok(ClaimOutcome::CapReached { .. }) => BinderPlan::Strict("live_base_cap"), + Err(_) => BinderPlan::Strict("claim_failed"), + } +} + +#[cfg(test)] +#[path = "base_builder_tests.rs"] +mod tests; diff --git a/crates/aft/src/views/base_builder_tests.rs b/crates/aft/src/views/base_builder_tests.rs new file mode 100644 index 0000000000..ed0cd6c91c --- /dev/null +++ b/crates/aft/src/views/base_builder_tests.rs @@ -0,0 +1,247 @@ +//! Build claim, live-base cap, binder plan, reservation sizing and v3 +//! disk-budget admission. + +use super::*; +use crate::views::base_refs::tests::{publish_test_base, FAMILY}; +use crate::views::eviction::{DiskBudget, V3Refusal, V3_HARD_LIMIT_GAP}; + +const MIB: u64 = 1 << 20; + +fn scope(byte: u8) -> String { + crate::views::base_refs::base_scope_for(&[byte; 32]) +} + +fn dead_claim(storage: &Path, scope: &str, token: &str, attempt: i64) { + with_base_barrier(storage, FAMILY, |tx| { + tx.execute( + "INSERT INTO base_builds VALUES(?1,?2,1,?3,?4,'building',0)", + params![scope, i32::MAX as u32 - 9, token, attempt], + )?; + Ok(()) + }) + .unwrap(); +} + +#[test] +fn the_initial_reservation_follows_the_ruled_formula_and_cap() { + // The ruling's AFT fixture: 5,377 files / 87,031,923 bytes, no history. + assert_eq!(initial_reservation(5_377, 87_031_923, None), 401 * MIB); + // A 200,000-file repository is capped at 1 GiB, not refused. + assert_eq!( + initial_reservation(200_000, 4 << 30, None), + INITIAL_RESERVATION_CAP + ); + // History scales a comparable base's measured peak; zero inventory drops it. + assert_eq!( + initial_reservation(100, 1_000, Some((200 * MIB, 50, 1_000))), + 64 * MIB + 500 * MIB + ); + assert_eq!( + initial_reservation(100, 1_000, Some((400 * MIB, 0, 1_000))), + initial_reservation(100, 1_000, None) + ); +} + +#[test] +fn two_claimants_of_one_base_produce_exactly_one_build() { + let _serial = v3::serial_for_test(); + let storage = tempfile::tempdir().unwrap(); + let target = scope(1); + let builds = std::sync::atomic::AtomicUsize::new(0); + std::thread::scope(|threads| { + for _ in 0..4 { + threads.spawn( + || match claim_base(storage.path(), FAMILY, &target, None).unwrap() { + ClaimOutcome::Claimed(_claim) => { + builds.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + } + ClaimOutcome::Busy { .. } => {} + other => panic!("{other:?}"), + }, + ); + } + }); + assert_eq!(builds.load(std::sync::atomic::Ordering::SeqCst), 1); +} + +#[test] +fn a_dead_builder_is_taken_over_once_with_attempt_plus_one_and_fresh_names() { + let _serial = v3::serial_for_test(); + let storage = tempfile::tempdir().unwrap(); + let target = scope(2); + dead_claim(storage.path(), &target, "deadtoken", 1); + let staging = v3::view_dir(storage.path(), FAMILY, &target) + .unwrap() + .join("staging/a1-deadtoken-x"); + std::fs::create_dir_all(&staging).unwrap(); + let ClaimOutcome::Claimed(claim) = claim_base(storage.path(), FAMILY, &target, None).unwrap() + else { + panic!("a dead owner's claim must be taken over"); + }; + assert_eq!(claim.attempt(), 2); + assert_ne!(claim.token(), "deadtoken"); + assert!(!staging.exists(), "the dead attempt's staging is reclaimed"); + let fresh = claim.staging_dir().unwrap(); + assert!(fresh + .file_name() + .unwrap() + .to_string_lossy() + .starts_with(&format!("a2-{}-", claim.token()))); + assert!(claim + .derived_name("g") + .ends_with(&format!("-a2-{}.sqlite", claim.token()))); + // The survivor is now the live owner: nobody else takes over. + assert!(matches!( + claim_base(storage.path(), FAMILY, &target, None).unwrap(), + ClaimOutcome::Busy { attempt: 2, .. } + )); + assert!(claim.verify_held().unwrap()); + claim.heartbeat().unwrap(); + claim.release().unwrap(); + assert!(matches!( + claim_base(storage.path(), FAMILY, &target, None).unwrap(), + ClaimOutcome::Claimed(_) + )); +} + +#[test] +fn a_live_claim_holds_an_empty_registered_base_through_sweeps() { + let _serial = v3::serial_for_test(); + let storage = tempfile::tempdir().unwrap(); + let target = scope(3); + let ClaimOutcome::Claimed(claim) = claim_base(storage.path(), FAMILY, &target, None).unwrap() + else { + panic!() + }; + with_base_barrier(storage.path(), FAMILY, |tx| { + register_base_in(tx, storage.path(), FAMILY, &target).map(|_| ()) + }) + .unwrap(); + for _ in 0..3 { + let report = crate::views::base_sweep::sweep_family(storage.path(), FAMILY, None).unwrap(); + assert!( + report.counted_bases.is_empty() && report.reclaimed_bases.is_empty(), + "{report:?}" + ); + } + claim.release().unwrap(); + crate::views::base_sweep::sweep_family(storage.path(), FAMILY, None).unwrap(); + let report = crate::views::base_sweep::sweep_family(storage.path(), FAMILY, None).unwrap(); + assert_eq!(report.reclaimed_bases, vec![target]); +} + +#[test] +fn the_live_base_cap_counts_claims_and_registered_bases_and_records_hits() { + let _serial = v3::serial_for_test(); + let storage = tempfile::tempdir().unwrap(); + with_base_barrier(storage.path(), FAMILY, |tx| { + for index in 0..LIVE_BASES_PER_FAMILY - 1 { + register_base_in( + tx, + storage.path(), + FAMILY, + &format!("{}{index:02}", scope(4)), + )?; + } + Ok(()) + }) + .unwrap(); + // One claimed-but-unregistered base fills the cap. + assert!(matches!( + claim_base(storage.path(), FAMILY, &scope(5), None).unwrap(), + ClaimOutcome::Claimed(_) + )); + assert!(matches!( + claim_base(storage.path(), FAMILY, &scope(6), None).unwrap(), + ClaimOutcome::CapReached { live_bases } if live_bases == LIVE_BASES_PER_FAMILY + )); + let hits: i64 = v3::LedgerReader::open_existing(storage.path(), FAMILY) + .unwrap() + .read(|c| { + Ok( + c.query_row("SELECT live_base_cap_hits FROM sweep_state", [], |r| { + r.get(0) + })?, + ) + }) + .unwrap(); + assert_eq!(hits, 1); + assert!(matches!( + plan_binder(storage.path(), FAMILY, &scope(6), std::time::Duration::ZERO), + BinderPlan::Strict("live_base_cap") + )); +} + +#[test] +fn binders_wait_for_a_live_claim_until_the_ruled_budget_then_go_strict() { + let _serial = v3::serial_for_test(); + let storage = tempfile::tempdir().unwrap(); + let target = scope(7); + let BinderPlan::Build(_owner) = + plan_binder(storage.path(), FAMILY, &target, std::time::Duration::ZERO) + else { + panic!("the first binder builds"); + }; + assert!(matches!( + plan_binder(storage.path(), FAMILY, &target, std::time::Duration::from_secs(1)), + BinderPlan::Wait { remaining } if remaining == LIVE_CLAIM_WAIT - std::time::Duration::from_secs(1) + )); + assert!(matches!( + plan_binder(storage.path(), FAMILY, &target, LIVE_CLAIM_WAIT), + BinderPlan::Strict("live_claim_wait_exhausted") + )); +} + +#[test] +fn v3_admission_refuses_past_the_hard_limit_with_the_named_gap_and_no_fallback() { + let _serial = v3::serial_for_test(); + let storage = tempfile::tempdir().unwrap(); + publish_test_base(storage.path(), &[("a.rs", b"alpha")]); + let lock = v3::ReservationLock::acquire(storage.path()).unwrap(); + let mut registry = v3::RegistryWriter::open_existing(storage.path(), FAMILY, &lock).unwrap(); + let usage = registry.accounting().unwrap().usage(); + let budget = DiskBudget { + family_store_hard: usage + 8 * MIB, + family_payload: usage + 2 * MIB, + ..DiskBudget::default() + }; + let before = registry.accounting().unwrap(); + let refused = budget.admit_v3(&mut registry, 9 * MIB).unwrap_err(); + assert!(matches!(refused, V3Refusal::HardLimit { .. })); + assert!( + refused.to_string().starts_with(V3_HARD_LIMIT_GAP), + "{refused}" + ); + assert_eq!( + registry.accounting().unwrap(), + before, + "a refusal reserves nothing" + ); + let admitted = budget.admit_v3(&mut registry, 4 * MIB).unwrap(); + assert!(admitted.over_target); + assert_eq!( + registry.reserved_bytes(&admitted.reservation).unwrap(), + 4 * MIB + ); + // Growth past the hard limit is refused before any byte is written. + assert!(matches!( + budget.extend_v3(&mut registry, &admitted, 5 * MIB), + Err(V3Refusal::HardLimit { .. }) + )); + budget.extend_v3(&mut registry, &admitted, 2 * MIB).unwrap(); + assert_eq!( + registry.reserved_bytes(&admitted.reservation).unwrap(), + 6 * MIB + ); + registry.release(admitted.reservation).unwrap(); + assert!(!storage.path().join("blobs/v2").exists()); +} + +#[cfg(any(unix, windows))] +#[test] +fn the_real_free_space_probe_reports_this_volume() { + let storage = tempfile::tempdir().unwrap(); + let (free, capacity) = + v3::probe_free_space_for_test(storage.path()).expect("supported platform"); + assert!(capacity > 0 && free <= capacity, "{free} / {capacity}"); +} diff --git a/crates/aft/src/views/eviction.rs b/crates/aft/src/views/eviction.rs index e688b75fb8..0927ec29ee 100644 --- a/crates/aft/src/views/eviction.rs +++ b/crates/aft/src/views/eviction.rs @@ -78,6 +78,99 @@ const MAX_WALK_ENTRIES: usize = 200_000; /// `pins/` and `readers/`, then the generation directories under `readers/`. const MAX_WALK_DEPTH: usize = 3; +/// The G4c storage gap a refused v3 admission reports. A refusal has no +/// legacy fallback: the caller serves what v3 already holds and names the gap. +pub const V3_HARD_LIMIT_GAP: &str = "storage_gap:v3_family_hard_limit"; + +/// Why a v3 reservation was refused. +#[derive(Debug)] +pub enum V3Refusal { + /// Committed usage plus the request would cross the family hard limit. + HardLimit { + usage: u64, + request: u64, + limit: u64, + }, + /// The ledger refused it (device floor, namespace budget) or failed. + Ledger(crate::blob_store::v3::Error), +} + +impl fmt::Display for V3Refusal { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::HardLimit { + usage, + request, + limit, + } => write!( + f, + "{V3_HARD_LIMIT_GAP}: {usage} used + {request} requested > {limit}" + ), + Self::Ledger(error) => write!(f, "storage_gap:{error}"), + } + } +} + +impl std::error::Error for V3Refusal {} + +/// An admitted v3 reservation. +#[derive(Debug)] +pub struct V3Admission { + pub reservation: crate::blob_store::v3::Reservation, + /// Usage after this reservation is above the family target: the caller + /// runs the family's v3 sweep so reclaimable bytes go before the next + /// admission. + pub over_target: bool, +} + +impl DiskBudget { + /// Admits a v3 reservation of `request` bytes against this family's + /// limits: the hard limit refuses, the target (the payload budget) only + /// asks for a sweep. Usage is the ledger's committed usage (last covering + /// measurement, committed charges since, and every outstanding + /// reservation), so concurrent builds are charged before they write. + pub fn admit_v3( + &self, + registry: &mut crate::blob_store::v3::RegistryWriter<'_>, + request: u64, + ) -> Result { + let usage = registry.accounting().map_err(V3Refusal::Ledger)?.usage(); + if usage.saturating_add(request) > self.family_store_hard { + return Err(V3Refusal::HardLimit { + usage, + request, + limit: self.family_store_hard, + }); + } + let reservation = registry.reserve(request).map_err(V3Refusal::Ledger)?; + Ok(V3Admission { + reservation, + over_target: usage.saturating_add(request) > self.family_payload, + }) + } + + /// Extends an admitted reservation before writing past it, under the same + /// hard limit. + pub fn extend_v3( + &self, + registry: &mut crate::blob_store::v3::RegistryWriter<'_>, + admission: &V3Admission, + additional: u64, + ) -> Result<(), V3Refusal> { + let usage = registry.accounting().map_err(V3Refusal::Ledger)?.usage(); + if usage.saturating_add(additional) > self.family_store_hard { + return Err(V3Refusal::HardLimit { + usage, + request: additional, + limit: self.family_store_hard, + }); + } + registry + .extend(&admission.reservation, additional) + .map_err(V3Refusal::Ledger) + } +} + /// The limits applied to one family. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct DiskBudget { From 72afb33ec6307fc927cca37348fd317143c26082 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Mon, 26 Oct 2026 16:29:36 +0100 Subject: [PATCH 4/8] mason: base builder from strict reads with v3 lexical materialization build_base reads the walker's membership with stability-checked reads (equal size, mtime and device/inode before and after, three attempts), builds a membership-keyed manifest (head_tree None, no git_oid), claims the base, admits the ruled initial reservation through DiskBudget::admit_v3 (a refusal releases the claim and returns the storage gap for the strict path), registers the base, writes the lexical plane into the v3 trigram store (trigram::materialize_v3, keys protected by a v3 live pin first) and first-publishes with graph and semantic planes pending. publish_successor lets only the live claim owner publish monotone readiness successors; a model outage is recorded as Failed while lexical serving continues. v3 generations carry no trigram segment: v3 has no segment rows or segment collection yet, so readers build from payloads. Co-authored-by: alfonso-aft <289616620+alfonso-aft@users.noreply.github.com> --- crates/aft/src/blob_store/v2.rs | 2 +- crates/aft/src/views/base_builder.rs | 224 ++++++++++++++ crates/aft/src/views/base_builder_tests.rs | 343 +++++++++++++++++++++ crates/aft/src/views/trigram.rs | 66 ++++ 4 files changed, 634 insertions(+), 1 deletion(-) diff --git a/crates/aft/src/blob_store/v2.rs b/crates/aft/src/blob_store/v2.rs index 8641f7bf70..372f725051 100644 --- a/crates/aft/src/blob_store/v2.rs +++ b/crates/aft/src/blob_store/v2.rs @@ -97,7 +97,7 @@ impl FamilyPlane { } } - const fn payload_schema(self) -> u32 { + pub(crate) const fn payload_schema(self) -> u32 { match self { Self::Trigram => TRIGRAM_PAYLOAD_SCHEMA, Self::Semantic => crate::blob_store::SEMANTIC_PAYLOAD_SCHEMA, diff --git a/crates/aft/src/views/base_builder.rs b/crates/aft/src/views/base_builder.rs index 92cb552df3..f4808a0473 100644 --- a/crates/aft/src/views/base_builder.rs +++ b/crates/aft/src/views/base_builder.rs @@ -460,6 +460,230 @@ pub fn plan_binder( } } +/// Attempts per path before a strict read gives up on an unstable file. +pub const STABLE_READ_ATTEMPTS: usize = 3; + +/// Reads `path` strictly: the file's stat (size, modification time and, on +/// unix, device and inode) must be equal before and after the read, up to +/// [`STABLE_READ_ATTEMPTS`] times. An unstable file is never accepted. +pub fn read_stable(path: &Path) -> std::io::Result> { + fn stamp(metadata: &std::fs::Metadata) -> (u64, Option, u64, u64) { + #[cfg(unix)] + let identity = { + use std::os::unix::fs::MetadataExt; + (metadata.dev(), metadata.ino()) + }; + #[cfg(not(unix))] + let identity = (0, 0); + ( + metadata.len(), + metadata.modified().ok(), + identity.0, + identity.1, + ) + } + for _ in 0..STABLE_READ_ATTEMPTS { + let before = std::fs::symlink_metadata(path)?; + if !before.is_file() { + return Err(std::io::Error::other("not a regular file")); + } + let bytes = std::fs::read(path)?; + let after = std::fs::symlink_metadata(path)?; + if stamp(&before) == stamp(&after) && bytes.len() as u64 == after.len() { + return Ok(bytes); + } + } + Err(std::io::Error::other( + "file kept changing during strict reads", + )) +} + +/// Inputs of one base build: the membership the configured walker produced +/// for `root`, and the effective identity inputs. +pub struct BaseBuildInput<'a> { + pub storage: &'a Path, + pub family: &'a str, + pub root: &'a Path, + pub paths: &'a [super::RelPath], + pub policy: crate::blob_store::v2::TrigramPolicy, + pub semantic_producer: Option, + pub callgraph_producer: String, + pub ignore_fingerprint: Option, + /// A comparable base's `(peak allocated build bytes, files, source bytes)`. + pub history: Option<(u64, u64, u64)>, + pub budget: super::eviction::DiskBudget, +} + +/// What [`build_base`] did. +#[derive(Debug)] +pub enum BaseBuild { + /// This process built and first-published the base; it keeps the claim + /// and owns the readiness successors. + Published { + identity: BaseIdentity, + generation: String, + claim: BuildClaim, + }, + /// Another live owner is building it. + Busy, + /// The bind takes the strict path for this reason. + Strict(String), +} + +/// Builds a membership-keyed base from strict, stability-checked reads of +/// `input.paths`, keyed by the tagged membership-and-content digest +/// (`head_tree = None`, no `git_oid`). Single-flight through the build claim; +/// admission through the v3 disk budget with the ruled initial reservation. +/// The first publication happens once membership and the lexical plane are +/// complete; graph and semantic entries stay pending for readiness successors. +pub fn build_base( + input: BaseBuildInput<'_>, + observer: Option<&dyn DurabilityObserver>, +) -> Result { + let BaseBuildInput { + storage, + family, + root, + paths, + policy, + semantic_producer, + callgraph_producer, + ignore_fingerprint, + history, + budget, + } = input; + let mut bytes = std::collections::BTreeMap::new(); + let mut source_bytes = 0u64; + let header = super::manifest_v2::ManifestHeader { + producers: super::manifest_v2::Producers { + trigram: policy.fingerprint_hex(), + semantic: semantic_producer.clone(), + callgraph: callgraph_producer, + }, + head_tree: None, + ignore_fingerprint, + segment: None, + }; + let mut manifest = ManifestV2::new_v3( + header, + None, + super::manifest_v2::PublishedFrontier::default(), + ); + for path in paths { + #[cfg(unix)] + let relative = { + use std::os::unix::ffi::OsStrExt; + std::ffi::OsStr::from_bytes(path.as_bytes()).to_owned() + }; + #[cfg(not(unix))] + let relative = + std::ffi::OsString::from(String::from_utf8_lossy(path.as_bytes()).into_owned()); + let source = read_stable(&root.join(relative))?; + source_bytes += source.len() as u64; + let planes = super::manifest_v2::EntryPlanes { + trigram: Some(PlaneState::pending("lexical plane not built")), + semantic: semantic_producer + .as_ref() + .filter(|_| super::semantic::applies_to(path)) + .map(|_| PlaneState::pending("awaiting the base owner")), + callgraph: Some(PlaneState::pending("awaiting the base owner")), + }; + manifest.insert( + path.clone(), + super::manifest_v2::EntryV2::regular( + crate::blob_store::v2::ContentHash::of(&source), + source.len() as u64, + planes, + ), + )?; + bytes.insert(path.clone(), source); + } + let identity = BaseIdentity::of(family, &manifest)?; + let claim = match claim_base(storage, family, &identity.scope, observer)? { + ClaimOutcome::Claimed(claim) => claim, + ClaimOutcome::Busy { .. } => return Ok(BaseBuild::Busy), + ClaimOutcome::CapReached { .. } => return Ok(BaseBuild::Strict("live_base_cap".into())), + }; + // Admission first: the reservation charges the build before any byte. + let request = initial_reservation(paths.len() as u64, source_bytes, history); + let admitted = { + let lock = v3::ReservationLock::acquire(storage)?; + let mut registry = v3::open_base_refs_registry(storage, family, &lock, None)?; + match budget.admit_v3(&mut registry, request) { + Ok(admitted) => admitted.reservation, + Err(refusal) => { + drop(registry); + drop(lock); + claim.release()?; + return Ok(BaseBuild::Strict(refusal.to_string())); + } + } + }; + let release = |reservation: v3::Reservation| -> Result<()> { + let lock = v3::ReservationLock::acquire(storage)?; + let mut registry = v3::RegistryWriter::open_for_barrier(storage, family, &lock)?; + registry.release(reservation)?; + Ok(()) + }; + let built = (|| -> Result { + let registered = with_base_barrier(storage, family, |tx| { + register_base_in(tx, storage, family, &identity.scope) + })?; + if registered.1 { + boundary(observer, DurabilityStep::BaseRegistered)?; + } + let mut live = crate::pins::LivePin::create_v3(storage, family, &identity.scope) + .map_err(|error| BaseRefError::Io(std::io::Error::other(error.to_string())))?; + super::trigram::materialize_v3(storage, family, &mut manifest, &bytes, &mut live, policy) + .map_err(|error| BaseRefError::Io(std::io::Error::other(error.reason)))?; + if !claim.verify_held()? { + return Err(BaseRefError::Unavailable( + "the build claim was taken over".into(), + )); + } + match publish_base(storage, family, &manifest, observer)? { + BasePublication::Published { generation, .. } => Ok(generation), + BasePublication::Lost { current } => Err(BaseRefError::Unavailable(format!( + "another publisher moved the base pointer to {current:?}" + ))), + } + })(); + release(admitted)?; + let generation = built?; + claim.mark_serving()?; + Ok(BaseBuild::Published { + identity, + generation, + claim, + }) +} + +/// Publishes a readiness successor of the claimed base: `update` edits a +/// copy of the current manifest (filling pending planes), and the result must +/// pass [`check_successor`]. A model outage is recorded as `Failed` here +/// without touching the lexical plane. +pub fn publish_successor( + claim: &BuildClaim, + update: impl FnOnce(&mut ManifestV2), + observer: Option<&dyn DurabilityObserver>, +) -> Result { + if !claim.verify_held()? { + return Err(BaseRefError::Unavailable( + "the build claim was taken over".into(), + )); + } + let base = super::base_refs::resolve_base(&claim.storage, &claim.family, &claim.scope)?; + let mut next = base.manifest().clone(); + drop(base); + update(&mut next); + match publish_base(&claim.storage, &claim.family, &next, observer)? { + BasePublication::Published { generation, .. } => Ok(generation), + BasePublication::Lost { current } => Err(BaseRefError::Unavailable(format!( + "the base pointer moved to {current:?}" + ))), + } +} + #[cfg(test)] #[path = "base_builder_tests.rs"] mod tests; diff --git a/crates/aft/src/views/base_builder_tests.rs b/crates/aft/src/views/base_builder_tests.rs index ed0cd6c91c..bf5fc87758 100644 --- a/crates/aft/src/views/base_builder_tests.rs +++ b/crates/aft/src/views/base_builder_tests.rs @@ -245,3 +245,346 @@ fn the_real_free_space_probe_reports_this_volume() { v3::probe_free_space_for_test(storage.path()).expect("supported platform"); assert!(capacity > 0 && free <= capacity, "{free} / {capacity}"); } + +fn policy() -> crate::blob_store::v2::TrigramPolicy { + crate::blob_store::v2::TrigramPolicy { + max_file_size: 1 << 20, + } +} + +fn checkout(files: &[(&str, &[u8])]) -> (tempfile::TempDir, Vec) { + let root = tempfile::tempdir().unwrap(); + let mut paths = Vec::new(); + for (name, bytes) in files { + let path = root.path().join(name); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(&path, bytes).unwrap(); + paths.push(crate::views::RelPath::new(name.as_bytes().to_vec()).unwrap()); + } + (root, paths) +} + +fn input<'a>( + storage: &'a Path, + root: &'a Path, + paths: &'a [crate::views::RelPath], + budget: DiskBudget, +) -> BaseBuildInput<'a> { + BaseBuildInput { + storage, + family: FAMILY, + root, + paths, + policy: policy(), + semantic_producer: Some("sem".into()), + callgraph_producer: "graph".into(), + ignore_fingerprint: None, + history: None, + budget, + } +} + +const FILES: &[(&str, &[u8])] = &[ + ("src/a.rs", b"fn alpha() {}"), + ("src/b.rs", b"fn beta() {}"), +]; + +#[test] +fn a_base_is_built_from_strict_reads_and_first_published_with_its_lexical_plane() { + let _serial = v3::serial_for_test(); + let storage = tempfile::tempdir().unwrap(); + let (root, paths) = checkout(FILES); + let BaseBuild::Published { + identity, + generation, + claim, + } = build_base( + input(storage.path(), root.path(), &paths, DiskBudget::default()), + None, + ) + .unwrap() + else { + panic!("the only builder publishes"); + }; + assert_eq!(claim.attempt(), 1); + let base = + crate::views::base_refs::resolve_base(storage.path(), FAMILY, &identity.scope).unwrap(); + assert_eq!(base.generation(), generation); + assert!(base.manifest().header().head_tree.is_none()); + let reader = + v3::PlaneReader::open_existing(storage.path(), FAMILY, FamilyPlane::Trigram).unwrap(); + for (_, entry) in base.manifest().entries() { + let Some(PlaneState::Ready { key }) = entry.plane_state(FamilyPlane::Trigram) else { + panic!("lexical plane incomplete at first publication"); + }; + let key = crate::blob_store::v2::FamilyKey::new( + FamilyPlane::Trigram, + crate::blob_store::v2::parse_hex32(key).unwrap(), + ); + assert!(reader.get(&key).unwrap().is_some()); + assert!(entry + .plane_state(FamilyPlane::Callgraph) + .unwrap() + .is_pending()); + } + // The admission reservation was released; only measured charges remain. + let ledger = v3::LedgerReader::open_existing(storage.path(), FAMILY).unwrap(); + assert_eq!(ledger.accounting().unwrap().reserved_bytes, 0); + // A second builder of the same identity, while the claim lives, does not build. + assert!(matches!( + build_base( + input(storage.path(), root.path(), &paths, DiskBudget::default()), + None + ) + .unwrap(), + BaseBuild::Busy + )); + assert!(!storage.path().join("views/v3").exists()); + assert!(!storage.path().join("blobs/v2").exists()); +} + +#[test] +fn concurrent_builders_of_one_base_build_it_exactly_once() { + let _serial = v3::serial_for_test(); + let storage = tempfile::tempdir().unwrap(); + let (root, paths) = checkout(FILES); + let results: Vec<_> = std::thread::scope(|threads| { + let handles: Vec<_> = (0..3) + .map(|_| { + threads.spawn(|| { + build_base( + input(storage.path(), root.path(), &paths, DiskBudget::default()), + None, + ) + .unwrap() + }) + }) + .collect(); + handles + .into_iter() + .map(|handle| handle.join().unwrap()) + .collect() + }); + assert_eq!( + results + .iter() + .filter(|built| matches!(built, BaseBuild::Published { .. })) + .count(), + 1, + "{results:?}" + ); + assert!(results + .iter() + .all(|built| matches!(built, BaseBuild::Published { .. } | BaseBuild::Busy))); +} + +#[test] +fn a_refused_admission_sends_the_bind_strict_with_the_named_gap_and_frees_the_claim() { + let _serial = v3::serial_for_test(); + let storage = tempfile::tempdir().unwrap(); + let (root, paths) = checkout(FILES); + let tiny = DiskBudget { + family_store_hard: MIB, + ..DiskBudget::default() + }; + let BaseBuild::Strict(reason) = + build_base(input(storage.path(), root.path(), &paths, tiny), None).unwrap() + else { + panic!("over the hard limit"); + }; + assert!(reason.starts_with(V3_HARD_LIMIT_GAP), "{reason}"); + assert!(matches!( + build_base( + input(storage.path(), root.path(), &paths, DiskBudget::default()), + None + ) + .unwrap(), + BaseBuild::Published { .. } + )); +} + +#[test] +fn a_dead_builder_after_binds_is_taken_over_once_and_its_successor_is_cold_equivalent() { + use crate::views::base_refs::tests::{bind, published}; + let _serial = v3::serial_for_test(); + let storage = tempfile::tempdir().unwrap(); + let (root, paths) = checkout(FILES); + let BaseBuild::Published { + identity, + generation, + claim, + } = build_base( + input(storage.path(), root.path(), &paths, DiskBudget::default()), + None, + ) + .unwrap() + else { + panic!() + }; + // Workers bind to the lexical-ready base. + let base = + crate::views::base_refs::resolve_base(storage.path(), FAMILY, &identity.scope).unwrap(); + let header = base.manifest().header().clone(); + drop(base); + let delta = |scope_generation: &str| { + crate::views::manifest_v2::ManifestV2::new_v3( + header.clone(), + Some(identity.header(scope_generation)), + crate::views::manifest_v2::PublishedFrontier::default(), + ) + }; + let mut workers = Vec::new(); + for scope in ["worker-1", "worker-2"] { + let (_, composed) = published( + bind( + storage.path(), + scope, + &identity, + &generation, + delta(&generation), + None, + ) + .unwrap(), + ); + drop(composed); + workers.push(scope); + } + // The builder dies with graph and semantic planes pending: its claim row + // now names a process that is gone. + let dead_token = claim.token().to_owned(); + with_base_barrier(storage.path(), FAMILY, |tx| { + tx.execute( + "UPDATE base_builds SET owner_pid=?1,owner_start=1 WHERE owner_token=?2", + params![i32::MAX as u32 - 11, dead_token], + )?; + Ok(()) + }) + .unwrap(); + drop(claim); + // Lexical service continues before the takeover. + let lexical = |scope: &str| { + let reader = crate::views::base_refs::open_foreign_v3( + storage.path(), + FAMILY, + scope, + &header.producers, + ) + .unwrap() + .unwrap(); + reader + .entries() + .filter(|(_, entry)| { + matches!( + entry.plane_state(FamilyPlane::Trigram), + Some(PlaneState::Ready { .. }) + ) + }) + .count() + }; + assert_eq!(lexical("worker-1"), FILES.len()); + // With no new binds, the surviving workers race for the claim: exactly + // one takes it over with attempt + 1. + let outcomes: Vec<_> = std::thread::scope(|threads| { + let handles: Vec<_> = workers + .iter() + .map(|_| { + threads.spawn(|| claim_base(storage.path(), FAMILY, &identity.scope, None).unwrap()) + }) + .collect(); + handles + .into_iter() + .map(|handle| handle.join().unwrap()) + .collect() + }); + let mut takeovers = outcomes.into_iter().filter_map(|outcome| match outcome { + ClaimOutcome::Claimed(claim) => Some(claim), + _ => None, + }); + let survivor = takeovers.next().expect("one survivor takes over"); + assert!(takeovers.next().is_none(), "exactly one takeover"); + assert_eq!(survivor.attempt(), 2); + assert_ne!(survivor.token(), dead_token); + // The survivor completes the pending planes: graph ready, semantic hit a + // model outage. Lexical stays served throughout. + let graph_key = |path: &crate::views::RelPath| { + crate::blob_store::v2::FamilyKey::new( + FamilyPlane::Callgraph, + *blake3::hash(path.as_bytes()).as_bytes(), + ) + }; + let fill = |manifest: &mut crate::views::manifest_v2::ManifestV2| { + for (path, entry) in manifest.entries_mut() { + if let crate::views::manifest_v2::EntryV2::Regular { planes, .. } = entry { + planes.callgraph = Some(PlaneState::ready(&graph_key(path))); + if planes.semantic.is_some() { + planes.semantic = Some(PlaneState::Failed { + reason: "model backend unavailable".into(), + producer: "sem".into(), + }); + } + } + } + }; + let successor = publish_successor(&survivor, fill, None).unwrap(); + assert_eq!(lexical("worker-2"), FILES.len()); + let base = + crate::views::base_refs::resolve_base(storage.path(), FAMILY, &identity.scope).unwrap(); + assert_eq!(base.generation(), successor); + // Cold equivalence: the successor equals a base built cold over the same + // files with the same completions. + let mut cold = crate::views::manifest_v2::ManifestV2::new_v3( + header.clone(), + None, + crate::views::manifest_v2::PublishedFrontier::default(), + ); + for (path, entry) in + crate::views::base_refs::resolve_base(storage.path(), FAMILY, &identity.scope) + .unwrap() + .manifest() + .entries() + { + let crate::views::manifest_v2::EntryV2::Regular { content, size, .. } = entry else { + continue; + }; + let bytes = std::fs::read( + root.path() + .join(std::str::from_utf8(path.as_bytes()).unwrap()), + ) + .unwrap(); + assert_eq!(crate::blob_store::v2::ContentHash::of(&bytes), *content); + let key = crate::blob_store::v2::TrigramKey { + content: *content, + policy: policy(), + } + .family_key(); + cold.insert( + path.clone(), + crate::views::manifest_v2::EntryV2::regular( + *content, + *size, + crate::views::manifest_v2::EntryPlanes { + trigram: Some(PlaneState::ready(&key)), + semantic: entry.plane_state(FamilyPlane::Semantic).cloned(), + callgraph: Some(PlaneState::ready(&graph_key(path))), + }, + ), + ) + .unwrap(); + } + assert_eq!(base.manifest(), &cold); + // A forbidden transition (a Ready key changed) is refused. + drop(base); + let error = publish_successor( + &survivor, + |manifest| { + for (_, entry) in manifest.entries_mut() { + if let crate::views::manifest_v2::EntryV2::Regular { planes, .. } = entry { + planes.callgraph = Some(PlaneState::pending("regressed")); + } + } + }, + None, + ) + .unwrap_err(); + assert!(matches!(error, BaseRefError::Corrupt(_)), "{error}"); +} diff --git a/crates/aft/src/views/trigram.rs b/crates/aft/src/views/trigram.rs index 4dbbab59e0..fd48473cbf 100644 --- a/crates/aft/src/views/trigram.rs +++ b/crates/aft/src/views/trigram.rs @@ -697,6 +697,72 @@ pub fn materialize( }) } +/// The lexical plane of a v3 generation (a base, or a worker's delta): every +/// regular entry's trigram payload goes into `blobs/v3//trigram.sqlite` +/// through one v3 publication, after `live` protects every key, and the entry +/// becomes `Ready`. `bytes` holds the strictly read source of each entry; an +/// entry whose bytes no longer hash to its content fails the whole call. +/// +/// No segment is written: v3 has no segment rows or segment collection yet, +/// so v3 generations carry `segment: None` and readers build from payloads. +pub fn materialize_v3( + storage: &Path, + family: &str, + manifest: &mut super::manifest_v2::ManifestV2, + bytes: &BTreeMap>, + live: &mut crate::pins::LivePin, + policy: TrigramPolicy, +) -> Result<(), super::contracts::PlaneError> { + if manifest.header().producers.trigram != policy.fingerprint_hex() { + return Err(plane_error("trigram producer mismatch")); + } + let mut payloads = Vec::new(); + for (path, entry) in manifest.entries() { + let super::manifest_v2::EntryV2::Regular { content, size, .. } = entry else { + continue; + }; + let source = bytes + .get(path) + .ok_or_else(|| plane_error(format!("no strict read for {path:?}")))?; + if ContentHash::of(source) != *content || source.len() as u64 != *size { + return Err(plane_error("source changed during trigram construction")); + } + let key = crate::blob_store::v2::TrigramKey { + content: *content, + policy, + } + .family_key(); + payloads.push(( + path.clone(), + key, + TrigramPayload::extract(source, &policy).encode(), + )); + } + // Protect every key before the first put, as the v2 materializer does. + live.protect(&payloads.iter().map(|(_, key, _)| *key).collect::>()) + .map_err(|e| plane_error(e.to_string()))?; + let request = payloads + .iter() + .map(|(_, _, payload)| payload.len() as u64) + .sum::() + .saturating_mul(2) + .saturating_add(1 << 20); + let schema = FamilyPlane::Trigram.payload_schema(); + crate::blob_store::v3::publish(storage, family, request, None, |publication| { + for (_, key, payload) in &payloads { + publication.put(key, payload, schema)?; + } + Ok(()) + }) + .map_err(|e| plane_error(e.to_string()))?; + for (path, key, _) in &payloads { + if let Some(super::manifest_v2::EntryV2::Regular { planes, .. }) = manifest.get_mut(path) { + planes.trigram = Some(super::readiness::PlaneState::ready(key)); + } + } + Ok(()) +} + type Residents = BTreeMap<(String, String), Arc>; /// Resident immutable data shared across checkout opens. Register explicitly From 0f4f7e4c0f5711a485615bf94b447f155fb7e362 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Mon, 26 Oct 2026 16:36:41 +0100 Subject: [PATCH 5/8] mason: cross-process base-reference scenarios and base-path kill test - G7: two child binders (one SIGSTOPped with its intent row across two sweeps, then killed) plus in-process binds with one aborting; the base is never counted, the stopped owner's intent is kept, then orphaned, then retired, and published references keep the base. - Kill test: a child parks at each DurabilityStep::BASE_PATH boundary (the child refuses unknown or non-base step names; the parent checks the ready file names the step), is SIGKILLed, and a fresh process sweeps: dead reservations are released, every published reference resolves, a dead BaseClaimed claim is taken over with attempt 2, and nothing appears under /views/v3. - Delete main, kill main's daemon, run GC in a standalone process, restart the worker: it composes over its base with correct contents. Co-authored-by: alfonso-aft <289616620+alfonso-aft@users.noreply.github.com> --- crates/aft/tests/integration/main.rs | 1 + crates/aft/tests/integration/v3_base_refs.rs | 600 +++++++++++++++++++ 2 files changed, 601 insertions(+) create mode 100644 crates/aft/tests/integration/v3_base_refs.rs diff --git a/crates/aft/tests/integration/main.rs b/crates/aft/tests/integration/main.rs index f231b5bbb1..5d5f1110ea 100644 --- a/crates/aft/tests/integration/main.rs +++ b/crates/aft/tests/integration/main.rs @@ -195,6 +195,7 @@ mod trace_to_symbol_test; mod typescript_native_lsp_test; mod undo_path_lifecycle_test; mod url_fetch_test; +mod v3_base_refs; mod v3_isolation; mod view_assembly_wiring_test; mod vue_test; diff --git a/crates/aft/tests/integration/v3_base_refs.rs b/crates/aft/tests/integration/v3_base_refs.rs new file mode 100644 index 0000000000..05b48f3cbe --- /dev/null +++ b/crates/aft/tests/integration/v3_base_refs.rs @@ -0,0 +1,600 @@ +//! v3 base references across real processes: concurrent binders with a +//! stopped one, a publisher killed at every base-path durability boundary, +//! and the delete-main scenario where a worker restarts and still serves from +//! its base after a foreign process collected the family. +//! +//! Child processes re-run this test binary with `--ignored --exact +//! v3_base_refs::base_ref_child`; the scenario and its pause point come from +//! `BASE_REF_*` environment variables. A child parks after writing its ready +//! file and is stopped or killed by the parent. + +#![cfg(unix)] + +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::{Child, Command, Stdio}; +use std::thread; +use std::time::{Duration, Instant}; + +use aft::blob_store::v2::{ContentHash, FamilyPlane, TrigramPolicy}; +use aft::blob_store::v3; +use aft::views::base_builder::{ + build_base, claim_base, publish_base, publish_successor, BaseBuild, BaseBuildInput, + BasePublication, ClaimOutcome, +}; +use aft::views::base_refs::{ + open_foreign_v3, publish_worker, resolve_base, with_base_barrier, BaseIdentity, WorkerOutcome, + WorkerPublication, +}; +use aft::views::base_sweep::sweep_family; +use aft::views::contracts::{DurabilityObserver, DurabilityStep}; +use aft::views::eviction::DiskBudget; +use aft::views::manifest_v2::{ + EntryPlanes, EntryV2, ManifestHeader, ManifestV2, Producers, PublishedFrontier, +}; +use aft::views::readiness::PlaneState; +use aft::views::RelPath; +use tempfile::tempdir; + +const FAMILY: &str = "00112233445566ff"; +const CHILD_TEST: &str = "v3_base_refs::base_ref_child"; +const FILES: &[(&str, &[u8])] = &[ + ("src/lib.rs", b"pub fn shared() -> u32 { 1 }"), + ("src/util.rs", b"pub fn helper() -> u32 { 2 }"), +]; + +fn policy() -> TrigramPolicy { + TrigramPolicy { + max_file_size: 1 << 20, + } +} + +fn producers() -> Producers { + Producers { + trigram: policy().fingerprint_hex(), + semantic: None, + callgraph: "graph".into(), + } +} + +fn rel(name: &str) -> RelPath { + RelPath::new(name.as_bytes().to_vec()).unwrap() +} + +fn checkout(root: &Path, files: &[(&str, &[u8])]) -> Vec { + files + .iter() + .map(|(name, bytes)| { + let path = root.join(name); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(path, bytes).unwrap(); + rel(name) + }) + .collect() +} + +fn build(storage: &Path, root: &Path, observer: Option<&dyn DurabilityObserver>) -> BaseBuild { + let paths = checkout(root, FILES); + build_base( + BaseBuildInput { + storage, + family: FAMILY, + root, + paths: &paths, + policy: policy(), + semantic_producer: None, + callgraph_producer: "graph".into(), + ignore_fingerprint: None, + history: None, + budget: DiskBudget::default(), + }, + observer, + ) + .unwrap() +} + +fn current_base(storage: &Path, scope: &str) -> (BaseIdentity, String) { + let base = resolve_base(storage, FAMILY, scope).unwrap(); + (base.identity().clone(), base.generation().to_owned()) +} + +/// A worker delta that edits nothing: it inherits every base entry. +fn bind( + storage: &Path, + scope: &str, + identity: &BaseIdentity, + generation: &str, + changed: Option<&[u8]>, + observer: Option<&dyn DurabilityObserver>, +) -> Result { + let header = ManifestHeader { + producers: producers(), + head_tree: None, + ignore_fingerprint: None, + segment: None, + }; + let mut delta = ManifestV2::new_v3( + header, + Some(identity.header(generation)), + PublishedFrontier::default(), + ); + if let Some(bytes) = changed { + delta + .insert( + rel("src/worker_only.rs"), + EntryV2::regular( + ContentHash::of(bytes), + bytes.len() as u64, + EntryPlanes::default(), + ), + ) + .unwrap(); + } + let root = storage.join("roots").join(scope); + fs::create_dir_all(&root).unwrap(); + let base = identity.header(generation); + publish_worker( + WorkerPublication { + storage, + family: FAMILY, + worker_scope: scope, + root: &root, + base: &base, + delta, + previous: None, + }, + observer, + ) +} + +fn spawn_child(scenario: &str, storage: &Path, ready: &Path, extra: &[(&str, &str)]) -> Child { + let mut command = Command::new(std::env::current_exe().unwrap()); + command + .args(["--exact", CHILD_TEST, "--ignored", "--nocapture"]) + .env("BASE_REF_SCENARIO", scenario) + .env("BASE_REF_STORAGE", storage) + .env("BASE_REF_READY", ready) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + for (name, value) in extra { + command.env(name, value); + } + command.spawn().unwrap() +} + +fn wait_for_file(child: &mut Child, path: &Path) { + let started = Instant::now(); + while !path.is_file() { + if let Some(status) = child.try_wait().unwrap() { + panic!("child exited before writing {path:?}: {status}"); + } + assert!( + started.elapsed() < Duration::from_secs(120), + "timed out waiting for {path:?}" + ); + thread::sleep(Duration::from_millis(10)); + } +} + +fn signal(child: &Child, signal: i32) { + assert_eq!(unsafe { libc::kill(child.id() as i32, signal) }, 0); +} + +fn kill(child: &mut Child) { + signal(child, libc::SIGKILL); + assert!(!child.wait().unwrap().success()); +} + +struct ParkAt { + step: DurabilityStep, + ready: PathBuf, +} + +impl DurabilityObserver for ParkAt { + fn reached(&self, step: DurabilityStep) { + if step == self.step { + fs::write(&self.ready, self.step.as_str()).unwrap(); + loop { + thread::sleep(Duration::from_millis(10)); + } + } + } +} + +fn park_forever(ready: &Path) -> ! { + fs::write(ready, b"ready").unwrap(); + loop { + thread::sleep(Duration::from_millis(10)); + } +} + +/// The child side of every multi-process scenario. +#[test] +#[ignore] +fn base_ref_child() { + let storage = PathBuf::from(std::env::var_os("BASE_REF_STORAGE").expect("BASE_REF_STORAGE")); + let ready = PathBuf::from(std::env::var_os("BASE_REF_READY").expect("BASE_REF_READY")); + let scenario = std::env::var("BASE_REF_SCENARIO").unwrap(); + v3::set_free_space_for_test(u64::MAX, u64::MAX / 2); + match scenario.as_str() { + "bind-and-park" => { + let scope = std::env::var("BASE_REF_WORKER").unwrap(); + let base = std::env::var("BASE_REF_BASE").unwrap(); + let (identity, generation) = current_base(&storage, &base); + let park = std::env::var("BASE_REF_STEP").ok().map(|step| ParkAt { + step: DurabilityStep::parse(&step).expect("unknown step"), + ready: ready.clone(), + }); + let outcome = bind( + &storage, + &scope, + &identity, + &generation, + Some(scope.as_bytes()), + park.as_ref().map(|park| park as &dyn DurabilityObserver), + ) + .unwrap(); + assert!(matches!(outcome, WorkerOutcome::Published { .. })); + // Keep the bundle (and its markers) for as long as this process lives. + let _held = outcome; + park_forever(&ready); + } + "build-main-and-park" => { + let root = PathBuf::from(std::env::var_os("BASE_REF_ROOT").unwrap()); + let BaseBuild::Published { + identity, + generation, + claim, + } = build(&storage, &root, None) + else { + panic!("main's daemon is the only builder"); + }; + let _main = bind(&storage, "main", &identity, &generation, None, None).unwrap(); + let _claim = claim; + park_forever(&ready); + } + "foreign-gc" => { + // A standalone process with no view session or views + // configuration: it honours references through the protocol. + for _ in 0..3 { + sweep_family(&storage, FAMILY, None).unwrap(); + } + fs::write(&ready, b"swept").unwrap(); + } + "base-path" => { + let name = std::env::var("BASE_REF_STEP").unwrap(); + let step = DurabilityStep::parse(&name) + .unwrap_or_else(|| panic!("the kill-test child refuses unknown step {name}")); + assert!( + DurabilityStep::BASE_PATH.contains(&step), + "{name} is not a base-path step" + ); + let observer = ParkAt { step, ready }; + let root = storage.join("roots/main"); + let BaseBuild::Published { + identity, + generation, + claim, + } = build(&storage, &root, Some(&observer)) + else { + panic!("the only builder publishes"); + }; + let successor = publish_successor( + &claim, + |manifest| { + for (_, entry) in manifest.entries_mut() { + if let EntryV2::Regular { planes, .. } = entry { + planes.callgraph = Some(PlaneState::Failed { + reason: "no extractor".into(), + producer: "graph".into(), + }); + } + } + }, + Some(&observer), + ) + .unwrap(); + let bound = bind( + &storage, + "worker", + &identity, + &successor, + Some(b"worker"), + Some(&observer), + ) + .unwrap(); + drop(bound); + claim.release().unwrap(); + let _ = generation; + // An unrelated, unreferenced base for the reclaim boundaries. + let other = base_manifest(&[("other.rs", b"other")]); + assert!(matches!( + publish_base(&storage, FAMILY, &other, Some(&observer)).unwrap(), + BasePublication::Published { .. } + )); + for _ in 0..2 { + sweep_family(&storage, FAMILY, Some(&observer)).unwrap(); + } + panic!("the pause point {name} was never reached"); + } + other => panic!("unknown scenario {other}"), + } +} + +/// A base manifest whose lexical plane is complete without stored payloads: +/// an empty-content file needs no trigram row for this protocol test. +fn base_manifest(files: &[(&str, &[u8])]) -> ManifestV2 { + let mut manifest = ManifestV2::new_v3( + ManifestHeader { + producers: producers(), + head_tree: None, + ignore_fingerprint: None, + segment: None, + }, + None, + PublishedFrontier::default(), + ); + for (name, bytes) in files { + manifest + .insert( + rel(name), + EntryV2::regular( + ContentHash::of(bytes), + bytes.len() as u64, + EntryPlanes::default(), + ), + ) + .unwrap(); + } + manifest +} + +fn refs(storage: &Path) -> Vec<(String, String)> { + with_base_barrier(storage, FAMILY, |tx| { + Ok(tx + .prepare("SELECT worker_scope,state FROM base_refs ORDER BY worker_scope")? + .query_map([], |r| Ok((r.get(0)?, r.get(1)?)))? + .collect::>>()?) + }) + .unwrap() +} + +fn registered(storage: &Path, scope: &str) -> bool { + v3::LedgerReader::open_existing(storage, FAMILY) + .unwrap() + .member(scope) + .unwrap() + .is_some() +} + +/// G7: two child binders plus several in-process binds, one of them aborting, +/// and one child SIGSTOPped with its intent row across two sweeps. +#[test] +fn concurrent_binders_across_processes_keep_the_base_and_a_stopped_owner_keeps_its_intent() { + v3::set_free_space_for_test(u64::MAX, u64::MAX / 2); + let storage = tempdir().unwrap(); + let root = storage.path().join("roots/main"); + let BaseBuild::Published { + identity, claim, .. + } = build(storage.path(), &root, None) + else { + panic!() + }; + claim.release().unwrap(); + let control = tempdir().unwrap(); + let ready_a = control.path().join("a"); + let mut a = spawn_child( + "bind-and-park", + storage.path(), + &ready_a, + &[ + ("BASE_REF_WORKER", "worker-a"), + ("BASE_REF_BASE", &identity.scope), + ], + ); + let ready_s = control.path().join("s"); + let mut stopped = spawn_child( + "bind-and-park", + storage.path(), + &ready_s, + &[ + ("BASE_REF_WORKER", "worker-s"), + ("BASE_REF_BASE", &identity.scope), + ("BASE_REF_STEP", DurabilityStep::BaseRefMarked.as_str()), + ], + ); + wait_for_file(&mut a, &ready_a); + wait_for_file(&mut stopped, &ready_s); + signal(&stopped, libc::SIGSTOP); + let (_, generation) = current_base(storage.path(), &identity.scope); + let mut held = Vec::new(); + for index in 0..3u8 { + let mut header = identity.clone(); + if index == 2 { + header.membership = [0; 32]; + } + match bind( + storage.path(), + &format!("local-{index}"), + &header, + &generation, + Some(&[index]), + None, + ) { + Ok(outcome) => held.push(outcome), + Err(_) => assert_eq!(index, 2, "only the forged binder aborts"), + } + } + assert_eq!(held.len(), 2); + for _ in 0..2 { + let report = sweep_family(storage.path(), FAMILY, None).unwrap(); + assert!( + report.counted_bases.is_empty() && report.reclaimed_bases.is_empty(), + "{report:?}" + ); + } + let rows = refs(storage.path()); + assert!( + rows.contains(&("worker-s".into(), "intent".into())), + "{rows:?}" + ); + assert!( + rows.contains(&("worker-a".into(), "published".into())), + "{rows:?}" + ); + assert!( + !rows.iter().any(|(worker, _)| worker == "local-2"), + "{rows:?}" + ); + // The stopped owner dies: its intent is orphaned, then retired, while + // the published references keep the base. + signal(&stopped, libc::SIGCONT); + kill(&mut stopped); + sweep_family(storage.path(), FAMILY, None).unwrap(); + assert!(refs(storage.path()).contains(&("worker-s".into(), "orphaned".into()))); + sweep_family(storage.path(), FAMILY, None).unwrap(); + assert!(!refs(storage.path()) + .iter() + .any(|(worker, _)| worker == "worker-s")); + kill(&mut a); + drop(held); + for _ in 0..2 { + let report = sweep_family(storage.path(), FAMILY, None).unwrap(); + assert!(report.reclaimed_bases.is_empty(), "{report:?}"); + } + let reader = open_foreign_v3(storage.path(), FAMILY, "worker-a", &producers()) + .unwrap() + .unwrap(); + assert!(reader.entry(&rel("src/lib.rs")).is_some()); + assert!(!storage.path().join("views/v3").exists()); +} + +/// Kill a builder/binder/sweeper at every base-path boundary. Afterwards no +/// published reference is dangling, a dead claim is taken over with +/// attempt + 1, dead reservations are released, and nothing appears under +/// `/views/v3`. +#[test] +fn a_process_killed_at_any_base_path_boundary_leaves_references_readable() { + v3::set_free_space_for_test(u64::MAX, u64::MAX / 2); + for step in DurabilityStep::BASE_PATH { + let storage = tempdir().unwrap(); + let control = tempdir().unwrap(); + let ready = control.path().join("ready"); + let mut child = spawn_child( + "base-path", + storage.path(), + &ready, + &[("BASE_REF_STEP", step.as_str())], + ); + wait_for_file(&mut child, &ready); + assert_eq!(fs::read_to_string(&ready).unwrap(), step.as_str()); + kill(&mut child); + // Recovery: a fresh process sweeps and, if the base is unfinished, + // takes over the dead claim. + for _ in 0..3 { + sweep_family(storage.path(), FAMILY, None) + .unwrap_or_else(|error| panic!("{step:?}: {error}")); + } + let lock = v3::ReservationLock::acquire(storage.path()).unwrap(); + let registry = v3::RegistryWriter::open_existing(storage.path(), FAMILY, &lock).unwrap(); + assert_eq!(registry.accounting().unwrap().reserved_bytes, 0, "{step:?}"); + drop(registry); + drop(lock); + if registered(storage.path(), "worker") { + if let Some(reader) = open_foreign_v3(storage.path(), FAMILY, "worker", &producers()) + .unwrap_or_else(|error| panic!("{step:?}: dangling reference: {error}")) + { + assert!(reader.entry(&rel("src/lib.rs")).is_some(), "{step:?}"); + } + } + if step == DurabilityStep::BaseClaimed { + let paths = checkout(&storage.path().join("roots/main"), FILES); + let scope = { + let BaseBuild::Published { claim, .. } = build_base( + BaseBuildInput { + storage: storage.path(), + family: FAMILY, + root: &storage.path().join("roots/main"), + paths: &paths, + policy: policy(), + semantic_producer: None, + callgraph_producer: "graph".into(), + ignore_fingerprint: None, + history: None, + budget: DiskBudget::default(), + }, + None, + ) + .unwrap() else { + panic!("{step:?}: the dead claim was not taken over"); + }; + assert_eq!(claim.attempt(), 2, "{step:?}"); + claim.scope().to_owned() + }; + assert!(matches!( + claim_base(storage.path(), FAMILY, &scope, None).unwrap(), + ClaimOutcome::Busy { attempt: 2, .. } + )); + } + assert!(!storage.path().join("views/v3").exists(), "{step:?}"); + } +} + +/// Delete main, kill main's daemon, run GC in another process, restart the +/// worker: it serves from its base with correct answers. +#[test] +fn a_worker_restarted_after_main_is_deleted_and_collected_serves_from_its_base() { + v3::set_free_space_for_test(u64::MAX, u64::MAX / 2); + let storage = tempdir().unwrap(); + let control = tempdir().unwrap(); + let main_root = storage.path().join("roots/main"); + let ready = control.path().join("main"); + let mut main = spawn_child( + "build-main-and-park", + storage.path(), + &ready, + &[("BASE_REF_ROOT", main_root.to_str().unwrap())], + ); + wait_for_file(&mut main, &ready); + // The base main's daemon built is the family's only base member. + let scope = with_base_barrier(storage.path(), FAMILY, |tx| { + Ok( + tx.query_row("SELECT scope FROM members WHERE kind='base'", [], |r| { + r.get::<_, String>(0) + })?, + ) + }) + .unwrap(); + // The worker binds and its daemon exits. + let ready_worker = control.path().join("worker"); + let mut worker = spawn_child( + "bind-and-park", + storage.path(), + &ready_worker, + &[("BASE_REF_WORKER", "worker"), ("BASE_REF_BASE", &scope)], + ); + wait_for_file(&mut worker, &ready_worker); + kill(&mut worker); + // Delete main and kill its daemon, then collect in a foreign process. + fs::remove_dir_all(&main_root).unwrap(); + kill(&mut main); + let ready_gc = control.path().join("gc"); + let mut gc = spawn_child("foreign-gc", storage.path(), &ready_gc, &[]); + wait_for_file(&mut gc, &ready_gc); + assert!(gc.wait().unwrap().success()); + // Restart the worker: its published generation composes over the base. + assert!(registered(storage.path(), &scope)); + let reader = open_foreign_v3(storage.path(), FAMILY, "worker", &producers()) + .unwrap() + .unwrap(); + for (name, bytes) in FILES { + let entry = reader.entry(&rel(name)).unwrap(); + assert_eq!(entry.content(), Some(ContentHash::of(bytes)), "{name}"); + assert!(matches!( + entry.plane_state(FamilyPlane::Trigram), + Some(PlaneState::Ready { .. }) + )); + } + assert!(reader.entry(&rel("src/worker_only.rs")).is_some()); + assert!(!storage.path().join("views/v3").exists()); +} From f07524a42e073f34ac60b3befa33629b8baf5700 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Mon, 26 Oct 2026 16:37:02 +0100 Subject: [PATCH 6/8] mason: register the base-reference schema and Windows probe in the v3 format doc Co-authored-by: alfonso-aft <289616620+alfonso-aft@users.noreply.github.com> --- docs/design/views-v3-format.md | 66 ++++++++++++++++++++++++++++++++-- 1 file changed, 63 insertions(+), 3 deletions(-) diff --git a/docs/design/views-v3-format.md b/docs/design/views-v3-format.md index 97eb07ad19..63af60f7e4 100644 --- a/docs/design/views-v3-format.md +++ b/docs/design/views-v3-format.md @@ -419,9 +419,15 @@ post-checkpoint empty file. `reader-floor.json` untouched; at the re-check it may leave `blobs/v3/`, the lock file and an empty registry, but no reservation row or plane store. - Assumptions: different storage roots on one device are not coordinated. On - non-unix the probe reports unsupported, so every reservation is refused as - `device_free_space_floor` (unverified on a native non-unix run). -- Wiring these budgets into `DiskBudget` (`views/eviction.rs`) is slice 4's job. + Windows the probe is `GetDiskFreeSpaceExW` (caller-available bytes and + total bytes of the nearest existing ancestor); other non-unix targets + report unsupported, so every reservation there is refused as + `device_free_space_floor`. The Windows probe is compile-checked only so + far; a native Windows run is still owed. +- `DiskBudget::admit_v3`/`extend_v3` (`views/eviction.rs`) admit v3 + reservations against the family hard limit (refused as + `storage_gap:v3_family_hard_limit`, never a legacy fallback) and flag + usage above the 2 GiB target so the caller runs the family's v3 sweep. ## Retention collector @@ -522,6 +528,60 @@ of bytes and `st_mode` over `blobs/v3/` must be unchanged, and no v3 member may appear in a v2 registry. The tests are `#[ignore]`d (they take seven and thirteen minutes) and need `AFT_V0_60_0_BINARY`. +## Base-reference protocol (registry 2/2) + +Source: `views/base_refs.rs`, `views/base_sweep.rs`, `views/base_builder.rs`. +A 1/1 registry is upgraded in one barrier transaction, only by a writer that +first raised the `views_v3_registry` floor to 2 +(`blob_store::v3::open_base_refs_registry`). Registries created by the copy +path stay 1/1 until then. The added schema text is: + +```sql +CREATE TABLE base_refs (worker_scope TEXT NOT NULL, base_scope TEXT NOT NULL, nonce TEXT NOT NULL, + membership_digest TEXT NOT NULL, worker_generation TEXT, + state TEXT NOT NULL CHECK(state IN ('intent','orphaned','published')), + owner_pid INTEGER NOT NULL, owner_start INTEGER NOT NULL, updated_epoch INTEGER NOT NULL, + PRIMARY KEY(worker_scope, base_scope, nonce)); +CREATE INDEX base_refs_by_base ON base_refs(base_scope); +CREATE TABLE base_builds (base_scope TEXT NOT NULL PRIMARY KEY, + owner_pid INTEGER NOT NULL, owner_start INTEGER NOT NULL, owner_token TEXT NOT NULL, + attempt INTEGER NOT NULL CHECK(attempt>=1), + state TEXT NOT NULL CHECK(state IN ('building','serving')), heartbeat_at_ms INTEGER NOT NULL); +CREATE TABLE strict_rebuild (worker_scope TEXT NOT NULL, worker_generation TEXT NOT NULL, + reason TEXT NOT NULL, recorded_epoch INTEGER NOT NULL, PRIMARY KEY(worker_scope, worker_generation)); +CREATE TABLE base_sweeps (base_scope TEXT NOT NULL PRIMARY KEY, + unreferenced_sweeps INTEGER NOT NULL DEFAULT 0); +CREATE TABLE sweep_state (singleton INTEGER PRIMARY KEY CHECK(singleton=1), + gc_epoch INTEGER NOT NULL, lease_pid INTEGER, lease_start INTEGER, reconcile_cursor TEXT, + dangling_base_edges INTEGER NOT NULL DEFAULT 0, live_base_cap_hits INTEGER NOT NULL DEFAULT 0); +``` + +- **Identity.** The membership digest is BLAKE3 over the length-prefixed + tag `aft/views-v3/base-membership/1`, the entry count, then per path (in + path order) the path bytes, an entry-kind byte, and mode, content hash, + size and resolution-input flag (regular files), target bytes (symlinks), + OID (gitlinks) or name (synthetic entries). Readiness is excluded. A + manifest with `head_tree`, any `git_oid`, a `base` header, tombstones or + overrides is not a base. The identity digest (tag + `aft/views-v3/base-identity/1`) covers family, all producers, the ignore + fingerprint, the platform family, `head_tree:none` and the membership + digest. The base scope is `base-m1-`. +- **Authentication.** Every consumer resolves a base by pin-then-verify and + recomputes both digests from the pinned manifest; a `BaseHeader` is + checked against that result, never trusted. `bound_generation` is + diagnostic. +- **Markers.** A `BaseRefMarker` is a read marker in `B/readers//` whose + file also carries a per-acquisition `nonce` (omitted, and so unchanged, + for ordinary readers). +- **Sweep.** Runs under the reservation lock; marks current, read-marked + and pinned generations; reconciles at most 256 rows / 25 ms per pass from + a wrap-around position kept in `sweep_state.reconcile_cursor`; reclaims a + base after two consecutive unreferenced sweeps. The plane-row sweep keeps + its resume position at `retention/cursors/v3-plane-rows/.json` + (format 1) when the retention collector runs it. +- **Segments.** v3 generations carry no trigram segment yet: v3 has no + segment rows or segment collection, so readers build from payloads. + ## Deferred implementation register This register assigns the remaining formats **before their owners write them**. From 1fae1b261ba6aa4abf9d5711b979a955e8029a8f Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Mon, 26 Oct 2026 16:55:01 +0100 Subject: [PATCH 7/8] mason: clarify base-builder comments and drop a timing dependency from the republication row test The republication test now runs its sweep with a 60 s reconciliation time budget: under a loaded test runner the ruled 25 ms budget could end the pass before its final empty batch, which marks the pass incomplete without changing the rows the test asserts. Co-authored-by: alfonso-aft <289616620+alfonso-aft@users.noreply.github.com> --- crates/aft/src/views/base_builder.rs | 11 ++++++----- crates/aft/src/views/base_builder_tests.rs | 3 ++- crates/aft/src/views/base_sweep_tests.rs | 9 ++++++++- docs/design/views-v3-format.md | 3 ++- 4 files changed, 18 insertions(+), 8 deletions(-) diff --git a/crates/aft/src/views/base_builder.rs b/crates/aft/src/views/base_builder.rs index f4808a0473..d8387750e5 100644 --- a/crates/aft/src/views/base_builder.rs +++ b/crates/aft/src/views/base_builder.rs @@ -2,8 +2,8 @@ //! (with the build claim) single-flight construction. //! //! A base is published once its membership and lexical plane are complete. -//! From then on its membership digest, identity and trigram segment are -//! fixed; its pointer moves only to readiness successors, which may turn +//! From then on its membership digest, identity and trigram segment (none, +//! for v3 generations today) are fixed; its pointer moves only to readiness successors, which may turn //! `Pending` into `Ready` or `Failed`, `Failed` into `Ready`, and nothing //! else. A `Ready` key never changes. @@ -177,11 +177,12 @@ pub const CLAIM_STALE: std::time::Duration = std::time::Duration::from_secs(15); /// Ruled cap of live bases per family, counting claimed or registered but /// unpublished bases. pub const LIVE_BASES_PER_FAMILY: i64 = 64; -/// Initial build reservation cap and extension step (pre-slice-4 ruling). +/// Initial build reservation cap and extension step, as fixed by the +/// maintainers' pre-slice-4 ruling (docs/design/views-slice4-opening.md). pub const INITIAL_RESERVATION_CAP: u64 = 1 << 30; pub const RESERVATION_STEP: u64 = 256 << 20; -/// The ruled initial reservation for a base of `files` members and +/// The initial reservation (docs/design/views-slice4-opening.md) for a base of `files` members and /// `source_bytes` bytes: `ceil_MiB(64 MiB + max(64 KiB × N, 2 × B, /// 1.25 × P × max(N/Np, B/Bp)))`, capped at 1 GiB. `history` is a comparable /// base's `(peak allocated build bytes P, Np, Bp)`; it is ignored when absent @@ -533,7 +534,7 @@ pub enum BaseBuild { /// Builds a membership-keyed base from strict, stability-checked reads of /// `input.paths`, keyed by the tagged membership-and-content digest /// (`head_tree = None`, no `git_oid`). Single-flight through the build claim; -/// admission through the v3 disk budget with the ruled initial reservation. +/// admission through the v3 disk budget, starting with [`initial_reservation`]. /// The first publication happens once membership and the lexical plane are /// complete; graph and semantic entries stay pending for readiness successors. pub fn build_base( diff --git a/crates/aft/src/views/base_builder_tests.rs b/crates/aft/src/views/base_builder_tests.rs index bf5fc87758..b08bf23a65 100644 --- a/crates/aft/src/views/base_builder_tests.rs +++ b/crates/aft/src/views/base_builder_tests.rs @@ -24,7 +24,8 @@ fn dead_claim(storage: &Path, scope: &str, token: &str, attempt: i64) { #[test] fn the_initial_reservation_follows_the_ruled_formula_and_cap() { - // The ruling's AFT fixture: 5,377 files / 87,031,923 bytes, no history. + // The opening note's AFT fixture: 5,377 files / 87,031,923 bytes, no + // history, which it says reserves 401 MiB. assert_eq!(initial_reservation(5_377, 87_031_923, None), 401 * MIB); // A 200,000-file repository is capped at 1 GiB, not refused. assert_eq!( diff --git a/crates/aft/src/views/base_sweep_tests.rs b/crates/aft/src/views/base_sweep_tests.rs index 5c2773d8cb..bcc6486d1b 100644 --- a/crates/aft/src/views/base_sweep_tests.rs +++ b/crates/aft/src/views/base_sweep_tests.rs @@ -301,7 +301,14 @@ fn repeated_same_base_republications_do_not_grow_the_rows() { &v3::view_dir(storage.path(), FAMILY, "worker-a").unwrap(), ); assert_eq!(refs(storage.path()).len(), 6); - let report = sweep(storage.path()); + // Only the row cap may cut this pass; a slow machine must not. + let lock = ReservationLock::acquire(storage.path()).unwrap(); + let bounds = SweepBounds { + reconcile_time: Duration::from_secs(60), + ..SweepBounds::default() + }; + let report = sweep_family_locked(storage.path(), FAMILY, &lock, None, bounds, None).unwrap(); + drop(lock); assert!(report.reconcile_complete, "{report:?}"); let rows = refs(storage.path()); assert_eq!(rows.len(), 1, "{rows:?}"); diff --git a/docs/design/views-v3-format.md b/docs/design/views-v3-format.md index 63af60f7e4..75d67c212a 100644 --- a/docs/design/views-v3-format.md +++ b/docs/design/views-v3-format.md @@ -570,7 +570,8 @@ CREATE TABLE sweep_state (singleton INTEGER PRIMARY KEY CHECK(singleton=1), recomputes both digests from the pinned manifest; a `BaseHeader` is checked against that result, never trusted. `bound_generation` is diagnostic. -- **Markers.** A `BaseRefMarker` is a read marker in `B/readers//` whose +- **Markers.** A `BaseRefMarker` is a read marker in `M/readers//`, where + `M` is the base's member directory and `G` its current generation, whose file also carries a per-acquisition `nonce` (omitted, and so unchanged, for ordinary readers). - **Sweep.** Runs under the reservation lock; marks current, read-marked From 8fd657c19d59ab82bbff14eed952c895b1c9f024 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Mon, 26 Oct 2026 17:06:41 +0100 Subject: [PATCH 8/8] search-quality descriptor for views-c-base-references Co-authored-by: alfonso-aft <289616620+alfonso-aft@users.noreply.github.com> --- .../slice-descriptors/land-views-c-base-references.json | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 benchmarks/aft-search/slice-descriptors/land-views-c-base-references.json diff --git a/benchmarks/aft-search/slice-descriptors/land-views-c-base-references.json b/benchmarks/aft-search/slice-descriptors/land-views-c-base-references.json new file mode 100644 index 0000000000..ebd574711e --- /dev/null +++ b/benchmarks/aft-search/slice-descriptors/land-views-c-base-references.json @@ -0,0 +1,8 @@ +{ + "targeted_mechanism": "none", + "kind": "harness", + "slice_class": "non_ranking", + "fixtures": [ + "harness-goldens" + ] +}