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" + ] +} 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" + ] +} 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/blob_store/v3.rs b/crates/aft/src/blob_store/v3.rs index ea4e4dc7b0..36913daef3 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), @@ -151,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() @@ -185,7 +225,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 +479,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 +496,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 +633,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 +855,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() } @@ -927,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 { @@ -940,6 +1157,7 @@ struct RegistryIdentity { path: PathBuf, file: Option, } +#[derive(Debug)] pub struct Reservation { id: i64, before: u64, @@ -1054,6 +1272,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, @@ -1183,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())?; @@ -1292,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 @@ -1313,6 +1725,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/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/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()); 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..55276b2c29 --- /dev/null +++ b/crates/aft/src/views/base_builder.rs @@ -0,0 +1,757 @@ +//! 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 (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. + +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 { + 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| { + 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)?; + // 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() { + 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), + }), + } +} + +/// 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, 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 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 +/// 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 + ) + } + /// 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<()> { + 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"), + } +} + +/// 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, 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( + 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); + 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:?}" + ))), + } +} + +#[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..b08bf23a65 --- /dev/null +++ b/crates/aft/src/views/base_builder_tests.rs @@ -0,0 +1,591 @@ +//! 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 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!( + 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}"); +} + +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/base_refs.rs b/crates/aft/src/views/base_refs.rs new file mode 100644 index 0000000000..d3dc104fe2 --- /dev/null +++ b/crates/aft/src/views/base_refs.rs @@ -0,0 +1,994 @@ +//! 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, 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(), + } +} + +/// 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 { + 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, + 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(storage, family); + 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)), + }; + 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) { + 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.with_v3_route(route(storage, family, worker_scope))), + 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))) + .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)) + { + 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(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")) + })) +} + +#[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..bcc6486d1b --- /dev/null +++ b/crates/aft/src/views/base_sweep_tests.rs @@ -0,0 +1,573 @@ +//! 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); + // 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:?}"); + 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/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 96824547e9..edecaf0261 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 { @@ -140,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/eviction.rs b/crates/aft/src/views/eviction.rs index de87445e4a..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 { @@ -112,6 +205,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 +217,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 +786,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/first_load.rs b/crates/aft/src/views/first_load.rs index c9d2bb12c8..fa9743e8a3 100644 --- a/crates/aft/src/views/first_load.rs +++ b/crates/aft/src/views/first_load.rs @@ -1257,6 +1257,40 @@ 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> { + // 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); + Ok(()) + } + + /// [`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 @@ -1947,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 @@ -2115,6 +2149,228 @@ 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 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 + /// 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) => { + // 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}" + ))) + } + } + } +} + impl QueryState for CheckoutDriver { fn installed_state( &self, @@ -3072,7 +3328,152 @@ 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()); + // 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(), + "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()); @@ -3080,7 +3481,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(), @@ -3090,15 +3491,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] @@ -3308,6 +3706,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 +4433,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/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..4e5e4bfcc3 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::{ @@ -65,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/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/src/views/registry.rs b/crates/aft/src/views/registry.rs index 5d4fcf4c5d..d0aee2b097 100644 --- a/crates/aft/src/views/registry.rs +++ b/crates/aft/src/views/registry.rs @@ -1072,6 +1072,16 @@ impl ViewRegistration { Ok(FamilyStore::open(&self.write_access(), plane)?) } + /// 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(), + family: self.family().to_owned(), + scope: self.scope().to_owned(), + } + } + /// 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())? 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..d5c76fdd69 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,11 @@ 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. 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)>, } @@ -738,14 +917,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 +1054,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 +1073,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 +1105,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 +1301,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 +1409,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 +1425,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 +1448,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 +1536,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 +1690,7 @@ impl SemanticPlane { } if let Some(run) = arena .load( - &store.reader(), + &*store.reader(), &key, &relative, &payload_producer, @@ -1466,7 +1768,7 @@ impl SemanticPlane { Some(run) => Some(run), None => arena .load( - &store.reader(), + &*store.reader(), &key, &relative, &payload_producer, @@ -1518,7 +1820,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 +1964,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 +1978,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 +2014,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 +2518,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 +2647,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 +2801,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 +2848,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 +4068,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 +4156,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 +4972,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 4dbbab59e0..f178485dd5 100644 --- a/crates/aft/src/views/trigram.rs +++ b/crates/aft/src/views/trigram.rs @@ -697,6 +697,133 @@ 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(), + )); + } + 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()))?; + 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 @@ -739,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() @@ -798,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 + /// (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( + &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/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/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 { 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()); +} diff --git a/docs/design/views-v3-format.md b/docs/design/views-v3-format.md index 97eb07ad19..d6dc89e322 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,71 @@ 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 `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 + 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: 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 + (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 + 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 This register assigns the remaining formats **before their owners write them**.