From 533abdcda541f55b34e0ea8d3249c9b5c5df83a8 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 01/12] 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 9f9f93284a..3444fc5aef 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 1b73c5b7b4a851a6954d9c8029f02d783da10008 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 02/12] 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 d5349d8cd3..0876d923f1 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()), @@ -4637,7 +4637,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, @@ -4864,10 +4864,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()))?; @@ -5180,6 +5186,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. @@ -12755,3 +12799,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 94d9d1b51761e8798d281e1ff9a54feaada33bcf 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 03/12] 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 6e56f43fd3cc0c72dd0b1d1c1b2fd6dfca5d82b0 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 04/12] 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 bdfdb20fb2b2ed5dc6284ff999d8180db32764d6 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 05/12] 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 660c1b8782..dc52d55c5a 100644 --- a/crates/aft/tests/integration/main.rs +++ b/crates/aft/tests/integration/main.rs @@ -194,6 +194,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 7cd49f1015e267bc60aa423386a38986c401f903 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 06/12] 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 ef2736a46274cf1403fb894ba55103e245150261 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 07/12] 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 142f103c0fc7665fc5d91e2cc54e577a0a068662 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 08/12] 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" + ] +} From f3b47678d63c49051b02bd392d3a53a5f8c9caa9 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Mon, 26 Oct 2026 17:41:48 +0100 Subject: [PATCH 09/12] mason: v3 storage route and v3 storage for the trigram, semantic and callgraph planes Storage route: a generation opened or published in the v3 namespace carries a V3Route (storage, family, member scope); ViewAccess::route(generation) returns StorageRoute::V2 or ::V3, and ViewRegistration::v3_route names a checkout's v3 member. Routing is per generation rather than per access because one checkout reads both v2 and v3 generations during the switch. resolve_base, publish_worker and open_foreign_v3 set the route. - v3 store API: PlaneReader::contains, Publication::put_or_touch and Publication::touch (missing keys reported), PayloadSource for PlaneReader. - Trigram: a v3 generation has no segment file. The adapter assembles the base generation's segment in memory from the base's stored payloads (cached per base generation) and overlays the worker's changed entries from the v3 store. No new on-disk rows. materialize_v3_observed writes a worker delta's payloads from attachments or verified reads. - Semantic: SemanticStore (v2 FamilyStore or v3 route) carries put_or_touch/touch/get/reader for fills; v3 writes go through v3::publish, fills of a v3 generation protect keys in a v3 live pin (LivePin::create_v3), and verified-run markers are skipped on v3. The arena reads runs through a RunSource trait (v2 reader or v3 PlaneReader). SemanticPlane::materialize_v3 applies the existing readiness rules with v3 presence checks and touches. - Callgraph: CompositePlane::materialize_v3/finish_generation_v3. The bridge puts delta blobs into v3 and builds the composed generation's derived DB in the worker's v3 member directory once the projection is complete; CallgraphPlane::open_generation reads a v3 generation's derived DB from its member directory. Base successors build derived--a-.sqlite named by derived-.ref. - publish_worker_with runs a step with the final generation name before the CAS (for derived renames). Co-authored-by: alfonso-aft <289616620+alfonso-aft@users.noreply.github.com> (cherry picked from commit 50757b3b99fc5881775c636377d92a316b14266b) --- crates/aft/src/blob_store/v3.rs | 43 ++ crates/aft/src/views/base_builder.rs | 69 +++- crates/aft/src/views/base_refs.rs | 68 +++- crates/aft/src/views/callgraph.rs | 70 +++- crates/aft/src/views/contracts.rs | 20 + crates/aft/src/views/first_load.rs | 197 ++++++++++ crates/aft/src/views/mod.rs | 2 + crates/aft/src/views/registry.rs | 10 + crates/aft/src/views/segment_store.rs | 7 + crates/aft/src/views/semantic.rs | 518 +++++++++++++++++++------ crates/aft/src/views/semantic_arena.rs | 47 ++- crates/aft/src/views/snapshot.rs | 32 ++ crates/aft/src/views/trigram.rs | 149 +++++++ crates/aft/src/views/v3_plane_tests.rs | 355 +++++++++++++++++ docs/design/views-v3-format.md | 14 +- 15 files changed, 1453 insertions(+), 148 deletions(-) create mode 100644 crates/aft/src/views/v3_plane_tests.rs diff --git a/crates/aft/src/blob_store/v3.rs b/crates/aft/src/blob_store/v3.rs index c153892f54..36913daef3 100644 --- a/crates/aft/src/blob_store/v3.rs +++ b/crates/aft/src/blob_store/v3.rs @@ -1552,6 +1552,40 @@ impl Publication<'_, '_> { pub fn put(&mut self, key: &FamilyKey, payload: &[u8], payload_schema: u32) -> Result<()> { self.put_row(key, payload, payload_schema).map(|_| ()) } + /// Inserts the row, or touches an equal existing one. `true` means the + /// row was inserted by this call. + pub fn put_or_touch( + &mut self, + key: &FamilyKey, + payload: &[u8], + payload_schema: u32, + ) -> Result { + self.put_row(key, payload, payload_schema) + } + /// Stamps the plane's current `gc_epoch` on each existing row of `keys` + /// and returns the keys that have no row. Never inserts, and never creates + /// a plane store. The caller protects every key (a pin or live pin) before + /// touching it, so a sweep either marks it or sees the new epoch. + pub fn touch(&mut self, keys: &[FamilyKey]) -> Result> { + let mut missing = Vec::new(); + for key in keys { + let Some(mut c) = self.plane_with(key.plane(), false)? else { + missing.push(*key); + continue; + }; + let tx = c.transaction_with_behavior(TransactionBehavior::Immediate)?; + let sql = if plane_layout(&tx)? == 1 { + "UPDATE blob_payloads SET ref_epoch=(SELECT gc_epoch FROM store_meta) WHERE full_key=?1" + } else { + "UPDATE blob_liveness SET ref_epoch=(SELECT gc_epoch FROM store_meta) WHERE blob_id=(SELECT id FROM blob_payloads WHERE full_key=?1)" + }; + if tx.execute(sql, [key.as_bytes().as_slice()])? == 0 { + missing.push(*key); + } + tx.commit()?; + } + Ok(missing) + } /// Inserts the row, or touches an equal existing one. `true` means inserted. fn put_row(&mut self, key: &FamilyKey, payload: &[u8], payload_schema: u32) -> Result { let mut c = self.plane(key.plane())?; @@ -1661,6 +1695,15 @@ impl PlaneReader { )?, }) } + /// True when the store holds a row for `key`. A presence probe through + /// the key index: the payload is not read. + pub fn contains(&self, key: &FamilyKey) -> Result { + Ok(self.database.connection()?.query_row( + "SELECT EXISTS(SELECT 1 FROM blob_payloads WHERE full_key=?1)", + [key.as_bytes().as_slice()], + |r| r.get(0), + )?) + } pub fn get(&self, key: &FamilyKey) -> Result>> { let row: Option<(Vec, Vec)> = self .database diff --git a/crates/aft/src/views/base_builder.rs b/crates/aft/src/views/base_builder.rs index d8387750e5..55276b2c29 100644 --- a/crates/aft/src/views/base_builder.rs +++ b/crates/aft/src/views/base_builder.rs @@ -129,6 +129,18 @@ pub fn publish_base( family: &str, manifest: &ManifestV2, observer: Option<&dyn DurabilityObserver>, +) -> Result { + publish_base_with(storage, family, manifest, observer, &mut |_, _| Ok(())) +} + +/// [`publish_base`] with a step that runs after the new manifest is durable +/// under its private name and before the pointer CAS. +fn publish_base_with( + storage: &Path, + family: &str, + manifest: &ManifestV2, + observer: Option<&dyn DurabilityObserver>, + before_cas: &mut dyn FnMut(&ViewStore, &str) -> Result<()>, ) -> Result { let identity = BaseIdentity::of(family, manifest)?; let (dir, registered) = with_base_barrier(storage, family, |tx| { @@ -148,6 +160,9 @@ pub fn publish_base( } let name = GenerationName::for_manifest(manifest)?; let prepared = store.prepare_v2(&name, current.as_deref(), manifest, observer)?; + // The private manifest is pinned by `prepared`; derived artifacts named + // after it are built before the pointer can name it. + before_cas(&store, &name.to_string())?; match store.commit_v2(prepared, observer)? { PublishV2::Published => { if current.is_some() { @@ -360,6 +375,50 @@ impl BuildClaim { self.attempt, self.token ) } + /// Builds the base's derived call graph for `generation` once every + /// callgraph member of `manifest` is ready, as + /// `derived--a-.sqlite` with a + /// `derived-.ref` naming it, so a takeover never reuses a + /// dead attempt's file. While any member is pending nothing is built. + fn build_derived( + &self, + store: &ViewStore, + generation: &str, + manifest: &ManifestV2, + ) -> Result<()> { + let composed = super::snapshot::ComposedGeneration::new(generation, manifest.clone(), None); + if super::callgraph::project_manifest(&composed).is_err() { + return Ok(()); + } + let owner = format!("{generation}-a{}-{}", self.attempt, self.token); + // `derived_name` is `derived-.sqlite`: the name the + // reference file below resolves to. + let database = store.view_dir().join(self.derived_name(generation)); + let reader = super::callgraph::V3BlobReader(v3::PlaneReader::open_existing( + &self.storage, + &self.family, + FamilyPlane::Callgraph, + )?); + super::callgraph::materialize(&database, &composed, &reader) + .map_err(|error| BaseRefError::Io(std::io::Error::other(error.reason)))?; + let (busy, frames, checkpointed) = + super::sqlite_full_sync_checkpoint(&database, "base_builder::build_derived")?; + if busy != 0 || (frames >= 0 && checkpointed != frames) { + return Err(BaseRefError::Io(std::io::Error::other( + "base derived graph checkpoint incomplete", + ))); + } + let reference = store.view_dir().join(format!("derived-{generation}.ref")); + let temporary = store + .view_dir() + .join(format!(".derived-{generation}.ref.tmp")); + std::fs::write(&temporary, owner.as_bytes())?; + std::fs::File::open(&temporary)?.sync_all()?; + std::fs::rename(&temporary, &reference)?; + super::sync_directory(store.view_dir())?; + Ok(()) + } + /// 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<()> { @@ -677,7 +736,15 @@ pub fn publish_successor( let mut next = base.manifest().clone(); drop(base); update(&mut next); - match publish_base(&claim.storage, &claim.family, &next, observer)? { + let mut build_graph = + |store: &ViewStore, generation: &str| claim.build_derived(store, generation, &next); + match publish_base_with( + &claim.storage, + &claim.family, + &next, + observer, + &mut build_graph, + )? { BasePublication::Published { generation, .. } => Ok(generation), BasePublication::Lost { current } => Err(BaseRefError::Unavailable(format!( "the base pointer moved to {current:?}" diff --git a/crates/aft/src/views/base_refs.rs b/crates/aft/src/views/base_refs.rs index 24341b43e2..d3dc104fe2 100644 --- a/crates/aft/src/views/base_refs.rs +++ b/crates/aft/src/views/base_refs.rs @@ -312,12 +312,24 @@ impl AuthenticatedBase { } /// 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)), - )) + pub fn into_generation(self, storage: &Path, family: &str) -> Arc { + let route = route(storage, family, &self.identity.scope); + Arc::new( + ComposedGeneration::new( + self.generation, + self.manifest, + Some(Residency::Marker(self.marker)), + ) + .with_v3_route(route), + ) + } +} + +pub(crate) fn route(storage: &Path, family: &str, scope: &str) -> super::snapshot::V3Route { + super::snapshot::V3Route { + storage: storage.to_path_buf(), + family: family.to_owned(), + scope: scope.to_owned(), } } @@ -667,6 +679,19 @@ thread_local! { pub fn publish_worker( request: WorkerPublication<'_>, observer: Option<&dyn DurabilityObserver>, +) -> Result { + publish_worker_with(request, observer, &mut |_| Ok(())) +} + +/// [`publish_worker`] with a step that runs once the final generation name is +/// known (after the delta is rebound to the authenticated base header and +/// its manifest is durable) and before the pointer CAS. Plane finishers +/// rename private derived files to that name here. An error aborts the job +/// like a failed verify. +pub fn publish_worker_with( + request: WorkerPublication<'_>, + observer: Option<&dyn DurabilityObserver>, + before_cas: &mut dyn FnMut(&str) -> Result<()>, ) -> Result { let WorkerPublication { storage, @@ -758,7 +783,7 @@ pub fn publish_worker( if let Err(error) = delta.set_base_header(header.clone()) { return Err(abort(error.into(), marker)); } - let base_generation = authenticated.into_generation(); + let base_generation = authenticated.into_generation(storage, family); let worker_store = match ViewStore::open_dir(worker_dir.clone()) { Ok(store) => store, Err(error) => return Err(abort(error.into(), marker)), @@ -772,6 +797,9 @@ pub fn publish_worker( Ok(prepared) => prepared, Err(error) => return Err(abort(error.into(), marker)), }; + if let Err(error) = before_cas(&generation) { + return Err(abort(error, 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) { @@ -784,7 +812,7 @@ pub fn publish_worker( base_generation, Some(Residency::Marker(worker_marker)), ) { - Ok(composed) => Arc::new(composed), + Ok(composed) => Arc::new(composed.with_v3_route(route(storage, family, worker_scope))), Err(error) => return Err(abort(error.into(), marker)), }; @@ -931,11 +959,10 @@ pub fn open_foreign_v3( 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)), - )))); + return Ok(Some(Arc::new( + ComposedGeneration::new(generation, manifest, Some(Residency::Marker(marker))) + .with_v3_route(route(storage, family, scope)), + ))); }; let base = match resolve_base(storage, family, &header.scope) .and_then(|base| base.check_header(&header).map(|()| base)) @@ -947,12 +974,15 @@ pub fn open_foreign_v3( } Err(error) => return Err(error), }; - return Ok(Some(Arc::new(ComposedGeneration::compose( - generation, - manifest, - base.into_generation(), - Some(Residency::Marker(marker)), - )?))); + return Ok(Some(Arc::new( + ComposedGeneration::compose( + generation, + manifest, + base.into_generation(storage, family), + Some(Residency::Marker(marker)), + )? + .with_v3_route(route(storage, family, scope)), + ))); } Err(last.unwrap_or_else(|| { BaseRefError::Unavailable(format!("{scope} moved during {RESOLVE_ATTEMPTS} attempts")) diff --git a/crates/aft/src/views/callgraph.rs b/crates/aft/src/views/callgraph.rs index b8c6f3f83a..80d067008d 100644 --- a/crates/aft/src/views/callgraph.rs +++ b/crates/aft/src/views/callgraph.rs @@ -90,6 +90,18 @@ impl ManifestBlobReader for BlobReader { } } +/// Reads callgraph blobs from the family's v3 callgraph plane store. +pub struct V3BlobReader(pub crate::blob_store::v3::PlaneReader); +impl ManifestBlobReader for V3BlobReader { + fn read_callgraph_blob(&self, key: &str) -> Result>, ManifestJoinError> { + let bytes = parse_hex32(key) + .ok_or_else(|| ManifestJoinError::InvalidBlob("invalid callgraph key".into()))?; + self.0 + .get(&FamilyKey::new(FamilyPlane::Callgraph, bytes)) + .map_err(|e| ManifestJoinError::InvalidBlob(e.to_string())) + } +} + /// Project membership and compatible ready keys, not checkout paths/configuration. /// Missing/failed callgraph members refuse construction rather than falsely ready. pub fn project_manifest( @@ -147,6 +159,38 @@ pub fn materialize( .map_err(error) } +/// The checkout root recorded for a v3 member, read without any lock. +fn v3_member_root(route: &super::snapshot::V3Route) -> Result { + use rusqlite::OptionalExtension; + let root: Option>> = + crate::blob_store::v3::LedgerReader::open_existing(&route.storage, &route.family) + .and_then(|ledger| { + ledger.read(|c| { + Ok(c.query_row( + "SELECT root FROM members WHERE scope=?1", + [&route.scope], + |r| r.get(0), + ) + .optional()?) + }) + }) + .map_err(error)?; + let bytes = root + .flatten() + .ok_or_else(|| error("v3 member root cannot be bound"))?; + #[cfg(unix)] + { + use std::os::unix::ffi::OsStrExt; + Ok(PathBuf::from(std::ffi::OsStr::from_bytes(&bytes))) + } + #[cfg(not(unix))] + { + String::from_utf8(bytes) + .map(PathBuf::from) + .map_err(|_| error("v3 member root is not UTF-8")) + } +} + /// Holding a reader keeps exactly its generation pinned, including after a swap. pub struct PinnedReader { pub store: Arc, @@ -201,12 +245,26 @@ impl PlaneAdapter for CallgraphPlane { generation: &Arc, ) -> Result<(), PlaneError> { queryable_manifest(generation)?; - let (root, dir): (PathBuf, PathBuf) = match access { - ViewAccess::Owner(view) => (view.root().to_path_buf(), view.view_dir().to_path_buf()), - ViewAccess::Reader { - registration, - scope, - } => { + let route = access.route(generation); + let (root, dir): (PathBuf, PathBuf) = match (access, route) { + // A v3 generation's derived graph lives in its v3 member + // directory; the checkout root binds rendering as on v2. + (ViewAccess::Owner(view), super::contracts::StorageRoute::V3(route)) => { + (view.root().to_path_buf(), route.member_dir()) + } + (ViewAccess::Reader { .. }, super::contracts::StorageRoute::V3(route)) => { + (v3_member_root(&route)?, route.member_dir()) + } + (ViewAccess::Owner(view), _) => { + (view.root().to_path_buf(), view.view_dir().to_path_buf()) + } + ( + ViewAccess::Reader { + registration, + scope, + }, + _, + ) => { let member = registration .members() .map_err(error)? diff --git a/crates/aft/src/views/contracts.rs b/crates/aft/src/views/contracts.rs index 2556c1aa71..edecaf0261 100644 --- a/crates/aft/src/views/contracts.rs +++ b/crates/aft/src/views/contracts.rs @@ -233,6 +233,26 @@ impl ViewAccess { } } +/// Where a generation's plane artifacts are read and written. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum StorageRoute { + /// The v2 family store and the registration's v2 view directory. + V2, + /// The v3 plane stores and the v3 member directory of the route's scope. + V3(super::snapshot::V3Route), +} + +impl ViewAccess { + /// The storage of `generation`: a generation opened or published in the + /// v3 namespace carries its route; every other generation is v2. + pub fn route(&self, generation: &ComposedGeneration) -> StorageRoute { + generation + .v3_route() + .cloned() + .map_or(StorageRoute::V2, StorageRoute::V3) + } +} + /// An error from an index plane or loader orchestration, carried into existing /// incomplete/named-gap reporting, never turned into an empty success. #[derive(Clone, Debug, Eq, PartialEq)] diff --git a/crates/aft/src/views/first_load.rs b/crates/aft/src/views/first_load.rs index c9d2bb12c8..7e69e011af 100644 --- a/crates/aft/src/views/first_load.rs +++ b/crates/aft/src/views/first_load.rs @@ -1257,6 +1257,41 @@ pub trait CompositePlane: Send + Sync { staging: &str, published: &str, ) -> Result<(), PlaneError>; + + /// Materializes this plane for a v3 worker generation whose `delta` + /// composes over `base`: rows go to the family's v3 plane stores and + /// derived files to `route.member_dir()` under `staging`. Every new key + /// is listed in `live`, the owner's v3 live pin, before it is put or + /// touched. Only the delta's entries are this checkout's work; inherited + /// base entries belong to the base's builder. + #[allow(clippy::too_many_arguments)] + fn materialize_v3( + &self, + owner: &super::registry::ViewRegistration, + route: &super::snapshot::V3Route, + staging: &str, + base: &Arc, + observed: &BTreeMap, + delta: &mut super::manifest_v2::ManifestV2, + live: &mut crate::pins::LivePin, + ) -> Result<(), PlaneError> { + let _ = (owner, route, staging, base, observed, delta, live); + Err(PlaneError { + plane: Some(self.plane()), + reason: "this plane has no v3 materialization".into(), + }) + } + + /// [`Self::finish_generation`] for a v3 member directory. + fn finish_generation_v3( + &self, + route: &super::snapshot::V3Route, + staging: &str, + published: &str, + ) -> Result<(), PlaneError> { + let _ = (route, staging, published); + Ok(()) + } } /// Supplies the same full membership as the configured lexical walker. Runtime @@ -3308,6 +3343,148 @@ impl CompositePlane for CallgraphBridge { super::sync_directory(owner.view_dir()).map_err(|e| error(e.into()))?; Ok(()) } + fn materialize_v3( + &self, + _owner: &super::registry::ViewRegistration, + route: &super::snapshot::V3Route, + staging: &str, + base: &Arc, + observed: &BTreeMap, + delta: &mut super::manifest_v2::ManifestV2, + live: &mut crate::pins::LivePin, + ) -> Result<(), PlaneError> { + let error = |reason: String| PlaneError { + plane: Some(FamilyPlane::Callgraph), + reason, + }; + let mut attachments = Vec::new(); + for (path, entry) in observed { + if entry.disk == super::snapshot::DiskState::Absent || !self.applies_to(path) { + continue; + } + // Only the delta's own entries; inherited base entries are the + // base's work, and their keys are the base's. + if delta.get(path).is_none() + || super::snapshot::DiskState::in_generation(delta, path) != entry.disk + { + continue; + } + let attachment = entry + .attachments + .get(&FamilyPlane::Callgraph) + .and_then(|value| value.downcast_ref::()) + .ok_or_else(|| error(format!("{path:?}: callgraph attachment missing")))?; + attachments.push((path.clone(), attachment.clone())); + } + live.protect(&attachments.iter().map(|(_, a)| a.key).collect::>()) + .map_err(|e| error(e.to_string()))?; + let mut payloads = Vec::new(); + for (path, attachment) in &attachments { + payloads.push(( + path.clone(), + attachment.key, + attachment + .blob + .to_bytes() + .map_err(|e| error(e.to_string()))?, + )); + } + if !payloads.is_empty() { + let request = payloads + .iter() + .map(|(_, _, bytes)| bytes.len() as u64) + .sum::() + .saturating_mul(2) + .saturating_add(1 << 20); + let schema = FamilyPlane::Callgraph.payload_schema(); + crate::blob_store::v3::publish(&route.storage, &route.family, request, None, |p| { + for (_, key, bytes) in &payloads { + p.put(key, bytes, schema)?; + } + Ok(()) + }) + .map_err(|e| error(e.to_string()))?; + } + for (path, key, _) in &payloads { + if let Some(super::manifest_v2::EntryV2::Regular { + planes, + resolution_input, + .. + }) = delta.get_mut(path) + { + planes.callgraph = Some(super::readiness::PlaneState::ready(key)); + *resolution_input = super::assembly::is_resolution_input(path.as_bytes()); + } + } + // The derived graph covers the composed generation. While the base's + // graph is still pending the projection refuses, and the worker's + // graph stays building until a later publication; nothing is copied. + let composed = ComposedGeneration::compose(staging, delta.clone(), base.clone(), None) + .map_err(|e| error(e.to_string()))?; + if super::callgraph::project_manifest(&composed).is_err() { + return Ok(()); + } + let reader = super::callgraph::V3BlobReader( + crate::blob_store::v3::PlaneReader::open_existing( + &route.storage, + &route.family, + FamilyPlane::Callgraph, + ) + .map_err(|e| error(e.to_string()))?, + ); + let dir = route.member_dir(); + // Open (and so initialize and recover) the member's view store before + // writing a private derived file into it: first-open recovery removes + // derived files no generation owns. The caller protects `staging` with + // an assembly pin, as on the v2 path. + super::ViewStore::open_dir(dir.clone()).map_err(|e| error(e.to_string()))?; + let database = + super::resolve_derived_path(&dir, staging).map_err(|e| error(e.to_string()))?; + super::callgraph::materialize(&database, &composed, &reader) + } + fn finish_generation_v3( + &self, + route: &super::snapshot::V3Route, + staging: &str, + published: &str, + ) -> Result<(), PlaneError> { + let error = |e: super::ViewError| PlaneError { + plane: Some(FamilyPlane::Callgraph), + reason: e.to_string(), + }; + let dir = route.member_dir(); + let staging_path = super::resolve_derived_path(&dir, staging).map_err(error)?; + if !staging_path.exists() { + // The graph was not built for this generation (base graph pending). + return Ok(()); + } + let (busy, frames, checkpointed) = super::sqlite_full_sync_checkpoint( + &staging_path, + "first_load::CallgraphBridge::finish_generation_v3", + ) + .map_err(error)?; + if busy != 0 || (frames >= 0 && checkpointed != frames) { + return Err(PlaneError { + plane: Some(FamilyPlane::Callgraph), + reason: "private callgraph checkpoint incomplete".into(), + }); + } + let published_path = super::resolve_derived_path(&dir, published).map_err(error)?; + if staging_path != published_path { + if crate::db::file_identity::open_connections(&staging_path) != 0 + || published_path.exists() + { + return Err(PlaneError { + plane: Some(FamilyPlane::Callgraph), + reason: "private callgraph install would replace a live database".into(), + }); + } + crate::db::file_identity::guard_replacement(&staging_path, "v3 derived rename"); + std::fs::rename(&staging_path, &published_path).map_err(|e| error(e.into()))?; + } + super::sync_directory(&dir).map_err(|e| error(e.into()))?; + Ok(()) + } } #[cfg(test)] @@ -3893,6 +4070,26 @@ impl CompositePlane for TrigramBridge { ) -> Result<(), PlaneError> { Ok(()) } + fn materialize_v3( + &self, + owner: &super::registry::ViewRegistration, + route: &super::snapshot::V3Route, + _: &str, + _: &Arc, + observed: &BTreeMap, + delta: &mut super::manifest_v2::ManifestV2, + live: &mut crate::pins::LivePin, + ) -> Result<(), PlaneError> { + super::trigram::materialize_v3_observed( + &route.storage, + &route.family, + owner.root(), + delta, + observed, + live, + self.policy, + ) + } } impl CheckoutDriver { diff --git a/crates/aft/src/views/mod.rs b/crates/aft/src/views/mod.rs index ac5b1d59ee..4e5e4bfcc3 100644 --- a/crates/aft/src/views/mod.rs +++ b/crates/aft/src/views/mod.rs @@ -68,6 +68,8 @@ pub mod trigram; mod dispatch_parity_probe; #[cfg(test)] mod per_checkout_core_tests; +#[cfg(test)] +mod v3_plane_tests; use std::collections::{BTreeMap, BTreeSet, HashMap}; use std::fmt; diff --git a/crates/aft/src/views/registry.rs b/crates/aft/src/views/registry.rs index 5d4fcf4c5d..e0f0dcd08a 100644 --- a/crates/aft/src/views/registry.rs +++ b/crates/aft/src/views/registry.rs @@ -1073,6 +1073,16 @@ impl ViewRegistration { } /// The view's own pointer and manifests in `views/v2/`. + /// This checkout's member in the v3 namespace: the same family and scope + /// under `blobs/v3//views/`. + pub fn v3_route(&self) -> super::snapshot::V3Route { + super::snapshot::V3Route { + storage: self.registry().storage().to_path_buf(), + family: self.family().to_owned(), + scope: self.scope().to_owned(), + } + } + pub fn view_store(&self) -> RegistryResult { let view = super::ViewStore::open_dir(self.view_dir.clone())? .with_sweep_registry(self.registry.clone()); diff --git a/crates/aft/src/views/segment_store.rs b/crates/aft/src/views/segment_store.rs index 923eabca83..0ee9b6eee4 100644 --- a/crates/aft/src/views/segment_store.rs +++ b/crates/aft/src/views/segment_store.rs @@ -353,6 +353,13 @@ impl PayloadSource for FamilyStoreReader { } } +impl PayloadSource for crate::blob_store::v3::PlaneReader { + fn payload(&self, key: &FamilyKey) -> Result>, StoreError> { + self.get(key) + .map_err(|error| StoreError::Io(std::io::Error::other(error.to_string()))) + } +} + /// Builds a segment from stored payloads only; no checkout bytes are read. pub fn build_from_blobs( source: &dyn PayloadSource, diff --git a/crates/aft/src/views/semantic.rs b/crates/aft/src/views/semantic.rs index 09812c236c..e12025389d 100644 --- a/crates/aft/src/views/semantic.rs +++ b/crates/aft/src/views/semantic.rs @@ -33,9 +33,7 @@ use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex, MutexGuard}; use std::time::{Duration, Instant}; -use crate::blob_store::v2::{ - ContentHash, FamilyKey, FamilyPlane, FamilyStore, FamilyStoreReader, PutOrTouch, -}; +use crate::blob_store::v2::{ContentHash, FamilyKey, FamilyPlane, FamilyStore, PutOrTouch}; use crate::blob_store::{FullKey, SemanticKey, SEMANTIC_PRODUCER_VERSION}; use crate::refresh::{FailureClass, FailureTracker, PreparedWork}; use crate::semantic_index::{ @@ -339,8 +337,184 @@ fn decode_chunk_vector(payload: &[u8], identity: &str, text: &str) -> Option Result>, String> { + Ok(None) + } + fn markers_at_deletion_epoch( + &self, + _: &mut dyn FnMut(&crate::blob_store::v2::DeletionEpoch) -> Vec, + ) -> Option<(crate::blob_store::v2::DeletionEpoch, HashSet)> { + None + } +} + +fn v3_store_error(error: crate::blob_store::v3::Error) -> crate::blob_store::v2::StoreError { + crate::blob_store::v2::StoreError::Io(std::io::Error::other(error.to_string())) +} + +impl SemanticStore { + fn for_generation( + owner: &ViewRegistration, + generation: &ComposedGeneration, + ) -> Result { + match generation.v3_route() { + Some(route) => Ok(Self::V3(route.clone())), + None => owner + .open_store(FamilyPlane::Semantic) + .map(Self::V2) + .map_err(|error| plane_error(error.to_string())), + } + } + + fn is_v3(&self) -> bool { + matches!(self, Self::V3(_)) + } + + fn v3_reader( + route: &super::snapshot::V3Route, + ) -> Result, crate::blob_store::v2::StoreError> { + let path = + crate::blob_store::v3::plane_path(&route.storage, &route.family, FamilyPlane::Semantic) + .map_err(v3_store_error)?; + if !path.exists() { + return Ok(None); + } + crate::blob_store::v3::PlaneReader::open_existing( + &route.storage, + &route.family, + FamilyPlane::Semantic, + ) + .map(Some) + .map_err(v3_store_error) + } + + fn get(&self, key: &FamilyKey) -> Result>, crate::blob_store::v2::StoreError> { + match self { + Self::V2(store) => store.get(key), + Self::V3(route) => match Self::v3_reader(route)? { + Some(reader) => reader.get(key).map_err(v3_store_error), + None => Ok(None), + }, + } + } + + fn reader(&self) -> Box { + match self { + Self::V2(store) => Box::new(store.reader()), + Self::V3(route) => match Self::v3_reader(route) { + Ok(Some(reader)) => Box::new(reader), + _ => Box::new(NoRuns), + }, + } + } + + fn put_or_touch( + &self, + key: &FamilyKey, + payload: &[u8], + ) -> Result { + match self { + Self::V2(store) => store.put_or_touch(key, payload), + Self::V3(route) => { + let request = (payload.len() as u64) + .saturating_mul(2) + .saturating_add(1 << 20); + let inserted = crate::blob_store::v3::publish( + &route.storage, + &route.family, + request, + None, + |publication| { + publication.put_or_touch( + key, + payload, + FamilyPlane::Semantic.payload_schema(), + ) + }, + ) + .map_err(|error| match error { + crate::blob_store::v3::Error::Refused("payload_mismatch") => { + crate::blob_store::v2::StoreError::ConflictingPayload(*key) + } + other => v3_store_error(other), + })?; + Ok(if inserted { + PutOrTouch::Inserted { ref_epoch: 0 } + } else { + PutOrTouch::Reused { ref_epoch: 0 } + }) + } + } + } + + /// Verified-run markers are a v2 deletion-epoch optimization; v3 stores + /// none, which only costs later loads their chunk-row reads. + fn put_at_deletion_epoch( + &self, + build: impl FnOnce(&crate::blob_store::v2::DeletionEpoch) -> Option<(FamilyKey, Vec)>, + ) -> Result, crate::blob_store::v2::StoreError> { + match self { + Self::V2(store) => store.put_at_deletion_epoch(build), + Self::V3(_) => Ok(None), + } + } + + fn deletion_epoch( + &self, + ) -> Result, crate::blob_store::v2::StoreError> + { + match self { + Self::V2(store) => store.deletion_epoch(), + Self::V3(_) => Ok(None), + } + } + + fn touch( + &self, + keys: &[FamilyKey], + ) -> Result { + match self { + Self::V2(store) => store.touch(keys), + Self::V3(route) => { + if Self::v3_reader(route)?.is_none() { + return Ok(crate::blob_store::v2::TouchReport { + missing: keys.to_vec(), + ..Default::default() + }); + } + let request = (keys.len() as u64).saturating_mul(64 * 1024).max(1 << 20); + let missing = crate::blob_store::v3::publish( + &route.storage, + &route.family, + request, + None, + |publication| publication.touch(keys), + ) + .map_err(v3_store_error)?; + Ok(crate::blob_store::v2::TouchReport { + touched: keys.len() - missing.len(), + missing, + ref_epoch: 0, + }) + } + } + } +} + fn load_chunk( - store: &FamilyStore, + store: &SemanticStore, arena: &super::semantic_arena::SemanticArena, key: &FamilyKey, identity: &str, @@ -506,6 +680,10 @@ struct ViewState { failed: BTreeMap<(RelPath, ContentHash), String>, tracker: FailureTracker, live: Option>>, + /// The live pin in the v3 member directory, for fills of a generation + /// published in the v3 namespace. Its keys are kept until the process + /// ends: post-fold trimming covers only the v2 pin so far. + live_v3: Option>>, active_fills: usize, cache: Option<(CacheKey, Arc)>, } @@ -738,14 +916,122 @@ impl SemanticPlane { .cloned() } - fn store_reader(access: &ViewAccess) -> Result, PlaneError> { + /// Records semantic readiness for `manifest`'s entries; see + /// [`super::first_load::CompositePlane::materialize`]. + fn materialize_with( + &self, + owner: &ViewRegistration, + observed: &BTreeMap, + manifest: &mut ManifestV2, + live: &mut crate::pins::LivePin, + store: &SemanticStore, + ) -> Result<(), PlaneError> { + let id = self.producer.id(); + if manifest.header().producers.semantic.as_deref() != Some(id.as_str()) { + return Err(plane_error("semantic producer mismatch")); + } + let view = self.view(&ViewAccess::Owner(owner.clone())); + let state = lock(&view); + let mut candidates = Vec::new(); + for (path, entry) in manifest.entries_mut() { + let EntryV2::Regular { + content, planes, .. + } = entry + else { + continue; + }; + if !applies_to(path) { + planes.semantic = None; + continue; + } + // A completed small-cap repair replaces the old Ready file-run key. + let repaired = self.producer.caps.total_chars < EmbedTextCaps::default().total_chars + && state + .fill + .get(path, content, FamilyPlane::Semantic) + .is_some(); + if matches!(planes.semantic, Some(PlaneState::Ready { .. })) && !repaired { + continue; + } + let stored = observed + .get(path) + .and_then(|entry| entry.attachments.get(&FamilyPlane::Semantic)) + .and_then(|value| value.downcast_ref::()) + .filter(|attachment| attachment.content == *content && attachment.producer == id) + .map(|attachment| attachment.key); + let key = match state.fill.get(path, content, FamilyPlane::Semantic) { + Some(key) => Some(*key), + None => match stored { + Some(key) => store + .get(&key) + .map_err(|error| plane_error(error.to_string()))? + .is_some() + .then_some(key), + None => None, + }, + }; + if let Some(key) = key { + candidates.push((path.clone(), key)); + } + if let Some(reason) = state.failed.get(&(path.clone(), *content)) { + planes.semantic = Some(PlaneState::Failed { + reason: reason.clone(), + producer: id.clone(), + }); + continue; + } + if !matches!(&planes.semantic, Some(PlaneState::Failed { producer, .. }) if *producer == id) + { + planes.semantic = Some(PlaneState::pending("awaiting semantic fill")); + } + } + if !candidates.is_empty() { + // Pin all candidates before the touch transaction. Missing rows + // remain pending/failed: GC may have won before protection landed. + let keys = candidates.iter().map(|(_, key)| *key).collect::>(); + live.protect(&keys) + .map_err(|error| plane_error(error.to_string()))?; + let missing = store + .touch(&keys) + .map_err(|error| plane_error(error.to_string()))? + .missing + .into_iter() + .collect::>(); + for (path, key) in candidates { + if !missing.contains(&key) { + if let Some(EntryV2::Regular { planes, .. }) = manifest.get_mut(&path) { + planes.semantic = Some(PlaneState::ready(&key)); + } + } + } + } + Ok(()) + } + + fn store_reader( + access: &ViewAccess, + generation: &ComposedGeneration, + ) -> Result>, PlaneError> { + if let super::contracts::StorageRoute::V3(route) = access.route(generation) { + return SemanticStore::v3_reader(&route) + .map(|reader| { + reader + .map(|reader| Box::new(reader) as Box) + }) + .map_err(|error| plane_error(error.to_string())); + } match access { ViewAccess::Owner(owner) => owner .open_store(FamilyPlane::Semantic) - .map(|store| Some(store.reader())) + .map(|store| { + Some(Box::new(store.reader()) as Box) + }) .map_err(|error| plane_error(error.to_string())), ViewAccess::Reader { registration, .. } => registration .open_store(FamilyPlane::Semantic) + .map(|store| { + store.map(|store| Box::new(store) as Box) + }) .map_err(|error| plane_error(error.to_string())), } } @@ -767,7 +1053,7 @@ impl SemanticPlane { let mut legacy_summaries = BTreeSet::new(); if compatible { let arena = self.arena(access.family()); - let store = Self::store_reader(access)?; + let store = Self::store_reader(access, generation)?; // Markers of every run loaded here are probed together afterwards. let mut seeds = Vec::new(); for (path, entry) in manifest.entries() { @@ -786,7 +1072,7 @@ impl SemanticPlane { ) { (Some(bytes), Some(store), Some(relative)) => arena .load_deferring_seed( - store, + store.as_ref(), &FamilyKey::new(FamilyPlane::Semantic, bytes), &relative, &self.producer.payload(), @@ -818,7 +1104,7 @@ impl SemanticPlane { } } if let Some(store) = store.as_ref() { - arena.seed_verified_runs(store, seeds, &self.producer); + arena.seed_verified_runs(store.as_ref(), seeds, &self.producer); } } Ok(Resident { @@ -1014,15 +1300,32 @@ impl SemanticPlane { // The view's state is locked only for short bookkeeping steps, never // across file reads, model calls or store writes, so queries and // publication of this checkout proceed while it fills. - let needs_pin = lock(&view).live.is_none(); + let v3 = snapshot.generation().v3_route().cloned(); + let needs_pin = { + let state = lock(&view); + if v3.is_some() { + state.live_v3.is_none() + } else { + state.live.is_none() + } + }; if needs_pin { - let pin = Arc::new(Mutex::new( - crate::pins::LivePin::create(owner) - .map_err(|error| plane_error(error.to_string()))?, - )); + let pin = match &v3 { + Some(route) => { + crate::pins::LivePin::create_v3(&route.storage, &route.family, &route.scope) + } + None => crate::pins::LivePin::create(owner), + } + .map_err(|error| plane_error(error.to_string()))?; + let pin = Arc::new(Mutex::new(pin)); let mut state = lock(&view); - if state.live.is_none() { - state.live = Some(pin.clone()); + let slot = if v3.is_some() { + &mut state.live_v3 + } else { + &mut state.live + }; + if slot.is_none() { + *slot = Some(pin.clone()); } drop(state); drop(pin); @@ -1105,9 +1408,7 @@ impl SemanticPlane { let lookup_started = Instant::now(); let arena = self.arena(owner.family()); - let store = owner - .open_store(FamilyPlane::Semantic) - .map_err(|error| plane_error(error.to_string()))?; + let store = SemanticStore::for_generation(owner, snapshot.generation())?; let payload_producer = self.producer.payload(); let mut ready = Vec::new(); let mut claimed = Vec::new(); @@ -1123,7 +1424,7 @@ impl SemanticPlane { ready.push((item.clone(), key, run)); } else if let Some(run) = arena .load( - &store.reader(), + &*store.reader(), &key, relative, &payload_producer, @@ -1146,7 +1447,7 @@ impl SemanticPlane { ready.push((item.clone(), key, run)); } else if let Some(run) = arena .load( - &store.reader(), + &*store.reader(), &key, relative, &payload_producer, @@ -1234,7 +1535,7 @@ impl SemanticPlane { ) .collect::>(); let store_started = Instant::now(); - protect(&view, &keys)?; + protect(&view, &keys, store.is_v3())?; report.timing.store_write += store_started.elapsed(); let mut pending = to_embed.into_iter().zip(chunks).peekable(); while pending.peek().is_some() { @@ -1388,7 +1689,7 @@ impl SemanticPlane { } if let Some(run) = arena .load( - &store.reader(), + &*store.reader(), &key, &relative, &payload_producer, @@ -1466,7 +1767,7 @@ impl SemanticPlane { Some(run) => Some(run), None => arena .load( - &store.reader(), + &*store.reader(), &key, &relative, &payload_producer, @@ -1518,7 +1819,7 @@ impl SemanticPlane { fn embed_chunks( &self, arena: &super::semantic_arena::SemanticArena, - store: &FamilyStore, + store: &SemanticStore, texts: Vec, embed: &mut F, report: &mut FillReport, @@ -1662,7 +1963,7 @@ fn chunk_store_error(error: crate::blob_store::v2::StoreError, report: &mut Fill fn admit_ready( view: &Mutex, - store: &FamilyStore, + store: &SemanticStore, ready: Vec<( WorkItem, FamilyKey, @@ -1676,7 +1977,7 @@ fn admit_ready( return Ok(()); } let keys = ready.iter().map(|(_, key, _)| *key).collect::>(); - protect(view, &keys)?; + protect(view, &keys, store.is_v3())?; // GC may have won before the live-pin write. Do not admit a missing row. let swept = store .touch(&keys) @@ -1712,11 +2013,11 @@ fn admit_ready( Ok(()) } -fn protect(view: &Mutex, keys: &[FamilyKey]) -> Result<(), PlaneError> { - let pin = lock(view) - .live - .clone() - .ok_or_else(|| plane_error("semantic fill has no live pin"))?; +fn protect(view: &Mutex, keys: &[FamilyKey], v3: bool) -> Result<(), PlaneError> { + let state = lock(view); + let pin = if v3 { &state.live_v3 } else { &state.live }.clone(); + drop(state); + let pin = pin.ok_or_else(|| plane_error("semantic fill has no live pin"))?; let result = lock(&pin) .protect(keys) .map_err(|error| plane_error(error.to_string())); @@ -2216,6 +2517,7 @@ impl PlaneAdapter for SemanticPlane { failed, tracker: std::mem::take(&mut state.tracker), live: pin.clone(), + live_v3: state.live_v3.clone(), active_fills: 0, cache: None, })); @@ -2344,88 +2646,33 @@ impl super::first_load::CompositePlane for SemanticPlane { live: &mut crate::pins::LivePin, _seed_derived: bool, ) -> Result<(), PlaneError> { - let id = self.producer.id(); - if manifest.header().producers.semantic.as_deref() != Some(id.as_str()) { - return Err(plane_error("semantic producer mismatch")); - } let store = owner .open_store(FamilyPlane::Semantic) + .map(SemanticStore::V2) .map_err(|error| plane_error(error.to_string()))?; - let view = self.view(&ViewAccess::Owner(owner.clone())); - let state = lock(&view); - let mut candidates = Vec::new(); - for (path, entry) in manifest.entries_mut() { - let EntryV2::Regular { - content, planes, .. - } = entry - else { - continue; - }; - if !applies_to(path) { - planes.semantic = None; - continue; - } - // A completed small-cap repair replaces the old Ready file-run key. - let repaired = self.producer.caps.total_chars < EmbedTextCaps::default().total_chars - && state - .fill - .get(path, content, FamilyPlane::Semantic) - .is_some(); - if matches!(planes.semantic, Some(PlaneState::Ready { .. })) && !repaired { - continue; - } - let stored = observed - .get(path) - .and_then(|entry| entry.attachments.get(&FamilyPlane::Semantic)) - .and_then(|value| value.downcast_ref::()) - .filter(|attachment| attachment.content == *content && attachment.producer == id) - .map(|attachment| attachment.key); - let key = match state.fill.get(path, content, FamilyPlane::Semantic) { - Some(key) => Some(*key), - None => match stored { - Some(key) => store - .contains(&key) - .map_err(|error| plane_error(error.to_string()))? - .then_some(key), - None => None, - }, - }; - if let Some(key) = key { - candidates.push((path.clone(), key)); - } - if let Some(reason) = state.failed.get(&(path.clone(), *content)) { - planes.semantic = Some(PlaneState::Failed { - reason: reason.clone(), - producer: id.clone(), - }); - continue; - } - if !matches!(&planes.semantic, Some(PlaneState::Failed { producer, .. }) if *producer == id) - { - planes.semantic = Some(PlaneState::pending("awaiting semantic fill")); - } - } - if !candidates.is_empty() { - // Pin all candidates before the touch transaction. Missing rows - // remain pending/failed: GC may have won before protection landed. - let keys = candidates.iter().map(|(_, key)| *key).collect::>(); - live.protect(&keys) - .map_err(|error| plane_error(error.to_string()))?; - let missing = store - .touch(&keys) - .map_err(|error| plane_error(error.to_string()))? - .missing - .into_iter() - .collect::>(); - for (path, key) in candidates { - if !missing.contains(&key) { - if let Some(EntryV2::Regular { planes, .. }) = manifest.get_mut(&path) { - planes.semantic = Some(PlaneState::ready(&key)); - } - } - } - } - Ok(()) + self.materialize_with(owner, observed, manifest, live, &store) + } + + /// The v3 counterpart: the same readiness rules, with presence checks and + /// touches against the family's v3 semantic store. Only the delta's own + /// entries are materialized; inherited base entries belong to the base. + fn materialize_v3( + &self, + owner: &ViewRegistration, + route: &super::snapshot::V3Route, + _staging: &str, + _base: &Arc, + observed: &BTreeMap, + delta: &mut ManifestV2, + live: &mut crate::pins::LivePin, + ) -> Result<(), PlaneError> { + self.materialize_with( + owner, + observed, + delta, + live, + &SemanticStore::V3(route.clone()), + ) } fn finish_generation( @@ -2553,7 +2800,7 @@ mod tests { crate::pins::LivePin::create(&checkout.owner).unwrap(), ))); } - protect(&view, &[key]).unwrap(); + protect(&view, &[key], false).unwrap(); let mut manifest = super::super::manifest_v2::ManifestV2::new(snapshot.generation().header().clone()); manifest @@ -2600,7 +2847,7 @@ mod tests { let key = plane .producer .key(b"pending bytes", &RelPath::new(b"new.rs".to_vec()).unwrap()); - protect(&view, &[key]).unwrap(); + protect(&view, &[key], false).unwrap(); let path = live_pin_path(&view); plane .generation_published(&checkout.access, snapshot.generation(), &|| { @@ -3820,7 +4067,7 @@ mod tests { let root = tempfile::tempdir().unwrap(); let plane = new_plane(storage.path(), "model-a"); let checkout = Checkout::open(storage.path(), "scope-a", root.path(), &plane); - let store = checkout.owner.open_store(FamilyPlane::Semantic).unwrap(); + let store = SemanticStore::V2(checkout.owner.open_store(FamilyPlane::Semantic).unwrap()); let producer = plane.semantic_producer(); let identity = producer.chunk_identity(); let text = "file:src/a.rs\nfn compact_vector()"; @@ -3908,7 +4155,7 @@ mod tests { let root = tempfile::tempdir().unwrap(); let plane = new_plane(storage.path(), "model-a"); let checkout = Checkout::open(storage.path(), "scope-a", root.path(), &plane); - let store = checkout.owner.open_store(FamilyPlane::Semantic).unwrap(); + let store = SemanticStore::V2(checkout.owner.open_store(FamilyPlane::Semantic).unwrap()); let producer = plane.semantic_producer(); let text = "file:src/a.rs\nfn correct()"; let key = producer.chunk_key(text); @@ -4724,3 +4971,44 @@ mod tests { assert_eq!(plane.readiness(&b.access, &shared), unfilled); } } + +#[cfg(test)] +mod v3_store_tests { + use super::*; + + #[test] + fn a_v3_semantic_store_puts_touches_and_admits_runs_without_the_v2_store() { + let _serial = crate::blob_store::v3::serial_for_test(); + let storage = tempfile::tempdir().unwrap(); + let store = SemanticStore::V3(super::super::snapshot::V3Route { + storage: storage.path().to_path_buf(), + family: "00112233445566ff".into(), + scope: "worker".into(), + }); + let key = FamilyKey::new(FamilyPlane::Semantic, [1; 32]); + assert_eq!(store.get(&key).unwrap(), None); + assert_eq!(store.touch(&[key]).unwrap().missing, vec![key]); + assert!(matches!( + store.put_or_touch(&key, b"run").unwrap(), + PutOrTouch::Inserted { .. } + )); + assert!(matches!( + store.put_or_touch(&key, b"run").unwrap(), + PutOrTouch::Reused { .. } + )); + assert!(matches!( + store.put_or_touch(&key, b"another run"), + Err(crate::blob_store::v2::StoreError::ConflictingPayload(conflict)) if conflict == key + )); + assert_eq!(store.get(&key).unwrap(), Some(b"run".to_vec())); + assert!(store.touch(&[key]).unwrap().missing.is_empty()); + // Verified-run markers are a v2 optimization; v3 writes none. + assert!(store.put_at_deletion_epoch(|_| None).unwrap().is_none()); + assert!(store.deletion_epoch().unwrap().is_none()); + assert_eq!( + store.reader().run_payload(&key).unwrap(), + Some(b"run".to_vec()) + ); + assert!(!storage.path().join("blobs/v2").exists()); + } +} diff --git a/crates/aft/src/views/semantic_arena.rs b/crates/aft/src/views/semantic_arena.rs index 03db203ea1..eb48949100 100644 --- a/crates/aft/src/views/semantic_arena.rs +++ b/crates/aft/src/views/semantic_arena.rs @@ -346,6 +346,43 @@ impl Drop for ArenaClaim { } } +/// Where admitted runs are read from: the v2 family store, or a v3 plane +/// store. Verified-run markers are a v2 deletion-epoch optimization; a source +/// without them returns `None` and its runs simply do not seed the chunk index. +pub(crate) trait RunSource { + fn run_payload(&self, key: &FamilyKey) -> Result>, String>; + fn markers_at_deletion_epoch( + &self, + keys: &mut dyn FnMut(&DeletionEpoch) -> Vec, + ) -> Option<(DeletionEpoch, std::collections::HashSet)>; +} + +impl RunSource for FamilyStoreReader { + fn run_payload(&self, key: &FamilyKey) -> Result>, String> { + self.get(key).map_err(|error| error.to_string()) + } + fn markers_at_deletion_epoch( + &self, + keys: &mut dyn FnMut(&DeletionEpoch) -> Vec, + ) -> Option<(DeletionEpoch, std::collections::HashSet)> { + self.contains_many_at_deletion_epoch(|epoch| keys(epoch)) + .ok() + .flatten() + } +} + +impl RunSource for crate::blob_store::v3::PlaneReader { + fn run_payload(&self, key: &FamilyKey) -> Result>, String> { + self.get(key).map_err(|error| error.to_string()) + } + fn markers_at_deletion_epoch( + &self, + _keys: &mut dyn FnMut(&DeletionEpoch) -> Vec, + ) -> Option<(DeletionEpoch, std::collections::HashSet)> { + None + } +} + impl SemanticArena { #[cfg(test)] pub(crate) fn set_chunk_shortcut(&self, enabled: bool) { @@ -419,7 +456,7 @@ impl SemanticArena { /// chunk index; see [`Self::seed_verified_runs`]. pub(crate) fn load( &self, - store: &FamilyStoreReader, + store: &dyn RunSource, key: &FamilyKey, rel_path: &Path, producer: &ViewPayloadProducer<'_>, @@ -437,7 +474,7 @@ impl SemanticArena { /// afterwards. pub(crate) fn load_deferring_seed( &self, - store: &FamilyStoreReader, + store: &dyn RunSource, key: &FamilyKey, rel_path: &Path, producer: &ViewPayloadProducer<'_>, @@ -448,7 +485,7 @@ impl SemanticArena { return Ok(Some(run)); } self.store_reads.fetch_add(1, Ordering::Relaxed); - let Some(payload) = store.get(key).map_err(|error| error.to_string())? else { + let Some(payload) = store.run_payload(key)? else { return Ok(None); }; self.decodes.fetch_add(1, Ordering::Relaxed); @@ -512,7 +549,7 @@ impl SemanticArena { /// [`MAX_CHUNK_KEYS`] keys, the remaining vectors stay unindexed. pub(crate) fn seed_verified_runs( &self, - store: &FamilyStoreReader, + store: &dyn RunSource, seeds: Vec, naming: &dyn ChunkNaming, ) { @@ -523,7 +560,7 @@ impl SemanticArena { if seeds.is_empty() { return; } - let Ok(Some((epoch, present))) = store.contains_many_at_deletion_epoch(|epoch| { + let Some((epoch, present)) = store.markers_at_deletion_epoch(&mut |epoch| { seeds .iter() .map(|seed| naming.verified_run_marker(&seed.payload_digest, epoch)) diff --git a/crates/aft/src/views/snapshot.rs b/crates/aft/src/views/snapshot.rs index a33014f5d0..a987f1c866 100644 --- a/crates/aft/src/views/snapshot.rs +++ b/crates/aft/src/views/snapshot.rs @@ -89,9 +89,28 @@ pub struct ComposedGeneration { delta: Arc, base: Option>, overrides: BTreeMap, + route: Option, _residency: Option, } +/// Where a v3 generation's artifacts live: plane rows in +/// `blobs/v3//.sqlite`, and its manifest, pointer and derived +/// files in the member directory `blobs/v3//views/`. A +/// generation without a route is a v2 generation. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct V3Route { + pub storage: std::path::PathBuf, + pub family: String, + pub scope: String, +} + +impl V3Route { + pub fn member_dir(&self) -> std::path::PathBuf { + crate::blob_store::v3::view_dir(&self.storage, &self.family, &self.scope) + .unwrap_or_else(|_| self.storage.join("blobs/v3/invalid")) + } +} + /// Compatibility name for callers that open a flat generation. Both names /// expose only the composed projection, never a raw manifest accessor. pub type OpenGeneration = ComposedGeneration; @@ -111,6 +130,7 @@ impl ComposedGeneration { delta: Arc::new(manifest), base: None, overrides: BTreeMap::new(), + route: None, _residency: residency, } } @@ -159,10 +179,22 @@ impl ComposedGeneration { delta: Arc::new(delta), base: Some(base), overrides, + route: None, _residency: residency, }) } + /// Marks this generation as stored in the v3 namespace at `route`. + pub fn with_v3_route(mut self, route: V3Route) -> Self { + self.route = Some(route); + self + } + + /// The v3 location of this generation, or `None` for a v2 generation. + pub fn v3_route(&self) -> Option<&V3Route> { + self.route.as_ref() + } + pub fn name(&self) -> &str { &self.name } diff --git a/crates/aft/src/views/trigram.rs b/crates/aft/src/views/trigram.rs index fd48473cbf..042a91ebff 100644 --- a/crates/aft/src/views/trigram.rs +++ b/crates/aft/src/views/trigram.rs @@ -738,6 +738,67 @@ pub fn materialize_v3( TrigramPayload::extract(source, &policy).encode(), )); } + put_v3_payloads(storage, family, manifest, payloads, live) +} + +/// [`materialize_v3`] for a worker delta: each regular entry's payload comes +/// from its strict-reconcile attachment when that attachment names the same +/// content, otherwise from a fresh read of the checkout that must hash to the +/// entry's content. +pub fn materialize_v3_observed( + storage: &Path, + family: &str, + root: &Path, + manifest: &mut super::manifest_v2::ManifestV2, + observed: &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 attachment = observed + .get(path) + .and_then(|entry| entry.attachments.get(&FamilyPlane::Trigram)) + .and_then(|value| value.downcast_ref::()) + .filter(|value| value.content == *content && value.policy == policy.fingerprint()); + let payload = match attachment { + Some(attachment) => attachment.payload.clone(), + None => { + let relative = rel_path_to_os(path).map_err(|e| plane_error(e.to_string()))?; + let bytes = + fs::read(root.join(relative)).map_err(|e| plane_error(e.to_string()))?; + if ContentHash::of(&bytes) != *content || bytes.len() as u64 != *size { + return Err(plane_error("source changed during trigram construction")); + } + TrigramPayload::extract(&bytes, &policy) + } + }; + let key = crate::blob_store::v2::TrigramKey { + content: *content, + policy, + } + .family_key(); + payloads.push((path.clone(), key, payload.encode())); + } + put_v3_payloads(storage, family, manifest, payloads, live) +} + +fn put_v3_payloads( + storage: &Path, + family: &str, + manifest: &mut super::manifest_v2::ManifestV2, + payloads: Vec<(RelPath, crate::blob_store::v2::FamilyKey, Vec)>, + live: &mut crate::pins::LivePin, +) -> Result<(), super::contracts::PlaneError> { + if payloads.is_empty() { + return Ok(()); + } // 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()))?; @@ -805,6 +866,9 @@ impl TrigramAdapter { access: &super::contracts::ViewAccess, snapshot: &Snapshot, ) -> Result { + if let super::contracts::StorageRoute::V3(route) = access.route(snapshot.generation()) { + return self.open_v3_index(&route, snapshot); + } let id = snapshot .generation() .segment_id() @@ -864,6 +928,91 @@ impl TrigramAdapter { } } +impl TrigramAdapter { + /// A v3 generation has no segment file: v3 keeps no segment rows, so the + /// base's segment is assembled in memory from the base's stored payloads + /// (one build per base generation, shared through the segment cache), and + /// the worker's changed entries load as the usual overlay. Missing or + /// not-ready payloads stay direct-scan gaps, as on the v2 path. + fn open_v3_index( + &self, + route: &super::snapshot::V3Route, + snapshot: &Snapshot, + ) -> Result { + let generation = snapshot.generation(); + if !generation.segment_ids().is_empty() { + return Err(plane_error("a v3 generation names a trigram segment")); + } + let source = crate::blob_store::v3::PlaneReader::open_existing( + &route.storage, + &route.family, + FamilyPlane::Trigram, + ) + .map_err(|e| plane_error(e.to_string()))?; + let base = generation + .base() + .map_or(generation.as_ref(), |base| base.as_ref()); + let base_scope = base + .v3_route() + .map_or(route.scope.as_str(), |base| base.scope.as_str()); + let segment_key = ( + format!("v3:{}", route.family), + *blake3::hash(format!("{base_scope}\0{}", base.name()).as_bytes()).as_bytes(), + ); + let cached = { + let mut segments = self.segments.lock().unwrap_or_else(|e| e.into_inner()); + segments.retain(|_, segment| segment.strong_count() != 0); + segments + .get(&segment_key) + .and_then(|segment| segment.upgrade()) + }; + let segment = match cached { + Some(segment) => segment, + None => { + let members: Vec<_> = base + .entries() + .filter_map(|(path, entry)| { + let content = entry.content()?; + let key = crate::blob_store::v2::TrigramKey { + content, + policy: self.policy, + } + .family_key(); + (base.plane_readiness(path, FamilyPlane::Trigram) + == Some(&super::readiness::PlaneState::ready(&key))) + .then(|| super::segment_store::SegmentMember { + rel_path: path.clone(), + content, + size: match entry { + super::manifest_v2::EntryV2::Regular { size, .. } => *size, + _ => 0, + }, + }) + }) + .collect(); + let bytes = super::segment_store::build_from_blobs(&source, &members, &self.policy) + .map_err(|e| plane_error(e.to_string()))?; + let reader = Arc::new( + SegmentReader::from_bytes(&bytes.bytes) + .map_err(|e| plane_error(e.to_string()))?, + ); + let mut segments = self.segments.lock().unwrap_or_else(|e| e.into_inner()); + match segments + .get(&segment_key) + .and_then(|segment| segment.upgrade()) + { + Some(existing) => existing, + None => { + segments.insert(segment_key, Arc::downgrade(&reader)); + reader + } + } + } + }; + GenerationIndex::open(segment, snapshot, self.policy, &source).map_err(plane_error) + } +} + impl super::contracts::PlaneAdapter for TrigramAdapter { fn plane(&self) -> FamilyPlane { FamilyPlane::Trigram diff --git a/crates/aft/src/views/v3_plane_tests.rs b/crates/aft/src/views/v3_plane_tests.rs new file mode 100644 index 0000000000..9f110e28ba --- /dev/null +++ b/crates/aft/src/views/v3_plane_tests.rs @@ -0,0 +1,355 @@ +//! The plane adapters on v3 storage: a v3 generation's trigram, callgraph +//! and semantic artifacts are read from and written to the v3 plane stores +//! and the v3 member directory, never the v2 family store. + +use std::collections::{BTreeMap, BTreeSet}; +use std::path::Path; +use std::sync::Arc; + +use crate::blob_store::v2::{ContentHash, FamilyPlane, TrigramPolicy}; +use crate::blob_store::v3; +use crate::views::base_builder::{build_base, publish_successor, BaseBuild, BaseBuildInput}; +use crate::views::base_refs::{ + register_view_in, resolve_base, with_base_barrier, BaseIdentity, WorkerOutcome, + WorkerPublication, +}; +use crate::views::contracts::{PlaneAdapter, ViewAccess}; +use crate::views::eviction::DiskBudget; +use crate::views::first_load::{CallgraphBridge, CompositePlane}; +use crate::views::manifest_v2::{EntryPlanes, EntryV2, ManifestV2, PublishedFrontier}; +use crate::views::readiness::PlaneState; +use crate::views::snapshot::{LiveDelta, LiveEntry}; +use crate::views::RelPath; + +const FAMILY: &str = "00112233445566ff"; +const BASE_FILES: &[(&str, &[u8])] = &[ + ("src/a.rs", b"pub fn alpha() -> u32 { 1 }\n"), + ("src/b.rs", b"pub fn beta() -> u32 { crate::a::alpha() }\n"), +]; +const CHANGED: &[u8] = b"pub fn gamma() -> u32 { 3 }\n"; + +fn policy() -> TrigramPolicy { + TrigramPolicy { + max_file_size: 1 << 20, + } +} + +fn rel(name: &str) -> RelPath { + RelPath::new(name.as_bytes().to_vec()).unwrap() +} + +fn write_files(root: &Path, files: &[(&str, &[u8])]) -> Vec { + files + .iter() + .map(|(name, bytes)| { + let path = root.join(name); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, bytes).unwrap(); + rel(name) + }) + .collect() +} + +struct Fixture { + storage: tempfile::TempDir, + worker_root: tempfile::TempDir, + identity: BaseIdentity, + claim: crate::views::base_builder::BuildClaim, +} + +fn fixture() -> Fixture { + let storage = tempfile::tempdir().unwrap(); + let base_root = tempfile::tempdir().unwrap(); + let paths = write_files(base_root.path(), BASE_FILES); + let BaseBuild::Published { + identity, claim, .. + } = build_base( + BaseBuildInput { + storage: storage.path(), + family: FAMILY, + root: base_root.path(), + paths: &paths, + policy: policy(), + semantic_producer: None, + callgraph_producer: crate::views::callgraph::PRODUCER.into(), + ignore_fingerprint: None, + history: None, + budget: DiskBudget::default(), + }, + None, + ) + .unwrap() + else { + panic!("the only builder publishes") + }; + let worker_root = tempfile::tempdir().unwrap(); + write_files(worker_root.path(), BASE_FILES); + write_files(worker_root.path(), &[("src/a.rs", CHANGED)]); + with_base_barrier(storage.path(), FAMILY, |tx| { + register_view_in(tx, storage.path(), FAMILY, "worker", worker_root.path()).map(|_| ()) + }) + .unwrap(); + Fixture { + storage, + worker_root, + identity, + claim, + } +} + +impl Fixture { + fn route(&self) -> crate::views::snapshot::V3Route { + crate::views::snapshot::V3Route { + storage: self.storage.path().to_path_buf(), + family: FAMILY.into(), + scope: "worker".into(), + } + } + + fn delta(&self) -> ManifestV2 { + let base = resolve_base(self.storage.path(), FAMILY, &self.identity.scope).unwrap(); + let mut delta = ManifestV2::new_v3( + base.manifest().header().clone(), + Some(base.header()), + PublishedFrontier::default(), + ); + delta + .insert( + rel("src/a.rs"), + EntryV2::regular( + ContentHash::of(CHANGED), + CHANGED.len() as u64, + EntryPlanes { + trigram: Some(PlaneState::pending("not built")), + semantic: None, + callgraph: Some(PlaneState::pending("not built")), + }, + ), + ) + .unwrap(); + delta + } + + fn publish( + &self, + delta: ManifestV2, + before_cas: &mut dyn FnMut(&str) -> crate::views::base_refs::Result<()>, + ) -> (String, Arc) { + let base = resolve_base(self.storage.path(), FAMILY, &self.identity.scope).unwrap(); + let header = base.header(); + drop(base); + match crate::views::base_refs::publish_worker_with( + WorkerPublication { + storage: self.storage.path(), + family: FAMILY, + worker_scope: "worker", + root: self.worker_root.path(), + base: &header, + delta, + previous: None, + }, + None, + before_cas, + ) + .unwrap() + { + WorkerOutcome::Published { + generation, + composed, + } => (generation, composed), + other => panic!("{other:?}"), + } + } + + /// A registry reader of the family. Reading a v3 generation never opens + /// the v2 store it registers in. + fn reader(&self) -> ViewAccess { + let registry = + crate::views::registry::FamilyRegistry::open(self.storage.path(), FAMILY).unwrap(); + ViewAccess::Reader { + registration: Arc::new(registry.register_reader("v3-plane-test").unwrap()), + scope: "worker".into(), + } + } +} + +fn files_named(root: &Path, prefix: &str) -> Vec { + let mut found = Vec::new(); + let mut stack = vec![root.to_path_buf()]; + while let Some(dir) = stack.pop() { + for entry in std::fs::read_dir(dir).into_iter().flatten().flatten() { + if entry.file_type().unwrap().is_dir() { + stack.push(entry.path()); + } else if entry.file_name().to_string_lossy().starts_with(prefix) { + found.push(entry.path()); + } + } + } + found +} + +#[test] +fn a_v3_trigram_index_builds_the_base_segment_from_payloads_and_overlays_the_delta() { + let _serial = v3::serial_for_test(); + let fixture = fixture(); + let mut delta = fixture.delta(); + let mut live = + crate::pins::LivePin::create_v3(fixture.storage.path(), FAMILY, "worker").unwrap(); + crate::views::trigram::materialize_v3_observed( + fixture.storage.path(), + FAMILY, + fixture.worker_root.path(), + &mut delta, + &BTreeMap::new(), + &mut live, + policy(), + ) + .unwrap(); + let (_, composed) = fixture.publish(delta, &mut |_| Ok(())); + let snapshot = LiveDelta::new(composed).snapshot(); + let adapter = + crate::views::trigram::TrigramAdapter::new(fixture.storage.path().to_path_buf(), policy()); + let index = adapter.open_index(&fixture.reader(), &snapshot).unwrap(); + let members: BTreeSet = snapshot.membership().into_keys().collect(); + let candidates = |literal: &str| index.candidates(&snapshot, &members, literal).indexed; + assert!(candidates("gamma").contains(&rel("src/a.rs"))); + assert!(candidates("beta").contains(&rel("src/b.rs"))); + assert!(!candidates("alpha() -> u32 { 1").contains(&rel("src/a.rs"))); + // The segment exists only in memory: v3 keeps no segment rows or files. + assert!(files_named(fixture.storage.path(), "trigram-seg").is_empty()); + assert!(!fixture + .storage + .path() + .join("blobs/v2/00112233445566ff/trigram.sqlite") + .exists()); +} + +#[test] +fn a_v3_worker_graph_is_built_from_v3_blobs_into_its_member_directory() { + let _serial = v3::serial_for_test(); + let fixture = fixture(); + let bridge = CallgraphBridge::default(); + let attachment = |name: &str, bytes: &[u8]| { + let attachment = bridge.attachment(&rel(name), bytes).unwrap(); + attachment + .downcast_ref::() + .unwrap() + .clone() + }; + // The base owner completes the base graph: blobs first, then a successor + // whose derived graph carries the claim's attempt and token. + let base_graph: Vec<_> = BASE_FILES + .iter() + .map(|(name, bytes)| (rel(name), attachment(name, bytes))) + .collect(); + v3::publish( + fixture.storage.path(), + FAMILY, + 1 << 22, + None, + |publication| { + for (_, attachment) in &base_graph { + publication.put( + &attachment.key, + &attachment.blob.to_bytes().unwrap(), + FamilyPlane::Callgraph.payload_schema(), + )?; + } + Ok(()) + }, + ) + .unwrap(); + let successor = publish_successor( + &fixture.claim, + |manifest| { + for (path, attachment) in &base_graph { + if let Some(EntryV2::Regular { planes, .. }) = manifest.get_mut(path) { + planes.callgraph = Some(PlaneState::ready(&attachment.key)); + } + } + }, + None, + ) + .unwrap(); + let base_dir = v3::view_dir(fixture.storage.path(), FAMILY, &fixture.identity.scope).unwrap(); + let owner = format!( + "{successor}-a{}-{}", + fixture.claim.attempt(), + fixture.claim.token() + ); + assert!(base_dir.join(format!("derived-{owner}.sqlite")).is_file()); + assert_eq!( + std::fs::read_to_string(base_dir.join(format!("derived-{successor}.ref"))).unwrap(), + owner + ); + // The worker's delta: its changed file's blob goes to v3, and the graph + // over the composed generation is built in the worker's member directory. + let registry = + crate::views::registry::FamilyRegistry::open(fixture.storage.path(), FAMILY).unwrap(); + let owner_registration = registry + .register_view_with_storage(fixture.storage.path(), "worker", fixture.worker_root.path()) + .unwrap(); + let mut observed = BTreeMap::new(); + let mut entry = LiveEntry::new(crate::views::snapshot::DiskState::of_bytes(CHANGED), 0); + entry.attachments.insert( + FamilyPlane::Callgraph, + bridge.attachment(&rel("src/a.rs"), CHANGED).unwrap(), + ); + observed.insert(rel("src/a.rs"), entry); + let mut delta = fixture.delta(); + let mut live = + crate::pins::LivePin::create_v3(fixture.storage.path(), FAMILY, "worker").unwrap(); + let base = resolve_base(fixture.storage.path(), FAMILY, &fixture.identity.scope) + .unwrap() + .into_generation(fixture.storage.path(), FAMILY); + let route = fixture.route(); + let _staging_pin = crate::pins::AssemblyPin::create( + &route.member_dir(), + FAMILY, + "worker", + "staging-graph", + &[], + ) + .unwrap(); + bridge + .materialize_v3( + &owner_registration, + &route, + "staging-graph", + &base, + &observed, + &mut delta, + &mut live, + ) + .unwrap(); + assert!(matches!( + delta + .get(&rel("src/a.rs")) + .unwrap() + .plane_state(FamilyPlane::Callgraph), + Some(PlaneState::Ready { .. }) + )); + let worker_dir = route.member_dir(); + assert!(worker_dir.join("derived-staging-graph.sqlite").is_file()); + let (generation, composed) = fixture.publish(delta, &mut |generation| { + bridge + .finish_generation_v3(&route, "staging-graph", generation) + .map_err(|error| { + crate::views::base_refs::BaseRefError::Io(std::io::Error::other(error.reason)) + }) + }); + assert!(worker_dir + .join(format!("derived-{generation}.sqlite")) + .is_file()); + // A foreign reader opens the worker's graph from the v3 member directory. + let access = fixture.reader(); + bridge.adapter.open_generation(&access, &composed).unwrap(); + let snapshot = LiveDelta::new(composed).snapshot(); + bridge.adapter.reader(&access, &snapshot).unwrap(); + drop(owner_registration); + assert!(!fixture + .storage + .path() + .join("blobs/v2/00112233445566ff/callgraph.sqlite") + .exists()); +} diff --git a/docs/design/views-v3-format.md b/docs/design/views-v3-format.md index 75d67c212a..ca58b660bf 100644 --- a/docs/design/views-v3-format.md +++ b/docs/design/views-v3-format.md @@ -580,8 +580,18 @@ CREATE TABLE sweep_state (singleton INTEGER PRIMARY KEY CHECK(singleton=1), 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. +- **Segments.** v3 generations carry no trigram segment: v3 has no segment + rows, files or segment collection. A reader assembles the base + generation's segment in memory from the base's stored trigram payloads + (once per base generation per process) and loads the worker's changed + entries as the usual overlay. No new on-disk rows were added for this. +- **Derived graphs.** A base's derived call graph is + `M/derived--a-.sqlite`, named by the + existing `M/derived-.ref` owner-reference file, and is built + only once every callgraph member of that generation is ready. A worker's + derived graph covers its composed generation and lives in its own member + directory under the usual `derived-.sqlite` name; while the + base's graph is pending the worker has none. ## Deferred implementation register From 7677120e46c837bc4c836d79522aa84b1689bb61 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Mon, 26 Oct 2026 17:59:26 +0100 Subject: [PATCH 10/12] mason: CheckoutDriver publishes base-referencing worker generations through v3 The base-header branch of build_own_generation no longer refuses. It authenticates the named base (pin-then-verify, digests recomputed; a header that names no registered base is refused before any write), then folds the cut into a sparse delta against it. A path whose bytes equal the base's inherits the base entry, with its readiness patch as an override; a deleted base path is a tombstone; only changed paths are entries. The plane delta keys are materialized into v3 under a v3 live pin, and a private staging name is pinned in the checkout's v3 member directory. Publication goes through base_refs::publish_worker_with, whose pre-CAS step runs each plane's finish_generation_v3. Wold is the installed v3 generation or the durable v3 pointer. Published opens the generation's readers for installation; Lost (Conflict/EquivalentWinner) and failed or possibly landed publications set the retry flag and return CHECKOUT_CHANGED_BEFORE_INSTALL, the existing revalidate path. No v1/v2 row, manifest or pointer is written. A plane without v3 storage now leaves its delta entries pending, instead of refusing. unwired_worker_publication_refuses_before_any_legacy_write is replaced: a_base_referencing_worker_publishes_a_sparse_v3_generation_readable_after_restart checks that the generation is sparse (entries = changed paths, tombstone for the deleted base path, nothing inherited copied), names the authenticated base, leaves the v2 pointer untouched, and composes correctly for a fresh reader after the driver is dropped. Its refusal half survives as a_worker_header_naming_an_unknown_base_is_refused_before_any_write. Co-authored-by: alfonso-aft <289616620+alfonso-aft@users.noreply.github.com> (cherry picked from commit 6eb0522ec0ca0e74b79af075eaa17f5ddfbb89cb) --- crates/aft/src/views/first_load.rs | 387 ++++++++++++++++++++++++++++- 1 file changed, 374 insertions(+), 13 deletions(-) diff --git a/crates/aft/src/views/first_load.rs b/crates/aft/src/views/first_load.rs index 7e69e011af..0e0877ca1f 100644 --- a/crates/aft/src/views/first_load.rs +++ b/crates/aft/src/views/first_load.rs @@ -1275,11 +1275,10 @@ pub trait CompositePlane: Send + Sync { delta: &mut super::manifest_v2::ManifestV2, live: &mut crate::pins::LivePin, ) -> Result<(), PlaneError> { + // A plane without v3 storage leaves its delta entries as they are + // (pending), which keeps them queued and honestly incomplete. let _ = (owner, route, staging, base, observed, delta, live); - Err(PlaneError { - plane: Some(self.plane()), - reason: "this plane has no v3 materialization".into(), - }) + Ok(()) } /// [`Self::finish_generation`] for a v3 member directory. @@ -1982,8 +1981,8 @@ impl FirstLoadDriver for CheckoutDriver { seed_derived: bool, ) -> Result, PlaneError> { self.check_owner(access)?; - if snapshot.generation().base_header().is_some() { - return Err(binding_unavailable()); + if let Some(header) = snapshot.generation().base_header() { + return self.build_v3_worker_generation(access, snapshot, header.clone()); } super::io::WORK .own_generation_folds @@ -2150,6 +2149,227 @@ impl FirstLoadDriver for CheckoutDriver { } } +impl CheckoutDriver { + /// Publishes this checkout's generation as a v3 worker that references the + /// base named by `header`. The header is only a lookup: the base is + /// authenticated (pin-then-verify, digests recomputed) before anything is + /// written. The published manifest is sparse: entries whose bytes differ + /// from the base, tombstones for base paths gone from the checkout, and + /// readiness overrides from the live delta's readiness patches; inherited + /// base entries are never copied. Plane rows go to the family's v3 plane + /// stores under a v3 live pin, derived files to the checkout's v3 member + /// directory, and the pointer CAS, referrer row and markers follow + /// `base_refs::publish_worker`. Nothing is written to v1 or v2 storage. + fn build_v3_worker_generation( + &self, + access: &ViewAccess, + snapshot: &Snapshot, + header: super::manifest_v2::BaseHeader, + ) -> Result, PlaneError> { + let failed = + |error: super::base_refs::BaseRefError| SiblingLoader::error(error.to_string()); + let route = self.owner.v3_route(); + let storage = route.storage.clone(); + let family = route.family.clone(); + let scope = route.scope.clone(); + let base = super::base_refs::resolve_base(&storage, &family, &header.scope) + .and_then(|base| base.check_header(&header).map(|()| base)) + .map_err(failed)?; + let base_header = base.header(); + let base_generation = base.into_generation(&storage, &family); + // The sparse delta: fold this cut's changes into the installed + // worker delta. A path whose bytes equal the base's inherits the base + // entry (with any checkout-specific readiness patch as an override); + // a deleted base path becomes a tombstone. + let generation = snapshot.generation(); + let inherited = |path: &RelPath, + content: &crate::blob_store::v2::ContentHash, + size: u64| { + generation.base().and_then(|base| base.entry(path)).filter(|entry| { + matches!(entry, super::manifest_v2::EntryV2::Regular { content: c, size: s, .. } if c == content && *s == size) + }) + }; + let patched = |path: &RelPath, entry: &super::manifest_v2::EntryV2| { + let mut entry = entry.clone(); + if let ( + Some(patch), + super::manifest_v2::EntryV2::Regular { + content, planes, .. + }, + ) = (snapshot.readiness_patches().get(path), &mut entry) + { + if patch.content == *content { + for plane in FamilyPlane::ALL { + if let Some(state) = patch.planes.get(plane) { + *planes.get_mut(plane) = Some(state.clone()); + } + } + } + } + entry + }; + let mut updates = Vec::new(); + let mut changed = std::collections::BTreeSet::new(); + for (path, entry) in snapshot.live_entries() { + changed.insert(path.clone()); + let update = match entry.disk { + super::snapshot::DiskState::Absent => None, + super::snapshot::DiskState::Present { content, size } => { + match inherited(path, &content, size) { + Some(base_entry) => Some(patched(path, base_entry)), + None => { + let mut planes = super::manifest_v2::EntryPlanes::default(); + for plane in &self.planes { + if plane.applies_to(path) { + *planes.get_mut(plane.plane()) = + Some(super::readiness::PlaneState::pending( + "awaiting checkout materialization", + )); + } + } + Some(super::manifest_v2::EntryV2::regular(content, size, planes)) + } + } + } + }; + updates.push((path.clone(), update)); + } + for (path, patch) in snapshot.readiness_patches() { + if changed.contains(path) { + continue; + } + if let Some(base_entry) = generation.base().and_then(|base| base.entry(path)) { + if base_entry.content() == Some(patch.content) { + updates.push((path.clone(), Some(patched(path, base_entry)))); + } + } + } + let mut delta = generation + .fold( + &generation.header().producers, + updates, + &super::readiness::FillMap::default(), + ) + .map_err(|error| SiblingLoader::error(error.to_string()))?; + delta + .set_base_header(base_header.clone()) + .map_err(|error| SiblingLoader::error(error.to_string()))?; + // Register the v3 member and protect the private staging name before + // any plane writes derived files under it. + super::base_refs::with_base_barrier(&storage, &family, |tx| { + super::base_refs::register_view_in(tx, &storage, &family, &scope, self.owner.root()) + .map(|_| ()) + }) + .map_err(failed)?; + let staging = format!( + "staging-{}-{}", + std::process::id(), + super::base_refs::fresh_nonce() + ); + let _staging_pin = crate::pins::AssemblyPin::create( + &route.member_dir(), + family.clone(), + scope.clone(), + staging.clone(), + &[], + ) + .map_err(|error| SiblingLoader::error(error.to_string()))?; + let mut live = crate::pins::LivePin::create_v3(&storage, &family, &scope) + .map_err(|error| SiblingLoader::error(error.to_string()))?; + let observed = self + .observed + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .entries + .clone(); + for plane in &self.planes { + plane.materialize_v3( + &self.owner, + &route, + &staging, + &base_generation, + &observed, + &mut delta, + &mut live, + )?; + } + // `Wold`: the installed v3 generation of this checkout, or the + // durable v3 pointer when the installed bundle is not one. + let previous = match generation.v3_route() { + Some(installed) if installed.scope == scope => Some(generation.name().to_owned()), + _ => super::ViewStore::existing_dir(route.member_dir()) + .map(|store| store.current_generation_read_only()) + .transpose() + .map_err(|error| SiblingLoader::error(error.to_string()))? + .flatten(), + }; + let planes = &self.planes; + let outcome = super::base_refs::publish_worker_with( + super::base_refs::WorkerPublication { + storage: &storage, + family: &family, + worker_scope: &scope, + root: self.owner.root(), + base: &base_header, + delta, + previous, + }, + None, + &mut |published| { + for plane in planes { + plane + .finish_generation_v3(&route, &staging, published) + .map_err(|error| { + super::base_refs::BaseRefError::Io(std::io::Error::other(error.reason)) + })?; + } + Ok(()) + }, + ); + drop(live); + match outcome { + Ok(super::base_refs::WorkerOutcome::Published { composed, .. }) => { + let readers = PlaneReaders::open(access, composed.clone(), &self.adapters)?; + for adapter in &self.adapters { + adapter + .generation_published(access, &composed, &|| self.installed_snapshot())?; + } + let retired = self + .prepared_readers + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .insert( + composed.name().to_owned(), + PreparedReaders { + seq: snapshot.install_cut().map_or(0, |cut| cut.seq), + readers, + }, + ); + drop(retired); + Ok(composed) + } + Ok(super::base_refs::WorkerOutcome::Lost { + current, + equivalent, + }) => { + self.retry.store(true, std::sync::atomic::Ordering::Release); + Err(SiblingLoader::error(format!( + "{CHECKOUT_CHANGED_BEFORE_INSTALL}: publication lost ({}, current {current:?}); revalidate a fresh job", + if equivalent { "EquivalentWinner" } else { "Conflict" } + ))) + } + Err(error) => { + // publish_worker already resolved any landed CAS into a + // published referrer; the next job revalidates from disk. + self.retry.store(true, std::sync::atomic::Ordering::Release); + Err(SiblingLoader::error(format!( + "{CHECKOUT_CHANGED_BEFORE_INSTALL}: v3 worker publication failed or may have landed; revalidate: {error}" + ))) + } + } + } +} + impl QueryState for CheckoutDriver { fn installed_state( &self, @@ -3107,7 +3327,151 @@ mod composite_tests { } #[test] - fn unwired_worker_publication_refuses_before_any_legacy_write() { + fn a_base_referencing_worker_publishes_a_sparse_v3_generation_readable_after_restart() { + let _serial = crate::blob_store::v3::serial_for_test(); + let root = tempfile::tempdir().unwrap(); + let storage = tempfile::tempdir().unwrap(); + let (driver, _, _, access) = fixture(root.path(), storage.path()); + // A v3 base over the checkout's files as they are now. + std::fs::write(root.path().join("kept.rs"), b"kept content").unwrap(); + std::fs::write(root.path().join("deleted.rs"), b"deleted content").unwrap(); + let paths = ["deleted.rs", "file.rs", "kept.rs"] + .map(|name| RelPath::new(name.as_bytes().to_vec()).unwrap()); + let super::super::base_builder::BaseBuild::Published { identity, .. } = + super::super::base_builder::build_base( + super::super::base_builder::BaseBuildInput { + storage: storage.path(), + family: "family", + root: root.path(), + paths: &paths, + policy: crate::blob_store::v2::TrigramPolicy { + max_file_size: 1 << 20, + }, + semantic_producer: None, + callgraph_producer: "extractor".into(), + ignore_fingerprint: None, + history: None, + budget: super::super::eviction::DiskBudget::default(), + }, + None, + ) + .unwrap() + else { + panic!("the only builder publishes"); + }; + let base = super::super::base_refs::resolve_base(storage.path(), "family", &identity.scope) + .unwrap(); + // An installed worker generation that inherits the whole base; the + // header names the base only by its identity strings. + let worker = super::super::manifest_v2::ManifestV2::new_v3( + base.manifest().header().clone(), + Some(base.header()), + Default::default(), + ); + let composed = Arc::new( + ComposedGeneration::compose( + "installed-worker", + worker, + base.into_generation(storage.path(), "family"), + None, + ) + .unwrap(), + ); + // The checkout then edits one file, adds one and deletes one. + let mut delta = LiveDelta::new(composed); + let rel = |name: &str| RelPath::new(name.as_bytes().to_vec()).unwrap(); + std::fs::write(root.path().join("file.rs"), b"edited content").unwrap(); + std::fs::write(root.path().join("added.rs"), b"added content").unwrap(); + std::fs::remove_file(root.path().join("deleted.rs")).unwrap(); + delta.apply( + rel("file.rs"), + LiveEntry::new( + super::super::snapshot::DiskState::of_bytes(b"edited content"), + 1, + ), + ); + delta.apply( + rel("added.rs"), + LiveEntry::new( + super::super::snapshot::DiskState::of_bytes(b"added content"), + 2, + ), + ); + delta.apply( + rel("deleted.rs"), + LiveEntry::new(super::super::snapshot::DiskState::Absent, 3), + ); + // A path rewritten with its base bytes inherits, rather than copies. + delta.apply( + rel("kept.rs"), + LiveEntry::new( + super::super::snapshot::DiskState::of_bytes(b"kept content"), + 4, + ), + ); + let snapshot = delta.snapshot(); + let published = driver + .build_own_generation(&access, &snapshot, false) + .unwrap(); + let generation = published.name().to_owned(); + assert!(published.v3_route().is_some()); + drop(published); + // No v2 pointer moved and nothing went outside the v3 namespace. + assert!(driver + .owner + .view_store() + .unwrap() + .current_generation() + .unwrap() + .is_none()); + assert!(!storage.path().join("views/v3").exists()); + let member = crate::blob_store::v3::view_dir(storage.path(), "family", "local").unwrap(); + let store = super::super::ViewStore::existing_dir(member).unwrap(); + assert_eq!( + store.current_generation_read_only().unwrap().as_deref(), + Some(generation.as_str()) + ); + let manifest = store.load_manifest_v2(&generation).unwrap(); + let header = manifest.base_header().unwrap(); + assert_eq!(header.scope, identity.scope); + identity.check_header(header).unwrap(); + // Sparse: only the changed paths, never the inherited base entries. + let entries: Vec<_> = manifest.entries().map(|(path, _)| path.clone()).collect(); + assert_eq!(entries, vec![rel("added.rs"), rel("file.rs")]); + assert_eq!( + manifest.tombstones().iter().cloned().collect::>(), + vec![rel("deleted.rs")] + ); + assert!(manifest.readiness_overrides().is_empty()); + // After a restart a fresh reader composes it over the authenticated base. + drop(driver); + let reader = super::super::base_refs::open_foreign_v3( + storage.path(), + "family", + "local", + &manifest.header().producers, + ) + .unwrap() + .unwrap(); + assert_eq!(reader.name(), generation); + assert!(reader.entry(&rel("deleted.rs")).is_none()); + assert_eq!( + reader + .entry(&rel("kept.rs")) + .and_then(|entry| entry.content()), + Some(crate::blob_store::v2::ContentHash::of(b"kept content")) + ); + assert_eq!( + reader + .entry(&rel("file.rs")) + .and_then(|entry| entry.content()), + Some(crate::blob_store::v2::ContentHash::of(b"edited content")) + ); + assert!(reader.entry(&rel("added.rs")).is_some()); + } + + #[test] + fn a_worker_header_naming_an_unknown_base_is_refused_before_any_write() { let root = tempfile::tempdir().unwrap(); let storage = tempfile::tempdir().unwrap(); let (driver, _, _, access) = fixture(root.path(), storage.path()); @@ -3115,7 +3479,7 @@ mod composite_tests { let worker = super::super::manifest_v2::ManifestV2::new_v3( base.header().clone(), Some(super::super::manifest_v2::BaseHeader { - scope: "base".into(), + scope: super::super::base_refs::base_scope_for(&[1; 32]), membership_digest: "11".repeat(32), identity_digest: "22".repeat(32), bound_generation: base.name().into(), @@ -3125,15 +3489,12 @@ mod composite_tests { let composed = Arc::new(ComposedGeneration::compose("worker", worker, base, None).unwrap()); let snapshot = LiveDelta::new(composed).snapshot(); let store = driver.owner.view_store().unwrap(); - assert!(store.current_generation().unwrap().is_none()); let error = driver .build_own_generation(&access, &snapshot, false) .unwrap_err(); - assert!(error - .reason - .contains("durable base publication binding is not wired")); + assert!(error.reason.contains("base unavailable"), "{error:?}"); assert!(store.current_generation().unwrap().is_none()); - assert!(!storage.path().join("blobs/v3").exists()); + assert!(!storage.path().join("blobs/v3/family/views/local").exists()); } #[test] From 6bcb177750c4d3393c5b2a2e975dd8e353687b38 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Mon, 26 Oct 2026 18:05:56 +0100 Subject: [PATCH 11/12] mason: correct comments on v3 routing, the v3 live pin and in-memory segments Co-authored-by: alfonso-aft <289616620+alfonso-aft@users.noreply.github.com> (cherry picked from commit c26f1c23821947845961d0f6b686efa836893f0e) --- crates/aft/src/views/first_load.rs | 14 ++++++++------ crates/aft/src/views/registry.rs | 6 +++--- crates/aft/src/views/semantic.rs | 5 +++-- crates/aft/src/views/trigram.rs | 2 +- docs/design/views-v3-format.md | 2 +- 5 files changed, 16 insertions(+), 13 deletions(-) diff --git a/crates/aft/src/views/first_load.rs b/crates/aft/src/views/first_load.rs index 0e0877ca1f..fa9743e8a3 100644 --- a/crates/aft/src/views/first_load.rs +++ b/crates/aft/src/views/first_load.rs @@ -2151,9 +2151,9 @@ impl FirstLoadDriver for CheckoutDriver { impl CheckoutDriver { /// Publishes this checkout's generation as a v3 worker that references the - /// base named by `header`. The header is only a lookup: the base is - /// authenticated (pin-then-verify, digests recomputed) before anything is - /// written. The published manifest is sparse: entries whose bytes differ + /// base named by `header`. The header is only a lookup: the base is pinned + /// and authenticated (digests recomputed) before any of this worker's plane + /// artifacts or its manifest is written. The published manifest is sparse: entries whose bytes differ /// from the base, tombstones for base paths gone from the checkout, and /// readiness overrides from the live delta's readiness patches; inherited /// base entries are never copied. Plane rows go to the family's v3 plane @@ -2359,8 +2359,9 @@ impl CheckoutDriver { ))) } Err(error) => { - // publish_worker already resolved any landed CAS into a - // published referrer; the next job revalidates from disk. + // If the CAS landed, its referrer is published or left as an + // intent row for reconciliation; the next job revalidates + // from disk. self.retry.store(true, std::sync::atomic::Ordering::Release); Err(SiblingLoader::error(format!( "{CHECKOUT_CHANGED_BEFORE_INSTALL}: v3 worker publication failed or may have landed; revalidate: {error}" @@ -3443,7 +3444,8 @@ mod composite_tests { vec![rel("deleted.rs")] ); assert!(manifest.readiness_overrides().is_empty()); - // After a restart a fresh reader composes it over the authenticated base. + // With the driver gone, a fresh reader composes the published delta + // over the authenticated base. drop(driver); let reader = super::super::base_refs::open_foreign_v3( storage.path(), diff --git a/crates/aft/src/views/registry.rs b/crates/aft/src/views/registry.rs index e0f0dcd08a..d0aee2b097 100644 --- a/crates/aft/src/views/registry.rs +++ b/crates/aft/src/views/registry.rs @@ -1072,9 +1072,8 @@ impl ViewRegistration { Ok(FamilyStore::open(&self.write_access(), plane)?) } - /// The view's own pointer and manifests in `views/v2/`. - /// This checkout's member in the v3 namespace: the same family and scope - /// under `blobs/v3//views/`. + /// The v3 member of this checkout: the same family and scope, under + /// `blobs/v3//views/`. pub fn v3_route(&self) -> super::snapshot::V3Route { super::snapshot::V3Route { storage: self.registry().storage().to_path_buf(), @@ -1083,6 +1082,7 @@ impl ViewRegistration { } } + /// The view's own pointer and manifests in `views/v2/`. pub fn view_store(&self) -> RegistryResult { let view = super::ViewStore::open_dir(self.view_dir.clone())? .with_sweep_registry(self.registry.clone()); diff --git a/crates/aft/src/views/semantic.rs b/crates/aft/src/views/semantic.rs index e12025389d..d5c76fdd69 100644 --- a/crates/aft/src/views/semantic.rs +++ b/crates/aft/src/views/semantic.rs @@ -681,8 +681,9 @@ struct ViewState { tracker: FailureTracker, live: Option>>, /// The live pin in the v3 member directory, for fills of a generation - /// published in the v3 namespace. Its keys are kept until the process - /// ends: post-fold trimming covers only the v2 pin so far. + /// published in the v3 namespace. It is carried across generation + /// publications but not trimmed after folds yet (only the v2 pin is), so + /// it keeps its keys until the view state holding it is dropped. live_v3: Option>>, active_fills: usize, cache: Option<(CacheKey, Arc)>, diff --git a/crates/aft/src/views/trigram.rs b/crates/aft/src/views/trigram.rs index 042a91ebff..f178485dd5 100644 --- a/crates/aft/src/views/trigram.rs +++ b/crates/aft/src/views/trigram.rs @@ -931,7 +931,7 @@ impl TrigramAdapter { impl TrigramAdapter { /// A v3 generation has no segment file: v3 keeps no segment rows, so the /// base's segment is assembled in memory from the base's stored payloads - /// (one build per base generation, shared through the segment cache), and + /// (shared through the segment cache while an index holds it), and /// the worker's changed entries load as the usual overlay. Missing or /// not-ready payloads stay direct-scan gaps, as on the v2 path. fn open_v3_index( diff --git a/docs/design/views-v3-format.md b/docs/design/views-v3-format.md index ca58b660bf..d6dc89e322 100644 --- a/docs/design/views-v3-format.md +++ b/docs/design/views-v3-format.md @@ -583,7 +583,7 @@ CREATE TABLE sweep_state (singleton INTEGER PRIMARY KEY CHECK(singleton=1), - **Segments.** v3 generations carry no trigram segment: v3 has no segment rows, files or segment collection. A reader assembles the base generation's segment in memory from the base's stored trigram payloads - (once per base generation per process) and loads the worker's changed + (shared through the segment cache while an index holds it) and loads the worker's changed entries as the usual overlay. No new on-disk rows were added for this. - **Derived graphs.** A base's derived call graph is `M/derived--a-.sqlite`, named by the From 3c190400eed10eddba2942ea24eff976a0a30b90 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Mon, 26 Oct 2026 18:16:16 +0100 Subject: [PATCH 12/12] search-quality descriptor for views-c2-v3-publication Co-authored-by: alfonso-aft <289616620+alfonso-aft@users.noreply.github.com> --- .../slice-descriptors/land-views-c2-v3-publication.json | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 benchmarks/aft-search/slice-descriptors/land-views-c2-v3-publication.json diff --git a/benchmarks/aft-search/slice-descriptors/land-views-c2-v3-publication.json b/benchmarks/aft-search/slice-descriptors/land-views-c2-v3-publication.json new file mode 100644 index 0000000000..ebd574711e --- /dev/null +++ b/benchmarks/aft-search/slice-descriptors/land-views-c2-v3-publication.json @@ -0,0 +1,8 @@ +{ + "targeted_mechanism": "none", + "kind": "harness", + "slice_class": "non_ranking", + "fixtures": [ + "harness-goldens" + ] +}