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/benchmarks/aft-search/slice-descriptors/land-views-e-legacy-imports.json b/benchmarks/aft-search/slice-descriptors/land-views-e-legacy-imports.json new file mode 100644 index 0000000000..ebd574711e --- /dev/null +++ b/benchmarks/aft-search/slice-descriptors/land-views-e-legacy-imports.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-f-lexical-routing.json b/benchmarks/aft-search/slice-descriptors/land-views-f-lexical-routing.json new file mode 100644 index 0000000000..fb0f069b40 --- /dev/null +++ b/benchmarks/aft-search/slice-descriptors/land-views-f-lexical-routing.json @@ -0,0 +1,15 @@ +{ + "slice_class": "engine_unwired", + "targeted_mechanism": "none", + "kind": "harness", + "fixtures": [ + "crates/aft/tests/integration/tool_call_parity_test.rs", + "crates/aft/src/search_index.rs", + "crates/aft/src/commands/semantic_search/mod.rs", + "crates/aft/src/commands/semantic_search/exact_lane.rs" + ], + "slices": [ + "views-f-lexical-routing" + ], + "note": "Views-on lexical acquisition projects verified v3 payloads into the existing read-only query engine. Views are default-off, so the benchmark runs the views-off path, whose acquisition, scoring, fusion, admission and paging are unchanged; ranked rows must remain byte-identical to the committed reference." +} diff --git a/benchmarks/aft-search/slice-descriptors/land-views-g-donors-callgraph.json b/benchmarks/aft-search/slice-descriptors/land-views-g-donors-callgraph.json new file mode 100644 index 0000000000..d39eb6b492 --- /dev/null +++ b/benchmarks/aft-search/slice-descriptors/land-views-g-donors-callgraph.json @@ -0,0 +1,15 @@ +{ + "slice_class": "engine_unwired", + "targeted_mechanism": "none", + "kind": "harness", + "fixtures": [ + "crates/aft/tests/integration/tool_call_parity_test.rs", + "crates/aft/src/search_index.rs", + "crates/aft/src/commands/semantic_search/mod.rs", + "crates/aft/src/commands/semantic_search/exact_lane.rs" + ], + "slices": [ + "views-g-donors-callgraph" + ], + "note": "Views-on call-graph reads route through the checkout's v3 view with sealed base donors. Step G touches no ranking-fence file; the fence files in this diff come from step F below it in the stack. Views are default-off, so the benchmark runs the views-off path and ranked rows must remain byte-identical to the committed reference." +} diff --git a/benchmarks/aft-search/slice-descriptors/land-views-steps-c-to-g.json b/benchmarks/aft-search/slice-descriptors/land-views-steps-c-to-g.json new file mode 100644 index 0000000000..6ef1700560 --- /dev/null +++ b/benchmarks/aft-search/slice-descriptors/land-views-steps-c-to-g.json @@ -0,0 +1,15 @@ +{ + "slice_class": "engine_unwired", + "targeted_mechanism": "none", + "kind": "harness", + "fixtures": [ + "crates/aft/tests/integration/tool_call_parity_test.rs", + "crates/aft/src/search_index.rs", + "crates/aft/src/commands/semantic_search/mod.rs", + "crates/aft/src/commands/semantic_search/exact_lane.rs" + ], + "slices": [ + "views-steps-c-to-g" + ], + "note": "Views cutover steps C, C part 2, E, D, F, tree-proven bases and G landed as one PR (each reviewed and gated on its own stacked PR, #997-#1026). Fence hits come from step F (lexical routing through views) and G (call-graph routing); with views off every route is unchanged, so ranked rows must stay byte-identical to the committed reference. The views-on parity tests compare grep, glob and search answers against the legacy index." +} diff --git a/benchmarks/aft-search/slice-descriptors/land-views-tree-proven-bases.json b/benchmarks/aft-search/slice-descriptors/land-views-tree-proven-bases.json new file mode 100644 index 0000000000..ebd574711e --- /dev/null +++ b/benchmarks/aft-search/slice-descriptors/land-views-tree-proven-bases.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..9529b867bd 100644 --- a/crates/aft/src/blob_store/v2.rs +++ b/crates/aft/src/blob_store/v2.rs @@ -44,6 +44,9 @@ pub const STORE_SCHEMA_VERSION: i64 = 1; /// Payload encoding version of trigram blobs. Bumping it must also change the /// trigram policy fingerprint, because that fingerprint is part of every key. pub const TRIGRAM_PAYLOAD_SCHEMA: u32 = 1; +/// Layout of a v3 symbol-plane payload (`views::symbol_store`). The symbol +/// plane exists only in v3; no v2 store is ever opened for it. +pub const SYMBOL_PAYLOAD_SCHEMA: u32 = 1; const STORE_SCHEMA: &str = r#" CREATE TABLE IF NOT EXISTS store_meta ( @@ -84,24 +87,37 @@ pub enum FamilyPlane { Trigram, Semantic, Callgraph, + /// Parsed symbols of one file, keyed by content, language and the + /// extraction schema. A v3-only plane: it has no manifest readiness and + /// no v2 store, so it is deliberately absent from [`Self::ALL`], which + /// the manifest, v2 GC, eviction and per-entry code iterate. The v3 + /// plane-row sweep covers it through [`Self::V3_STORES`]. + Symbol, } impl FamilyPlane { + /// The planes a manifest entry, a v2 family store and the v2 GC know. + /// Never add [`Self::Symbol`] here: `EntryPlanes::get_mut` has no field + /// for it and panics. pub const ALL: [Self; 3] = [Self::Trigram, Self::Semantic, Self::Callgraph]; + /// Every plane that can have a store under `blobs/v3//`. + pub const V3_STORES: [Self; 4] = [Self::Trigram, Self::Semantic, Self::Callgraph, Self::Symbol]; pub const fn as_str(self) -> &'static str { match self { Self::Trigram => "trigram", Self::Semantic => "semantic", Self::Callgraph => "callgraph", + Self::Symbol => "symbol", } } - 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, Self::Callgraph => crate::blob_store::CALLGRAPH_PAYLOAD_SCHEMA, + Self::Symbol => SYMBOL_PAYLOAD_SCHEMA, } } } @@ -347,6 +363,7 @@ pub type StoreResult = Result; pub struct StoreWriteAccess { storage: PathBuf, family: String, + legacy_allowed: bool, } impl StoreWriteAccess { @@ -355,12 +372,20 @@ impl StoreWriteAccess { Self { storage: storage.to_path_buf(), family: family.to_owned(), + legacy_allowed: true, } } pub fn storage(&self) -> &Path { &self.storage } + pub(crate) fn v3_only(storage: &Path, family: &str) -> Self { + Self { + storage: storage.to_path_buf(), + family: family.to_owned(), + legacy_allowed: false, + } + } pub fn family(&self) -> &str { &self.family @@ -426,6 +451,11 @@ pub struct FamilyStoreReader { impl FamilyStore { /// Opens (creating when absent) `/blobs/v2//.sqlite`. pub fn open(access: &StoreWriteAccess, plane: FamilyPlane) -> StoreResult { + if !access.legacy_allowed { + return Err(StoreError::InvalidFamily( + "v3-only registration cannot open a v2 writer".into(), + )); + } let path = plane_path(&access.storage, &access.family, plane)?; if let Some(parent) = path.parent() { crate::private_storage::open_dir(&access.storage, parent)?; @@ -1892,6 +1922,20 @@ mod tests { .family_key() } + #[test] + fn symbol_is_not_a_manifest_plane() { + // `EntryPlanes::get_mut` panics for Symbol because a manifest entry + // has no symbol state. That panic is unreachable only while every + // list the manifest, v2 store, GC, eviction and per-entry code + // iterates (`FamilyPlane::ALL`) excludes the v3-only symbol plane. + assert!(!FamilyPlane::ALL.contains(&FamilyPlane::Symbol)); + assert!(FamilyPlane::V3_STORES.contains(&FamilyPlane::Symbol)); + for plane in FamilyPlane::ALL { + assert!(FamilyPlane::V3_STORES.contains(&plane)); + } + assert_eq!(FamilyPlane::Symbol.as_str(), "symbol"); + } + #[test] fn a_conflicting_payload_for_one_key_is_rejected_and_the_stored_payload_is_kept() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/aft/src/blob_store/v3.rs b/crates/aft/src/blob_store/v3.rs index ea4e4dc7b0..ef6d118a33 100644 --- a/crates/aft/src/blob_store/v3.rs +++ b/crates/aft/src/blob_store/v3.rs @@ -24,6 +24,10 @@ pub const LAYOUT_TAG: u32 = 2; pub const REGISTRY_APPLICATION_ID: i64 = 0x41465452; pub const PLANE_APPLICATION_ID: i64 = 0x41465450; pub const NAMESPACE_BUDGET: u64 = 4 * 1024 * 1024 * 1024; +/// Sweep epochs a symbol-plane row survives without being touched. Symbol +/// rows are a reusable cache that no manifest names, so the plane-row sweep +/// ages them out by epoch instead of by marking. +pub const SYMBOL_RETENTION_SWEEPS: i64 = 8; const TIMEOUT_MS: i64 = 5_000; mod connections; mod copy; @@ -42,7 +46,16 @@ 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; +pub const REGISTRY_VERIFICATION_VERSION: u32 = 3; +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 +70,49 @@ 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!()); +macro_rules! view_tokens_schema { + () => { + "CREATE TABLE view_tokens (nonce TEXT NOT NULL PRIMARY KEY, root BLOB NOT NULL, + expected_tree TEXT NOT NULL, minted_ms INTEGER NOT NULL, expires_ms INTEGER NOT NULL, + bound_root BLOB, bound_gitdir BLOB, consumed_ms INTEGER, + CHECK((bound_root IS NULL AND bound_gitdir IS NULL AND consumed_ms IS NULL) OR + (bound_root IS NOT NULL AND bound_gitdir IS NOT NULL AND consumed_ms IS NOT NULL)));" + }; +} +const VIEW_TOKENS_SCHEMA: &str = view_tokens_schema!(); +const REGISTRY_SCHEMA_V3: &str = concat!( + registry_v1_schema!(), + base_refs_schema!(), + view_tokens_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 +206,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 +245,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 +499,14 @@ 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::Registry, 3, 3) if layout == 0 => Some(REGISTRY_SCHEMA_V3), + _ => kind.schema_for(layout), + }; + let Some(compiled_schema) = compiled_schema else { return Err(incompatible( kind, path, @@ -418,8 +517,23 @@ 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, + ) + || (version, schema) + == ( + u64::from(REGISTRY_VERIFICATION_VERSION), + REGISTRY_VERIFICATION_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 +659,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 +881,93 @@ 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}") + || (version, schema) == (REGISTRY_VERIFICATION_VERSION, REGISTRY_VERIFICATION_VERSION) + && text == format!("{META}{REGISTRY_SCHEMA_V3}") + { + 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(()) + }) + } + /// Adds only the registered v3 token protocol; plane stores and legacy + /// registries retain their own versions and schema. + pub fn upgrade_to_verification(&mut self) -> Result<()> { + self.upgrade_to_base_refs()?; + self.with_barrier(|tx| { + let version: u32 = tx.query_row("SELECT format_version FROM format_meta WHERE singleton=1", [], |r| r.get(0))?; + if version == REGISTRY_VERIFICATION_VERSION { return Ok(()); } + tx.execute_batch(VIEW_TOKENS_SCHEMA)?; + tx.execute("UPDATE format_meta SET format_version=?1,schema_version=?1,schema_text=?2 WHERE singleton=1", + params![REGISTRY_VERIFICATION_VERSION, format!("{META}{REGISTRY_SCHEMA_V3}")])?; + Ok(()) + }) + } fn connection(&self) -> Result> { self.database.connection() } @@ -927,6 +1146,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 +1198,7 @@ struct RegistryIdentity { path: PathBuf, file: Option, } +#[derive(Debug)] pub struct Reservation { id: i64, before: u64, @@ -1054,6 +1313,186 @@ 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() + }; + // Every v3 plane store, including the v3-only symbol plane. + for plane in FamilyPlane::V3_STORES { + 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![collect_below, 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, collect_below])? 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)))? +} +/// Raises the verification floor before any token or stat-evidence bytes exist. +pub fn open_verification_registry<'a>( + storage: &Path, + family: &str, + lock: &'a ReservationLock, +) -> Result> { + crate::reader_floor::publish_v3_with_floor( + storage, + REGISTRY_VERIFICATION_VERSION, + FORMAT_VERSION, + || { + let mut registry = open_base_refs_registry(storage, family, lock, None)?; + registry.upgrade_to_verification()?; + Ok(registry) + }, + ) + .map_err(|e| Error::Io(std::io::Error::other(e)))? +} + fn publish_under_lock( storage: &Path, family: &str, @@ -1183,6 +1622,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())?; @@ -1259,6 +1732,7 @@ pub fn v3_format_report(path: &Path) -> Result { Some("semantic") => Kind::Plane(FamilyPlane::Semantic), Some("callgraph") => Kind::Plane(FamilyPlane::Callgraph), Some("trigram") => Kind::Plane(FamilyPlane::Trigram), + Some("symbol") => Kind::Plane(FamilyPlane::Symbol), _ => return Err(Error::InvalidIdentity), }; let database = connections::shared(path, kind, false, true)?; @@ -1292,6 +1766,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 +1796,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..d568cee51e 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:?}"), } } } @@ -857,13 +859,15 @@ fn newer_format_keeps_artifact_refusal_and_malformed_file_reports_found_zero() { let root = tempdir().unwrap(); seed(root.path(), A); let path = db(root.path(), A); + let future = REGISTRY_VERIFICATION_VERSION + 1; Connection::open(&path) .unwrap() - .execute("UPDATE format_meta SET format_version=2", []) + // Keep testing a future registry, not drift within a supported version. + .execute("UPDATE format_meta SET format_version=?1", [future]) .unwrap(); match LedgerReader::open_existing(root.path(), A).err().unwrap() { Error::Incompatible(error) => { - assert_eq!(error.found, 2); + assert_eq!(error.found, u64::from(future)); assert_eq!(error.source, persisted_format::RefusalSource::Artifact); } error => panic!("{error}"), diff --git a/crates/aft/src/callgraph_store/mod.rs b/crates/aft/src/callgraph_store/mod.rs index a013e5b23f..3ad8e31c09 100644 --- a/crates/aft/src/callgraph_store/mod.rs +++ b/crates/aft/src/callgraph_store/mod.rs @@ -9277,7 +9277,12 @@ impl ReadonlyCallGraphStore { ) -> Result { let sqlite_path = crate::views::resolve_derived_path(&view_dir, generation) .map_err(|e| CallGraphStoreError::Unavailable(e.to_string()))?; - let conn = open_readonly_connection(&sqlite_path)?; + // A sealed donor never changes; open it without locks or a WAL. + let conn = if crate::views::donor::is_sealed(&sqlite_path) { + open_sealed_connection(&sqlite_path)? + } else { + open_readonly_connection(&sqlite_path)? + }; ensure_database_ready(&conn)?; configure_serving_reader(&conn)?; let mut inner = CallGraphStore::from_connection( @@ -11893,6 +11898,21 @@ fn open_readonly_connection(path: &Path) -> Result { open_readonly_connection_before(path, Instant::now()) } +/// A sealed donor (see `views::donor`) is opened with `immutable=1`: SQLite +/// then takes no locks and never looks for a WAL, which is what lets a +/// worker clone the file with a plain descriptor. +fn open_sealed_connection(path: &Path) -> Result { + crate::private_storage::prepare_sqlite(path, OpenFlags::SQLITE_OPEN_READ_ONLY)?; + let uri = format!("{}&immutable=1", sqlite_readonly_uri(path)); + let conn = TrackedConnection::open_with_flags( + &uri, + OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_URI, + SqliteStore::CallgraphGeneration, + )?; + conn.execute_batch("PRAGMA query_only=ON;")?; + Ok(conn) +} + fn open_readonly_connection_before(path: &Path, deadline: Instant) -> Result { crate::private_storage::prepare_sqlite(path, OpenFlags::SQLITE_OPEN_READ_ONLY)?; let uri = sqlite_readonly_uri(path); diff --git a/crates/aft/src/cli/prune_legacy.rs b/crates/aft/src/cli/prune_legacy.rs index 3a6eb9cefb..677be0c9c1 100644 --- a/crates/aft/src/cli/prune_legacy.rs +++ b/crates/aft/src/cli/prune_legacy.rs @@ -20,7 +20,10 @@ fn usage() -> String { Lists every legacy (pre-view) index set under the AFT storage root with its bytes, and with --yes deletes the sets whose import into per-checkout views -completed at least {days} days ago. Without --yes it is a dry run. +or into the v3 stores completed at least {days} days ago. A set that a running +import or a v3 base build may still read is kept. Older v1 views and blob +stores and v2 family stores are listed as retained and never deleted. Without +--yes it is a dry run. {OPERATOR_CONTRACT} diff --git a/crates/aft/src/commands/callgraph_borrowed.rs b/crates/aft/src/commands/callgraph_borrowed.rs index d105281e97..5304bf594a 100644 --- a/crates/aft/src/commands/callgraph_borrowed.rs +++ b/crates/aft/src/commands/callgraph_borrowed.rs @@ -276,9 +276,12 @@ pub fn disclose_borrowed_answer( /// Describe the borrowed callgraph this context answers from, or `None` when /// callgraph answers come from this checkout's own data: the owner checkout, -/// a per-checkout query runtime, or a pinned per-HEAD view. +/// a per-checkout query runtime, a checkout's v3 view or a pinned per-HEAD view. pub fn borrowed_callgraph(ctx: &AppContext) -> Option { - if !ctx.shared_artifacts_read_only() || ctx.checkout_query_runtime_active() { + if !ctx.shared_artifacts_read_only() + || ctx.checkout_query_runtime_active() + || ctx.callgraph_views_enabled() + { return None; } if ctx.config().views.enabled diff --git a/crates/aft/src/commands/callgraph_store_adapter.rs b/crates/aft/src/commands/callgraph_store_adapter.rs index 0f8b41376e..4d3a2171df 100644 --- a/crates/aft/src/commands/callgraph_store_adapter.rs +++ b/crates/aft/src/commands/callgraph_store_adapter.rs @@ -3058,10 +3058,30 @@ fn content_matches_store( }; Ok( stored == crate::cache_freshness::zero_hash().to_hex().as_str() - || stored == current.to_hex().as_str(), + || stored == current.to_hex().as_str() + || stored == view_blob_key(file, current).unwrap_or_default(), ) } +/// A view-materialized graph records each file's call-graph blob key rather +/// than its raw content hash. The key is derived from the content hash, the +/// file's language and the extractor version, so the current bytes match +/// exactly when they derive the stored key. +fn view_blob_key(file: &str, current: blake3::Hash) -> Option { + let language = if crate::views::assembly::is_resolution_input(file.as_bytes()) { + "config".to_owned() + } else { + format!("{:?}", crate::parser::detect_language(Path::new(file))?).to_lowercase() + }; + let key = crate::blob_store::CallgraphKey::from_source_digest( + *current.as_bytes(), + language, + crate::views::callgraph::PRODUCER, + ) + .full_key(); + Some(crate::blob_store::v2::FamilyKey::from(&key).to_hex()) +} + fn file_matches_store( store: &impl CallGraphRead, file: &str, diff --git a/crates/aft/src/commands/configure.rs b/crates/aft/src/commands/configure.rs index 6fb5bf52eb..e7d60fed08 100644 --- a/crates/aft/src/commands/configure.rs +++ b/crates/aft/src/commands/configure.rs @@ -4877,7 +4877,7 @@ fn handle_configure_inner( &canonical_cache_root, )); ctx.begin_configure_ack_phase("index_loading_state"); - let search_index = ctx.config().indexes.trigram; + let search_index = ctx.config().indexes.trigram && !ctx.config().views.enabled; let semantic_search = ctx.config().indexes.semantic; let mut search_index_cache_reused = false; @@ -5288,7 +5288,7 @@ fn semantic_status_is_not_opened(ctx: &AppContext) -> bool { fn missing_artifact_loads(ctx: &AppContext) -> ArtifactLoadNeeds { let config = ctx.config(); - let search_enabled = config.indexes.trigram; + let search_enabled = config.indexes.trigram && !config.views.enabled; let semantic_enabled = config.indexes.semantic; drop(config); let semantic_admitted = semantic_enabled && semantic_work_admitted(ctx); @@ -5424,6 +5424,9 @@ fn start_artifact_loads(starts: ArtifactLoadStarts) -> bool { /// dropped. Without the overlay, a read-only root serves the shared snapshot /// as-is. pub(crate) fn trigger_search_index_reload_if_evicted(ctx: &AppContext) -> bool { + if ctx.config().views.enabled { + return false; + } if ctx.canonical_cache_root_opt().is_none() || !ctx.search_index_query_reload_allowed() { return false; } @@ -5676,6 +5679,9 @@ fn schedule_artifact_loads_admitted( load_semantic: bool, semantic_view_work_allowed: bool, ) -> ArtifactLoadStarts { + // With views enabled, skip both loading another checkout's SearchIndex + // cache and building a new legacy search cache. + let load_search = load_search && !ctx.config().views.enabled; if let Some(reason) = ctx.cache_unavailable_reason() { let code = crate::root_cache::cache_status_code(&reason); *ctx.semantic_index_status() @@ -5769,6 +5775,7 @@ fn schedule_artifact_loads_admitted( let search_rx_terminal_guard = ctx.search_index_rx_terminal_guard(search_rx_epoch); let search_pending_install = ctx.search_pending_install(); let search_persist_epoch_flag = ctx.search_persist_epoch_flag(); + let search_publication_gate = ctx.legacy_search_publication_gate(); let (start_tx, start_rx) = crossbeam_channel::bounded::<()>(1); search_artifact_load_start = Some(start_tx); @@ -5776,6 +5783,7 @@ fn schedule_artifact_loads_admitted( mark_search_rebuild_spawn_for_debug(); thread::spawn(move || { let _terminal_guard = search_rx_terminal_guard; + let _publication = search_publication_gate.install(); if start_rx.recv().is_err() { #[cfg(test)] note_configure_artifact_load_cancellation_for_test(); @@ -6004,14 +6012,14 @@ fn schedule_artifact_loads_admitted( if search_persist_epoch_flag.current() != search_persist_epoch { return; } - let index = SearchIndex::rebuild_or_refresh_with_strategy( + let Some(index) = SearchIndex::build_with_limit_to_cache_dir_cancellable( &root_for_search, search_index_max_file_size, - current_head, - None, - Some(&cache_dir), - VerifyStrategy::StatFirst, - ); + &cache_dir, + &|| search_publication_gate.allowed(), + ) else { + return; + }; delay_search_rebuild_publish_for_debug(); index }; @@ -8596,10 +8604,17 @@ fn run_configure_maintenance_unit_inner( && ctx.config().views.enabled && !job.root_too_broad { - finish_configure_view_load( - ctx, - prepare_configure_view(&ConfigureViewInput::capture(ctx, job), job), - ); + if ctx.config().indexes.semantic || ctx.config().indexes.callgraph { + finish_configure_view_load( + ctx, + prepare_configure_view(&ConfigureViewInput::capture(ctx, job), job), + ); + } else { + ctx.clear_view_runtime(); + } + if let Some(Err(error)) = ctx.lexical_view_snapshot("bind") { + slog_warn!("lexical checkout view unavailable: {}", error); + } } else { ctx.clear_view_runtime(); } @@ -11445,9 +11460,11 @@ mod tests { .pending_inputs .values() .any(|input| { input.reason == crate::views::read::CALLGRAPH_READINESS_BUSY })); + // The v1 publication's locked database no longer stands between a + // query and its graph: the checkout's own view answers. assert!(matches!( ctx.callgraph_store_for_ops(), - CallgraphStoreAccess::Building + CallgraphStoreAccess::Ready(_) )); let health = ctx.view_health_snapshot().unwrap(); assert_eq!( @@ -11984,31 +12001,6 @@ mod tests { assert_eq!(projected, dead_code(cold)); } - /// Wait for the publication that builds the worktree's own graph. - fn lazy_wait_for_own_graph(ctx: &AppContext) { - let deadline = Instant::now() + Duration::from_secs(180); - loop { - let built = ctx - .view_runtime_snapshot() - .and_then(|view| { - let generation = view.generation?; - Some(crate::views::read::derived_built( - &view.view_dir, - &generation, - )) - }) - .unwrap_or(false); - if built && !crate::executor::view_publication::running_for(ctx) { - return; - } - assert!( - Instant::now() < deadline, - "the on-demand call-graph build did not finish" - ); - std::thread::sleep(Duration::from_millis(20)); - } - } - /// Edit `lib.rs` in the worktree and deliver the change through the /// watcher drain, without publishing it yet. fn lazy_edit_through_watcher(fixture: &LazyWorktree, contents: &str) { @@ -12182,6 +12174,16 @@ mod tests { }; drop(ctx); crate::views::wait_for_derived_checkpoint_for_test(&lazy_view_dir(storage, root)); + // The checkout's v3 views end their registry sessions and settle their + // checkpoints on short-lived detached threads; a real stop ends the + // process. Give those threads a bounded moment to close. + let v3 = storage.join("blobs").join("v3"); + let deadline = Instant::now() + Duration::from_secs(10); + while Instant::now() < deadline + && !crate::db::file_identity::open_connections_under_for_test(&v3).is_empty() + { + std::thread::sleep(Duration::from_millis(10)); + } let held = crate::db::file_identity::open_connections_under_for_test(storage); assert!( held.is_empty(), @@ -12814,111 +12816,11 @@ mod tests { "both files were extracted again" ); - // Rebuilding from scratch flushes its database before bind returns, so - // there is no background checkpoint to pause. Publish an edit below and - // pause that generation's checkpoint with a real SQLite writer. - // An exclusive rollback-journal transaction makes short-lived lock - // contention observable without racing the scheduler. - enum CheckpointObservation { - Waiting, - Answer(std::thread::Result<(String, String)>), - } - let (begin_tx, begin_rx) = std::sync::mpsc::channel(); - let (locked_tx, locked_rx) = std::sync::mpsc::channel(); - let (release_tx, release_rx) = std::sync::mpsc::channel(); - let (observation_tx, observation_rx) = std::sync::mpsc::channel(); - let waiting_tx = observation_tx.clone(); - crate::views::checkpoint_test_hooks() - .lock() - .unwrap() - .insert( - view_dir.clone(), - crate::views::CheckpointTestHooks { - before_settled: Some(Box::new(move |path| { - begin_rx.recv_timeout(Duration::from_secs(60)).unwrap(); - let connection = crate::db::file_identity::IdentityConnection::open( - path, - "checkpoint contention fixture", - ) - .unwrap(); - let journal: String = connection - .query_row("PRAGMA journal_mode=DELETE", [], |row| row.get(0)) - .unwrap(); - assert_eq!(journal, "delete"); - connection.execute_batch("BEGIN EXCLUSIVE;").unwrap(); - locked_tx.send(path.to_path_buf()).unwrap(); - let _ = release_rx.recv_timeout(Duration::from_secs(60)); - connection - .execute_batch("ROLLBACK; PRAGMA journal_mode=WAL;") - .unwrap(); - })), - before_wait: Some(Box::new(move || { - waiting_tx.send(CheckpointObservation::Waiting).unwrap(); - })), - }, - ); - let source = fs::read_to_string(root.join("lib.rs")).unwrap(); - fs::write( - root.join("lib.rs"), - format!("{source}\n// checkpoint synchronization fixture\n"), - ) - .unwrap(); - let checkpoint_publication = ctx - .publish_view_paths(BTreeSet::from([b"lib.rs".to_vec()]), false) - .unwrap(); - assert!( - checkpoint_publication.published, - "{checkpoint_publication:?}" - ); - let generation = checkpoint_publication - .generation - .expect("incremental checkpoint generation"); - begin_tx.send(()).unwrap(); - let locked_path = locked_rx.recv_timeout(Duration::from_secs(60)).unwrap(); - assert_eq!( - locked_path, - crate::views::resolve_derived_path(&view_dir, &generation).unwrap() - ); - let request: RawRequest = serde_json::from_value(serde_json::json!({ - "id": "checkpoint-busy", "command": "callers", "session_id": "after-removal", - "file": "lib.rs", "symbol": "target" - })) - .unwrap(); - let busy = crate::commands::callers::handle_callers(&request, &ctx); - assert!(!busy.success, "{busy:?}"); - assert_eq!(busy.data["code"], "callgraph_building", "{busy:?}"); - let reader_ctx = Arc::clone(&ctx); - let reader = std::thread::spawn(move || { - let answer = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - blob_removal_answers(&reader_ctx) - })); - observation_tx - .send(CheckpointObservation::Answer(answer)) - .unwrap(); - }); - assert!( - matches!( - observation_rx - .recv_timeout(Duration::from_secs(60)) - .unwrap(), - CheckpointObservation::Waiting - ), - "the fixture read before the checkpoint completion event" - ); - release_tx.send(()).unwrap(); - let CheckpointObservation::Answer(answer) = observation_rx - .recv_timeout(Duration::from_secs(60)) - .unwrap() - else { - panic!("checkpoint waiter did not answer"); - }; - reader.join().unwrap(); - crate::views::checkpoint_test_hooks() - .lock() - .unwrap() - .remove(&view_dir); + // With views on, answers come from the checkout's own v3 view; the v1 + // publication's checkpoint no longer stands between a query and its + // graph. The rebuilt checkout answers exactly as before. assert_eq!( - answer.unwrap(), + blob_removal_answers(&ctx), ( fixture.callers_before.clone(), fixture.call_tree_before.clone() @@ -12946,63 +12848,7 @@ mod tests { } #[test] - fn linked_worktree_answers_from_main_graph_without_building_its_own() { - let _artifact_guard = artifact_owner_test_lock(); - let _git_env = crate::test_env::hermetic_git_env_guard(); - let _disable_watcher = EnvVarGuard::set("AFT_TEST_DISABLE_FILE_WATCHER", "1"); - let fixture = lazy_worktree(true); - let view_dir = lazy_view_dir(&fixture.storage, &fixture.linked); - let view = crate::views::ViewStore::open( - &fixture.storage, - &crate::path_identity::project_scope_key(&fixture.linked), - ) - .unwrap(); - assert!( - view.current_generation().unwrap().is_some(), - "the worktree's view is still published at bind" - ); - assert_eq!(lazy_derived_files(&view_dir), Vec::::new()); - - let store = lazy_ready(fixture.borrower.callgraph_store_for_ops()); - assert!( - store - .sqlite_path() - .starts_with(lazy_view_dir(&fixture.storage, &fixture.main)), - "answered from {:?}, not the main checkout's graph", - store.sqlite_path() - ); - let (_cold_storage, cold) = lazy_cold_store(&fixture.linked); - lazy_assert_same_graph(&store, &cold); - // Inspect's Tier-2 categories, such as dead code, also read the main - // checkout's graph for this worktree. - let (inspect_store, _) = crate::inspect::current_view_projection_store_for_test( - &fixture.linked, - &fixture.borrower.config(), - ) - .expect("inspect reads the main checkout's graph for the worktree"); - lazy_assert_same_graph(&inspect_store, &cold); - let answer = crate::commands::callers::handle_callers( - &RawRequest { - id: "lazy-callers".into(), - command: "callers".into(), - session_id: Some("lazy-linked".into()), - lsp_hints: None, - params: json!({ "file": fixture.linked.join("lib.rs"), "symbol": "target" }), - }, - &fixture.borrower, - ); - assert!(answer.success, "{}", answer.data); - assert_eq!(answer.data["total_callers"], 1, "{}", answer.data); - - assert_eq!(lazy_derived_files(&view_dir), Vec::::new()); - assert_eq!( - fixture.borrower.lazy_callgraph_builds_scheduled_for_test(), - 0 - ); - } - - #[test] - fn linked_worktree_edit_reports_building_then_answers_from_its_own_graph() { + fn linked_worktree_edit_answers_from_its_own_view_at_once() { let _artifact_guard = artifact_owner_test_lock(); let _git_env = crate::test_env::hermetic_git_env_guard(); let _disable_watcher = EnvVarGuard::set("AFT_TEST_DISABLE_FILE_WATCHER", "1"); @@ -13012,26 +12858,8 @@ mod tests { "pub fn caller() { target(); }\npub fn edited_caller() { target(); }\npub fn target() {}\nfn unused() {}\n", ); - // The edit is not published yet, but main's graph no longer answers. - let access = { - let _scope = crate::executor::install_standalone_scope(Arc::clone(&fixture.borrower)); - fixture.borrower.callgraph_store_for_ops() - }; - assert!(matches!(access, CallgraphStoreAccess::Building)); - assert_eq!( - fixture.borrower.lazy_callgraph_builds_scheduled_for_test(), - 1 - ); - lazy_wait_for_own_graph(&fixture.borrower); - - let store = lazy_ready(fixture.borrower.callgraph_store_for_ops()); - assert!( - store - .sqlite_path() - .starts_with(lazy_view_dir(&fixture.storage, &fixture.linked)), - "answered from {:?}, not the worktree's own graph", - store.sqlite_path() - ); + // The edit is not published yet; the worktree's own view answers with it. + let store = lazy_query_builds_own_graph(&fixture); let (_cold_storage, cold) = lazy_cold_store(&fixture.linked); lazy_assert_same_graph(&store, &cold); assert!(crate::callgraph_store::CallGraphRead::node_for( @@ -13040,10 +12868,6 @@ mod tests { "edited_caller" ) .is_ok()); - assert_eq!( - fixture.borrower.lazy_callgraph_builds_scheduled_for_test(), - 1 - ); } #[test] @@ -13129,7 +12953,15 @@ mod tests { let owner = fixture.owner.as_ref().unwrap(); assert_eq!(owner.lazy_callgraph_builds_scheduled_for_test(), 0); let store = lazy_ready(owner.callgraph_store_for_ops()); - assert!(store.sqlite_path().starts_with(&view_dir)); + // Queries read the checkout's own v3 call-graph view. + assert_eq!(store.reader_kind(), "view"); + assert!( + store + .sqlite_path() + .starts_with(fixture.storage.join("blobs").join("v3")), + "{:?}", + store.sqlite_path() + ); } #[test] @@ -13138,8 +12970,8 @@ mod tests { let _git_env = crate::test_env::hermetic_git_env_guard(); let _disable_watcher = EnvVarGuard::set("AFT_TEST_DISABLE_FILE_WATCHER", "1"); let fixture = lazy_worktree(true); - // A change the watcher saw means main's graph cannot answer. - fixture.borrower.note_checkout_changed_for_callgraph(); + // Concurrent first queries are serialized on the checkout view: both + // answer, and the shared base graph is built and sealed only once. let barrier = Arc::new(std::sync::Barrier::new(2)); let queries = (0..2) .map(|_| { @@ -13150,32 +12982,28 @@ mod tests { barrier.wait(); matches!( ctx.callgraph_store_for_ops(), - CallgraphStoreAccess::Building + CallgraphStoreAccess::Ready(_) ) }) }) .collect::>(); for query in queries { - assert!(query.join().unwrap(), "a first query must report building"); + assert!(query.join().unwrap(), "a first query must answer"); } - assert_eq!( - fixture.borrower.lazy_callgraph_builds_scheduled_for_test(), - 1 - ); - lazy_wait_for_own_graph(&fixture.borrower); - assert_eq!( - fixture.borrower.lazy_callgraph_builds_scheduled_for_test(), - 1 - ); + let sealed = crate::views::donor::events_under(&fixture.storage) + .into_iter() + .filter(|event| *event == crate::views::donor::DonorEvent::Sealed) + .count(); + assert_eq!(sealed, 1, "one base graph build for both queries"); let store = lazy_ready(fixture.borrower.callgraph_store_for_ops()); - assert!(store - .sqlite_path() - .starts_with(lazy_view_dir(&fixture.storage, &fixture.linked))); + assert_eq!(store.reader_kind(), "view"); + let (_cold_storage, cold) = lazy_cold_store(&fixture.linked); + lazy_assert_same_graph(&store, &cold); } /// Query the worktree's call graph under a publication scope, as a daemon - /// request does: it must report building, never main's rows. Then wait for - /// the build and return the worktree's own graph. + /// request does. With views on it answers at once from the worktree's own + /// checkout view (its own v3 member), never from main's graph. fn lazy_query_builds_own_graph( fixture: &LazyWorktree, ) -> Arc { @@ -13183,20 +13011,14 @@ mod tests { let _scope = crate::executor::install_standalone_scope(Arc::clone(&fixture.borrower)); fixture.borrower.callgraph_store_for_ops() }; - if let CallgraphStoreAccess::Ready(store) = &access { - panic!( - "the first query answered before any build, from {:?}", - store.sqlite_path() - ); - } - assert!(matches!(access, CallgraphStoreAccess::Building)); - lazy_wait_for_own_graph(&fixture.borrower); - let store = lazy_ready(fixture.borrower.callgraph_store_for_ops()); + let store = lazy_ready(access); + assert_eq!(store.reader_kind(), "view"); + assert_eq!(store.project_root(), fixture.linked); assert!( - store + !store .sqlite_path() - .starts_with(lazy_view_dir(&fixture.storage, &fixture.linked)), - "answered from {:?}, not the worktree's own graph", + .starts_with(lazy_view_dir(&fixture.storage, &fixture.main)), + "answered from main's graph {:?}", store.sqlite_path() ); store @@ -13225,14 +13047,6 @@ mod tests { "branch_caller" ) .is_ok()); - // aft_inspect's dead-code check must also read the worktree's own view, not main's graph. - assert!(crate::inspect::current_view_projection_store_for_test( - &fixture.linked, - &fixture.borrower.config(), - ) - .is_some_and(|(store, _)| store - .sqlite_path() - .starts_with(lazy_view_dir(&fixture.storage, &fixture.linked)))); } #[test] @@ -13241,13 +13055,8 @@ mod tests { let _git_env = crate::test_env::hermetic_git_env_guard(); let _disable_watcher = EnvVarGuard::set("AFT_TEST_DISABLE_FILE_WATCHER", "1"); let fixture = lazy_worktree(true); - // The worktree matches main's generation, so main answers and that - // verdict is cached. - let store = lazy_ready(fixture.borrower.callgraph_store_for_ops()); - assert!(store - .sqlite_path() - .starts_with(lazy_view_dir(&fixture.storage, &fixture.main))); - drop(store); + // The worktree matches main's generation and answers from its own view. + drop(lazy_query_builds_own_graph(&fixture)); // Main moves on: a new commit removes the caller and main publishes a // generation with different inputs. lazy_commit( @@ -13339,71 +13148,6 @@ mod tests { lazy_assert_same_graph(&store, &cold); } - /// Hold an exclusive write transaction on `root`'s current derived - /// call-graph database, so a reader without a busy wait gets - /// "database is locked" until the returned connection rolls back. - fn lazy_lock_derived( - storage: &Path, - root: &Path, - ) -> crate::db::file_identity::IdentityConnection { - let (generation, built) = lazy_current_generation(storage, root); - assert!(built, "{root:?} has a derived call graph to lock"); - let view = - crate::views::ViewStore::open(storage, &crate::path_identity::project_scope_key(root)) - .unwrap(); - crate::views::wait_for_derived_checkpoint_for_test(view.view_dir()); - let writer = crate::db::file_identity::IdentityConnection::open( - &view.derived_path(&generation).unwrap(), - "lazy_callgraph_lock_test", - ) - .unwrap(); - // In WAL mode a writer never blocks readers; the rollback journal makes - // the exclusive lock visible to them, as a checkpoint resetting the WAL - // or the last connection closing it is. - writer - .execute_batch("PRAGMA journal_mode=DELETE; BEGIN EXCLUSIVE;") - .unwrap(); - writer - } - - #[test] - fn linked_worktree_waits_for_a_locked_main_graph_instead_of_building() { - let _artifact_guard = artifact_owner_test_lock(); - let _git_env = crate::test_env::hermetic_git_env_guard(); - let _disable_watcher = EnvVarGuard::set("AFT_TEST_DISABLE_FILE_WATCHER", "1"); - let fixture = lazy_worktree(true); - // Main's publication holds its derived database while the worktree, - // whose files match main's, asks for callers. - let writer = lazy_lock_derived(&fixture.storage, &fixture.main); - match fixture.borrower.callgraph_store_for_ops() { - CallgraphStoreAccess::Building => {} - CallgraphStoreAccess::Ready(store) => panic!( - "a locked main graph must report building, not answer from {:?}", - store.sqlite_path() - ), - CallgraphStoreAccess::Error(error) => { - panic!("a locked main graph failed the query: {error}") - } - _ => panic!("a locked main graph must report building"), - } - // A lock is no verdict: no build started, and main answers once free. - assert!(fixture.borrower.defer_callgraph_build()); - assert_eq!( - fixture.borrower.lazy_callgraph_builds_scheduled_for_test(), - 0 - ); - assert_eq!( - lazy_derived_files(&lazy_view_dir(&fixture.storage, &fixture.linked)), - Vec::::new() - ); - writer.execute_batch("ROLLBACK").unwrap(); - drop(writer); - let store = lazy_ready(fixture.borrower.callgraph_store_for_ops()); - assert!(store - .sqlite_path() - .starts_with(lazy_view_dir(&fixture.storage, &fixture.main))); - } - #[test] fn build_key_memo_does_not_bypass_first_configure_identity_bind() { let _git_env = crate::test_env::hermetic_git_env_guard(); @@ -13508,7 +13252,7 @@ mod tests { } #[test] - fn views_restart_dirty_source_reports_working_tree_refresh_until_publication() { + fn views_restart_dirty_source_answers_the_working_tree_at_once() { let _git_env = crate::test_env::hermetic_git_env_guard(); let _disable_watcher = EnvVarGuard::set("AFT_TEST_DISABLE_FILE_WATCHER", "1"); let project = tempfile::tempdir().unwrap(); @@ -13568,18 +13312,8 @@ mod tests { params: json!({"file": root.join("tracked.rs"), "symbol": "dirty"}), }; let response = crate::commands::callers::handle_callers(&request, &ctx); - assert_eq!(response.data["code"], "callgraph_building", "{response:?}"); - assert_eq!(response.data["progress"]["phase"], "working_tree_refresh"); - assert_eq!(response.data["progress"]["pending_paths"], 1); - assert!(response.data["progress"]["waiting_on"] - .to_string() - .contains("view publication")); - assert!( - ctx.publish_view_paths(BTreeSet::new(), true) - .unwrap() - .published - ); - let response = crate::commands::callers::handle_callers(&request, &ctx); + // The checkout view verifies the working tree for the query itself, so + // the dirty source answers at once, without waiting for publication. assert!(response.success, "{response:?}"); assert_eq!(response.data["total_callers"], 1); } @@ -14532,9 +14266,12 @@ mod tests { "project_root": canonical_root, "storage_dir": storage.path(), "harness": "opencode", + // The views runtime, which runs the import, loads only when a + // semantic or call-graph index is enabled; the lexical view alone + // needs no runtime. "config": [user_tier(json!({ "views": { "enabled": true }, - "indexes": { "trigram": false, "semantic": false, "callgraph": false } + "indexes": { "trigram": false, "semantic": false, "callgraph": true } }))] }); @@ -15262,21 +14999,30 @@ mod tests { assert!(ctx.cache_unavailable_reason().is_none()); assert!(ctx.cache_dir().ends_with("offline-volume/aft")); let _ = ctx.flush_search_index_on_graceful_shutdown(); - assert!(ctx - .search_index() - .read() - .unwrap() - .as_ref() - .is_some_and(|index| index.ready)); + // With views on, the legacy search index is never built or written: + // the checkout's lexical view is the trigram cache, and it must be + // usable and persisted under the recovered cache directory. + let lexical = ctx.lexical_view_snapshot("grep"); + assert!( + matches!(lexical, Some(Ok(_))), + "{:?}", + lexical.map(|result| result.err()) + ); let key = ctx.memoized_artifact_cache_key(&ctx.canonical_cache_root()); assert!( ctx.cache_dir() - .join("index") - .join(key) - .join("cache.bin") + .join("blobs/v3") + .join(&key) + .join("trigram.sqlite") .is_file(), "normal cold initialization must persist the recovered trigram cache" ); + assert!(!ctx + .cache_dir() + .join("index") + .join(key) + .join("cache.bin") + .exists()); assert_eq!( ctx.try_health_snapshot(&project).cache_unavailable_reason, None @@ -15339,6 +15085,679 @@ mod tests { })) } + fn configure_lexical_views(root: &Path, storage: &Path) -> RawRequest { + configure_request_with_params(json!({ + "project_root": root, "harness": "opencode", "storage_dir": storage, + "config": [user_tier(json!({ + "views": { "enabled": true }, + "indexes": { "trigram": true, "semantic": false, "callgraph": false } + }))] + })) + } + + fn lexical_views_oracle(root: &Path) -> TestContext { + let oracle = test_context(); + oracle.update_config(|config| { + config.project_root = Some(root.to_path_buf()); + config.views.enabled = false; + config.indexes.trigram = true; + config.indexes.semantic = false; + config.indexes.callgraph = false; + }); + oracle.set_canonical_cache_root(root.to_path_buf()); + *oracle.search_index().write().unwrap() = Some(SearchIndex::build_with_limit_serial( + root, + oracle.config().search_index_max_file_size, + )); + oracle + } + + fn lexical_views_assert_public_parity(ctx: &AppContext, root: &Path) { + let oracle = lexical_views_oracle(root); + for (command, params, field) in [ + ( + "grep", + json!({"pattern": "TOKEN|needle", "include": "**/*.ts"}), + "matches", + ), + ("glob", json!({"pattern": "**/*.ts"}), "files"), + ( + "semantic_search", + json!({"query": "OLDER_COMMIT_TOKEN"}), + "results", + ), + ( + "semantic_search", + json!({"query": "needle alpha"}), + "results", + ), + ( + "semantic_search", + json!({"query": "unfindable_token"}), + "results", + ), + ( + "semantic_search", + json!({"pattern": "TOKEN|needle"}), + "results", + ), + ( + "semantic_search", + json!({"query": "needle", "pattern": "TOKEN|needle"}), + "results", + ), + ] { + let request = RawRequest { + id: format!("lexical-views-{command}"), + command: command.into(), + lsp_hints: None, + session_id: None, + params, + }; + let run = |ctx: &AppContext| match command { + "grep" => crate::commands::grep::handle_grep(&request, ctx), + "glob" => crate::commands::glob::handle_glob(&request, ctx), + _ => crate::commands::semantic_search::handle_semantic_search(&request, ctx), + }; + let expected = run(&oracle); + let actual = run(ctx); + assert!(expected.success, "oracle {command}: {:?}", expected.data); + assert!(actual.success, "view {command}: {:?}", actual.data); + assert!( + expected.data[field].is_array(), + "{command}: {:?}", + expected.data + ); + assert_eq!( + actual.data[field], expected.data[field], + "{command} {field}" + ); + assert_eq!(actual.data["text"], expected.data["text"], "{command} text"); + } + assert!(ctx + .lexical_view_route_counts() + .iter() + .all(|count| *count > 0)); + assert_eq!(oracle.lexical_view_route_counts(), [0; 3]); + } + + fn lexical_views_public_checkout(kind: OlderCheckoutKind) { + let temp = tempfile::tempdir().unwrap(); + let (owner, root, storage) = older_checkout_beside_live_owner(temp.path(), kind); + let root = std::fs::canonicalize(root).unwrap(); + let cache = crate::search_index::resolve_cache_dir_with_key( + &owner.memoized_artifact_cache_key(&owner.canonical_cache_root()), + Some(&storage), + ); + let owner_cache = std::fs::read(cache.join("cache.bin")).unwrap(); + let builds = crate::write_ledger::register( + crate::write_ledger::Domain::SearchIndexBuild, + root.display().to_string(), + ); + let deltas = crate::write_ledger::register( + crate::write_ledger::Domain::SearchIndexDelta, + root.display().to_string(), + ); + let before = ( + builds.physical_total_for_test(), + deltas.physical_total_for_test(), + ); + let ctx = test_context(); + let bound = handle_configure_for_test(&configure_lexical_views(&root, &storage), &ctx); + assert!(bound.success, "{:?}", bound.data); + assert!(ctx.search_index().read().unwrap().is_none()); + assert!(ctx.search_index_rx().read().unwrap().is_none()); + lexical_views_assert_public_parity(&ctx, &root); + + std::fs::write( + root.join("shared.ts"), + "export const OLDER_COMMIT_TOKEN = 4;\nneedle alpha\n", + ) + .unwrap(); + std::fs::write(root.join("new.ts"), "needle alpha beta\n").unwrap(); + std::fs::write(root.join(".gitignore"), "new.ts\n").unwrap(); + ctx.record_checkout_watcher_change(&root.join("shared.ts")); + lexical_views_assert_public_parity(&ctx, &root); + let stamp = filetime::FileTime::from_last_modification_time( + &std::fs::metadata(root.join("shared.ts")).unwrap(), + ); + std::fs::write( + root.join("shared.ts"), + "export const OLDER_COMMIT_TOKEN = 5;\nneedle alpha\n", + ) + .unwrap(); + filetime::set_file_mtime(root.join("shared.ts"), stamp).unwrap(); + lexical_views_assert_public_parity(&ctx, &root); + std::fs::remove_file(root.join("shared.ts")).unwrap(); + std::fs::write(root.join(".gitignore"), "").unwrap(); + lexical_views_assert_public_parity(&ctx, &root); + std::fs::rename(root.join("new.ts"), root.join("moved.ts")).unwrap(); + lexical_views_assert_public_parity(&ctx, &root); + ctx.record_checkout_watcher_overflow(); + std::fs::write(root.join("moved.ts"), "needle alpha after overflow\n").unwrap(); + lexical_views_assert_public_parity(&ctx, &root); + + crate::runtime_drain::spawn_search_corpus_refresh(&ctx, root.clone(), ctx.config()); + crate::runtime_drain::refresh_search_index_after_write(&ctx, &root.join("moved.ts")); + assert!(ctx.search_index_rx().read().unwrap().is_none()); + ctx.flush_search_index_on_graceful_shutdown(); + assert_eq!( + ( + builds.physical_total_for_test(), + deltas.physical_total_for_test() + ), + before + ); + // The owner fixture already wrote a cache. A routed checkout must not + // replace it with its own content, even after refresh and shutdown. + assert_eq!(std::fs::read(cache.join("cache.bin")).unwrap(), owner_cache); + assert!(!storage.join("blobs/v2").exists()); + ctx.mark_subc_unbound(); + ctx.cancel_unbound_artifact_work(); + drop(ctx); + let restarted = test_context(); + assert!( + handle_configure_for_test(&configure_lexical_views(&root, &storage), &restarted) + .success + ); + lexical_views_assert_public_parity(&restarted, &root); + assert_eq!(std::fs::read(cache.join("cache.bin")).unwrap(), owner_cache); + } + + #[test] + fn lexical_views_public_linked_worktree_parity_edits_restart_and_writer_stop() { + lexical_views_public_checkout(OlderCheckoutKind::LinkedWorktree); + } + + #[test] + fn lexical_views_public_plain_clone_parity_edits_restart_and_writer_stop() { + lexical_views_public_checkout(OlderCheckoutKind::PlainClone); + } + + #[test] + fn lexical_views_public_empty_generation_and_live_only_addition_match_legacy() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("project"); + let storage = temp.path().join("storage"); + std::fs::create_dir_all(&root).unwrap(); + let ctx = test_context(); + assert!(handle_configure_for_test(&configure_lexical_views(&root, &storage), &ctx).success); + lexical_views_assert_public_parity(&ctx, &root); + std::fs::write( + root.join("new.ts"), + "export const OLDER_COMMIT_TOKEN = 1;\nneedle alpha\n", + ) + .unwrap(); + lexical_views_assert_public_parity(&ctx, &root); + assert!(!storage.join("blobs/v2").exists()); + } + + #[test] + fn lexical_views_public_late_legacy_completion_and_views_off_boundary() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("project"); + init_git_fixture(&root); + let storage = temp.path().join("storage"); + let ctx = test_context(); + assert!(handle_configure_for_test(&configure_with_storage(&root, &storage), &ctx).success); + wait_for_search_index_ready(&ctx, Duration::from_secs(10)); + assert_eq!(ctx.lexical_view_route_counts(), [0; 3]); + let floor = std::fs::read(storage.join("reader-floor.json")).ok(); + assert!(crate::commands::grep::handle_grep(&grep_request("needle"), &ctx).success); + let off_glob: RawRequest = serde_json::from_value(json!({ + "id": "lexical-off-glob", "command": "glob", "pattern": "**/*" + })) + .unwrap(); + assert!(crate::commands::glob::handle_glob(&off_glob, &ctx).success); + assert!( + crate::commands::semantic_search::handle_semantic_search( + &semantic_search_request("needle"), + &ctx + ) + .success + ); + assert_eq!(std::fs::read(storage.join("reader-floor.json")).ok(), floor); + assert_eq!(ctx.lexical_view_route_counts(), [0; 3]); + assert!(!storage.join("blobs/v3").exists()); + let stale = + SearchIndex::build_with_limit_serial(&root, ctx.config().search_index_max_file_size); + std::fs::write( + root.join("shared.ts"), + "export const OLDER_COMMIT_TOKEN = 7;\nneedle alpha\n", + ) + .unwrap(); + assert!(handle_configure_for_test(&configure_lexical_views(&root, &storage), &ctx).success); + let (tx, rx) = crossbeam_channel::unbounded(); + ctx.install_search_index_rx(rx, ctx.configure_generation()); + tx.send(stale).unwrap(); + crate::runtime_drain::drain_search_index_events(&ctx); + assert!(ctx.search_index().read().unwrap().is_none()); + assert!(ctx.search_index_rx().read().unwrap().is_none()); + lexical_views_assert_public_parity(&ctx, &root); + assert!(!storage.join("blobs/v2").exists()); + } + + #[test] + fn lexical_views_public_acquisition_failure_never_uses_legacy_index() { + struct Refused; + impl crate::views::trigram::LexicalSnapshotSource for Refused { + fn acquire(&self) -> Result { + Err("lexical source deliberately refused".into()) + } + fn record_change(&self, _: &Path) {} + } + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("project"); + init_git_fixture(&root); + std::fs::write(root.join("visible.ts"), "needle\n").unwrap(); + let ctx = test_context(); + assert!( + handle_configure_for_test( + &configure_lexical_views(&root, &temp.path().join("storage")), + &ctx + ) + .success + ); + ctx.install_lexical_source_for_test(Arc::new(Refused)); + *ctx.search_index().write().unwrap() = Some(SearchIndex::empty_ready_for_root(&root)); + let grep = crate::commands::grep::handle_grep(&grep_request("needle"), &ctx); + assert!( + !grep.success && grep.data.to_string().contains("deliberately refused"), + "{:?}", + grep.data + ); + let search = crate::commands::semantic_search::handle_semantic_search( + &semantic_search_request("needle"), + &ctx, + ); + assert!( + !search.success && search.data.to_string().contains("deliberately refused"), + "{:?}", + search.data + ); + let glob = crate::commands::glob::handle_glob( + &RawRequest { + id: "lexical-refused-glob".into(), + command: "glob".into(), + lsp_hints: None, + session_id: None, + params: json!({"pattern":"**/*.ts"}), + }, + &ctx, + ); + assert!( + glob.success && glob.data["text"].as_str().unwrap().contains("visible.ts"), + "{:?}", + glob.data + ); + assert_eq!(glob.data["fallback"], "filesystem"); + assert_eq!(ctx.lexical_view_route_counts(), [0; 3]); + } + + #[test] + fn lexical_views_public_search_pins_one_snapshot_for_all_lexical_lanes() { + struct Counted { + source: Arc, + calls: std::sync::atomic::AtomicUsize, + } + impl crate::views::trigram::LexicalSnapshotSource for Counted { + fn acquire(&self) -> Result { + self.calls.fetch_add(1, Ordering::SeqCst); + crate::views::trigram::LexicalSnapshotSource::acquire(self.source.as_ref()) + } + fn record_change(&self, path: &Path) { + crate::views::trigram::LexicalSnapshotSource::record_change( + self.source.as_ref(), + path, + ); + } + } + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("project"); + init_git_fixture(&root); + std::fs::write( + root.join("shared.ts"), + "export const OLDER_COMMIT_TOKEN = 1;\n", + ) + .unwrap(); + let ctx = test_context(); + assert!( + handle_configure_for_test( + &configure_lexical_views(&root, &temp.path().join("storage")), + &ctx + ) + .success + ); + let initialized = ctx.lexical_view_snapshot("bind").unwrap(); + assert!(initialized.is_ok(), "{:?}", initialized.err()); + let source = ctx.lexical_source_for_test(); + let counted = Arc::new(Counted { + source, + calls: Default::default(), + }); + ctx.install_lexical_source_for_test(counted.clone()); + let response = crate::commands::semantic_search::handle_semantic_search( + &semantic_search_request("OLDER_COMMIT_TOKEN"), + &ctx, + ); + assert!(response.success, "{:?}", response.data); + assert!(!response.data["results"].as_array().unwrap().is_empty()); + assert_eq!(counted.calls.load(Ordering::SeqCst), 1); + assert_eq!(ctx.lexical_view_route_counts(), [0, 0, 1]); + } + + #[test] + fn lexical_views_public_kill_child() { + let Some(root) = std::env::var_os("AFT_LEXICAL_KILL_ROOT") else { + return; + }; + let storage = PathBuf::from(std::env::var_os("AFT_LEXICAL_KILL_STORAGE").unwrap()); + let root = PathBuf::from(root); + let ctx = test_context(); + assert!(handle_configure_for_test(&configure_lexical_views(&root, &storage), &ctx).success); + lexical_views_assert_public_parity(&ctx, &root); + println!("LEXICAL_VIEW_PUBLISHED"); + use std::io::{Read, Write}; + std::io::stdout().flush().unwrap(); + let _ = std::io::stdin().read(&mut [0u8; 1]); + } + + #[test] + fn lexical_views_public_forced_kill_reopens_current_tree() { + use std::io::BufRead; + use std::process::Stdio; + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("project"); + init_git_fixture(&root); + std::fs::write( + root.join("shared.ts"), + "export const OLDER_COMMIT_TOKEN = 1;\nneedle alpha\n", + ) + .unwrap(); + let root = std::fs::canonicalize(root).unwrap(); + let storage = temp.path().join("storage"); + let mut child = Command::new(std::env::current_exe().unwrap()) + .args([ + "--exact", + "commands::configure::tests::lexical_views_public_kill_child", + "--nocapture", + ]) + .env("AFT_LEXICAL_KILL_ROOT", &root) + .env("AFT_LEXICAL_KILL_STORAGE", &storage) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .spawn() + .unwrap(); + let output = child.stdout.take().unwrap(); + let mut published = false; + for line in std::io::BufReader::new(output).lines() { + if line.unwrap().contains("LEXICAL_VIEW_PUBLISHED") { + published = true; + break; + } + } + if !published { + let status = child.wait().unwrap(); + panic!("child exited before publication handshake: {status}"); + } + child.kill().unwrap(); + assert!(!child.wait().unwrap().success()); + std::fs::write( + root.join("shared.ts"), + "export const OLDER_COMMIT_TOKEN = 2;\nneedle alpha changed\n", + ) + .unwrap(); + let ctx = test_context(); + assert!(handle_configure_for_test(&configure_lexical_views(&root, &storage), &ctx).success); + lexical_views_assert_public_parity(&ctx, &root); + } + + fn configure_callgraph_views(root: &Path, storage: &Path, views: bool) -> RawRequest { + configure_request_with_params(json!({ + "project_root": root, "harness": "opencode", "storage_dir": storage, + "config": [user_tier(json!({ + "views": { "enabled": views }, + "indexes": { "trigram": true, "semantic": false, "callgraph": true } + }))] + })) + } + + /// The navigation answers whose output the views route must reproduce. + fn callgraph_views_answers(ctx: &AppContext, root: &Path) -> Vec<(String, Response)> { + let requests = [ + ("callers", "a.ts", "target", 2), + ("impact", "a.ts", "target", 3), + ("call_tree", "c.ts", "top", 3), + ("trace_to", "a.ts", "target", 5), + ("callers", "b.ts", "caller", 1), + ]; + requests + .into_iter() + .map(|(command, file, symbol, depth)| { + let request = RawRequest { + id: format!("callgraph-views-{command}"), + command: command.into(), + lsp_hints: None, + session_id: None, + params: json!({"file": root.join(file), "symbol": symbol, "depth": depth}), + }; + let response = match command { + "callers" => crate::commands::callers::handle_callers(&request, ctx), + "impact" => crate::commands::impact::handle_impact(&request, ctx), + "call_tree" => crate::commands::call_tree::handle_call_tree(&request, ctx), + _ => crate::commands::trace_to::handle_trace_to(&request, ctx), + }; + (format!("{command} {file} {symbol}"), response) + }) + .collect() + } + + /// Compares the views route with the legacy store over the same files, + /// built cold by a views-off context in separate storage. The legacy + /// store serves no linked worktree, so it reads a plain copy of the + /// checkout's files, and both sides render their root as ``. + fn callgraph_views_assert_parity(ctx: &AppContext, root: &Path) { + let storage = tempfile::tempdir().unwrap(); + let copy = tempfile::tempdir().unwrap(); + let copy_root = std::fs::canonicalize(copy.path()).unwrap(); + for entry in std::fs::read_dir(root).unwrap() { + let entry = entry.unwrap(); + if entry.file_type().unwrap().is_file() && entry.file_name() != ".git" { + std::fs::copy(entry.path(), copy_root.join(entry.file_name())).unwrap(); + } + } + let oracle = test_context(); + assert!( + handle_configure_for_test( + &configure_callgraph_views(©_root, storage.path(), false), + &oracle + ) + .success + ); + { + let _wait = crate::context::override_callgraph_build_wait_ms_for_test(&oracle, 60_000); + let legacy = lazy_ready(oracle.callgraph_store_for_ops()); + assert_eq!(legacy.reader_kind(), "legacy"); + } + let normalized = |data: &serde_json::Value, root: &Path| { + data.to_string() + .replace(&root.display().to_string(), "") + }; + let before = ctx.callgraph_view_route_count(); + let expected = callgraph_views_answers(&oracle, ©_root); + let actual = callgraph_views_answers(ctx, root); + for ((label, expected), (_, actual)) in expected.iter().zip(&actual) { + assert!(expected.success, "legacy {label}: {:?}", expected.data); + assert!(actual.success, "view {label}: {:?}", actual.data); + assert_eq!( + normalized(&actual.data, root), + normalized(&expected.data, ©_root), + "{label}" + ); + } + assert!(ctx.callgraph_view_route_count() > before); + assert_eq!(oracle.callgraph_view_route_count(), 0); + assert!(ctx.callgraph_store().read().unwrap().is_none()); + } + + fn callgraph_views_checkout(kind: OlderCheckoutKind) { + let temp = tempfile::tempdir().unwrap(); + let live = temp.path().join("live"); + init_git_fixture(&live); + for (name, text) in [ + ("a.ts", "export function target() { return 1; }\n"), + ( + "b.ts", + "import { target } from './a';\nexport function caller() { return target(); }\n", + ), + ( + "c.ts", + "import { caller } from './b';\nexport function top() { return caller(); }\n", + ), + ] { + std::fs::write(live.join(name), text).unwrap(); + } + git_commit_all(&live, "graph"); + let scratch = temp.path().join("scratch"); + match kind { + OlderCheckoutKind::LinkedWorktree => git_ok( + &live, + &[ + "worktree", + "add", + "--quiet", + "--detach", + scratch.to_str().unwrap(), + "HEAD", + ], + ), + _ => git_ok(temp.path(), &["clone", "--quiet", "live", "scratch"]), + } + let root = std::fs::canonicalize(&scratch).unwrap(); + let storage = temp.path().join("storage"); + let ctx = test_context(); + assert!( + handle_configure_for_test(&configure_callgraph_views(&root, &storage, true), &ctx) + .success + ); + callgraph_views_assert_parity(&ctx, &root); + + // Edits: a new caller, a changed callee file and a deletion. + std::fs::write( + root.join("d.ts"), + "import { target } from './a';\nexport function second() { return target(); }\n", + ) + .unwrap(); + ctx.record_checkout_watcher_change(&root.join("d.ts")); + callgraph_views_assert_parity(&ctx, &root); + std::fs::write( + root.join("b.ts"), + "import { target } from './a';\nexport function caller() { return target() + target(); }\n", + ) + .unwrap(); + ctx.record_checkout_watcher_change(&root.join("b.ts")); + callgraph_views_assert_parity(&ctx, &root); + std::fs::remove_file(root.join("d.ts")).unwrap(); + ctx.record_checkout_watcher_overflow(); + callgraph_views_assert_parity(&ctx, &root); + assert!(!storage.join("blobs/v2").exists()); + + // Restart: a new context reopens the published view. + ctx.mark_subc_unbound(); + ctx.cancel_unbound_artifact_work(); + drop(ctx); + let restarted = test_context(); + assert!( + handle_configure_for_test( + &configure_callgraph_views(&root, &storage, true), + &restarted + ) + .success + ); + callgraph_views_assert_parity(&restarted, &root); + } + + #[test] + fn callgraph_views_public_linked_worktree_parity_across_edits_and_restart() { + callgraph_views_checkout(OlderCheckoutKind::LinkedWorktree); + } + + #[test] + fn callgraph_views_public_plain_clone_parity_across_edits_and_restart() { + callgraph_views_checkout(OlderCheckoutKind::PlainClone); + } + + #[test] + fn callgraph_views_acquisition_failure_never_uses_the_legacy_store() { + struct Refused(std::sync::atomic::AtomicUsize); + impl crate::views::callgraph::CallgraphReaderSource for Refused { + fn acquire( + &self, + ) -> Result< + Arc, + crate::views::callgraph::CallgraphViewError, + > { + self.0.fetch_add(1, Ordering::SeqCst); + Err("callgraph source deliberately refused".into()) + } + fn record_change(&self, _: &Path) {} + } + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("project"); + init_git_fixture(&root); + std::fs::write( + root.join("a.ts"), + "export function target() { return 1; }\nexport function caller() { return target(); }\n", + ) + .unwrap(); + let root = std::fs::canonicalize(root).unwrap(); + let storage = temp.path().join("storage"); + // A legacy store built for this very root must not answer either. + let legacy = test_context(); + assert!( + handle_configure_for_test(&configure_callgraph_views(&root, &storage, false), &legacy) + .success + ); + let legacy_store = { + let _wait = crate::context::override_callgraph_build_wait_ms_for_test(&legacy, 60_000); + lazy_ready(legacy.callgraph_store_for_ops()) + }; + let ctx = test_context(); + assert!( + handle_configure_for_test(&configure_callgraph_views(&root, &storage, true), &ctx) + .success + ); + *ctx.callgraph_store().write().unwrap() = Some(legacy_store); + let refused = Arc::new(Refused(Default::default())); + ctx.install_callgraph_source_for_test(refused.clone()); + let _pin = ctx.pin_config(); + let answers = callgraph_views_answers(&ctx, &root); + for (label, answer) in &answers { + assert!(!answer.success, "{label}: {:?}", answer.data); + assert!( + answer.data.to_string().contains("deliberately refused"), + "{label}: {:?}", + answer.data + ); + } + // One pinned request acquires once and keeps its refusal. + let inspect_access = ctx.callgraph_store_for_query_without_wait(); + assert!(matches!( + inspect_access, + CallgraphStoreAccess::Error(ref error) if error.to_string().contains("deliberately refused") + )); + assert_eq!(refused.0.load(Ordering::SeqCst), 1); + assert_eq!(ctx.callgraph_view_route_count(), 0); + // Views off keeps the legacy store exactly as before. + assert_eq!(legacy.callgraph_view_route_count(), 0); + let legacy_answers = callgraph_views_answers(&legacy, &root); + assert!( + legacy_answers[0].1.success, + "{:?}", + legacy_answers[0].1.data + ); + } + fn configure_search_with_max_file_size( root: &std::path::Path, storage: &std::path::Path, @@ -24172,7 +24591,9 @@ mod tests { assert!(ctx.config().indexes.semantic); assert!(ctx.config().indexes.callgraph); assert!(ctx.config().views.enabled); - assert!(ctx.search_index_rx().read().unwrap().is_some()); + // With views on, the checkout's lexical view replaces the legacy + // search loader, so trigram work for this root is the lexical view. + assert!(ctx.lexical_views_enabled()); assert!(ctx.semantic_index_rx().lock().is_some()); super::drain_deferred_configure_maintenance(&ctx); assert!(ctx.watcher_runtime_active()); diff --git a/crates/aft/src/commands/glob.rs b/crates/aft/src/commands/glob.rs index 8f7b82e05a..bc9e36fcb8 100644 --- a/crates/aft/src/commands/glob.rs +++ b/crates/aft/src/commands/glob.rs @@ -38,6 +38,8 @@ const MAX_DIRECTORY_SECTIONS: usize = 8; const MAX_DISPLAY_DIRECTORIES: usize = 6; pub fn handle_glob(req: &RawRequest, ctx: &AppContext) -> Response { + let config = ctx.config(); + let _pin = config.views.enabled.then(|| ctx.pin_config_to(config)); ctx.with_read_policy_snapshot(&req.id, || { crate::context::confinement::with_floor_disclosure(|| { crate::bounded_io::command(req, ctx, handle_glob_inner) @@ -590,7 +592,14 @@ fn glob_root( } let search_root_text = search_root.to_string_lossy(); let search_scope = resolve_search_scope(project_root, Some(search_root_text.as_ref())); - let indexed_snapshot = { + let view_snapshot = (search_scope.use_index && !ignored_target) + .then(|| ctx.lexical_view_snapshot("glob")) + .flatten(); + let indexed_snapshot = if let Some(snapshot) = view_snapshot { + // Failed view acquisition uses the bounded current-tree walk, never a + // borrowed legacy snapshot that may describe another checkout. + snapshot.ok() + } else { let search_index = ctx .search_index() .read() diff --git a/crates/aft/src/commands/grep.rs b/crates/aft/src/commands/grep.rs index 286a8bbd30..5b3ce6b755 100644 --- a/crates/aft/src/commands/grep.rs +++ b/crates/aft/src/commands/grep.rs @@ -133,6 +133,8 @@ pub(crate) mod work_probe { } pub fn handle_grep(req: &RawRequest, ctx: &AppContext) -> Response { + let config = ctx.config(); + let _pin = config.views.enabled.then(|| ctx.pin_config_to(config)); ctx.with_read_policy_snapshot(&req.id, || { crate::context::confinement::with_floor_disclosure(|| { crate::bounded_io::command(req, ctx, handle_grep_inner) diff --git a/crates/aft/src/commands/inspect.rs b/crates/aft/src/commands/inspect.rs index 65feb63361..5e48e324f6 100644 --- a/crates/aft/src/commands/inspect.rs +++ b/crates/aft/src/commands/inspect.rs @@ -708,12 +708,34 @@ fn handle_inspect_payload( .iter() .any(|category| category.is_tier2() && snapshot.config.inspect.category_enabled(*category)); start_on_demand_callgraph_for_inspect(ctx, &snapshot.config); - let routed_store = if tier2_enabled && ctx.checkout_query_runtime_active() { + // With views on, the graph-backed categories read the checkout's v3 view, + // pinned once for this request; an unavailable view is reported, never + // replaced by the legacy scanner's store. + let v3_routed = tier2_enabled && ctx.callgraph_views_enabled(); + let routed_store = if tier2_enabled && (ctx.checkout_query_runtime_active() || v3_routed) { ctx.callgraph_store_for_ops() } else { crate::context::CallgraphStoreAccess::Unavailable }; - let checkout_routed = ctx.checkout_query_outcome().is_some(); + let checkout_routed = ctx.checkout_query_outcome().is_some() || v3_routed; + // Why the v3 view could not serve, observed once from the access this + // request already holds: a SQLite lock is contention (pending, retry), + // anything else is unavailability. + let routed_observation = v3_routed.then(|| match &routed_store { + crate::context::CallgraphStoreAccess::Error(error) + if error.is_transient_lock_contention() => + { + (crate::feature_status::IndexObservation::building(), true) + } + crate::context::CallgraphStoreAccess::Error(error) => ( + crate::feature_status::IndexObservation::unavailable(error.to_string()), + false, + ), + access => ( + crate::feature_status::callgraph_access_observation(ctx, access), + false, + ), + }); let checkout_store = if checkout_routed { match routed_store { crate::context::CallgraphStoreAccess::Ready(store) => Some(store), @@ -758,12 +780,17 @@ fn handle_inspect_payload( && checkout_store.is_none() { Some(crate::feature_status::observe_callgraph_query_with_contention(ctx)) + } else if checkout_store.is_none() { + routed_observation } else { None }; - let use_checkout_view = needs_ephemeral_view + // A v3-routed request scans only with a reader in hand; without one its + // graph-backed categories report the gap below rather than running the + // legacy reuse scanner. + let use_checkout_view = (needs_ephemeral_view || v3_routed) && snapshot.config.views.enabled - && (checkout_routed || checkout_store.is_some()); + && (checkout_store.is_some() || (checkout_routed && !v3_routed)); let checkout_view_unavailability = (needs_ephemeral_view && snapshot.config.views.enabled && !checkout_routed @@ -830,7 +857,7 @@ fn handle_inspect_payload( if outcomes.contains_key(&category) { continue; } - if (scope_was_provided || !ctx.inspect_writer()) && !use_checkout_view { + if (scope_was_provided || !ctx.inspect_writer() || v3_routed) && !use_checkout_view { continue; } if request_deadline.is_some_and(|deadline| !deadline.has_work_budget()) { diff --git a/crates/aft/src/commands/mod.rs b/crates/aft/src/commands/mod.rs index 76df2b11d6..d2cb062195 100644 --- a/crates/aft/src/commands/mod.rs +++ b/crates/aft/src/commands/mod.rs @@ -79,6 +79,7 @@ pub mod undo; pub mod untrust_filter_project; pub(crate) mod url_output; mod url_resolution; +pub mod view_token; pub mod write; pub mod writes_census; pub mod zoom; diff --git a/crates/aft/src/commands/semantic_search/exact_lane.rs b/crates/aft/src/commands/semantic_search/exact_lane.rs index a9a063c3b3..13a839c17c 100644 --- a/crates/aft/src/commands/semantic_search/exact_lane.rs +++ b/crates/aft/src/commands/semantic_search/exact_lane.rs @@ -521,7 +521,36 @@ impl ExactLane { fallback_options: Option<&FallbackExactOptions>, scope: &ExactEvidenceScope, ) -> Result { - let is_ready = index.is_some_and(SearchIndex::is_ready); + let snapshot = index + .filter(|index| index.is_ready()) + .map(SearchIndex::snapshot); + self.search_scoped_snapshot( + snapshot.as_ref(), + project_root, + snapshot_generation, + query, + include_tests, + offset, + top_k, + fallback_options, + scope, + ) + } + + /// Accepts an optional SearchIndexSnapshot without requiring a mutable index. + pub(crate) fn search_scoped_snapshot( + &self, + snapshot: Option<&crate::search_index::SearchIndexSnapshot>, + project_root: &Path, + snapshot_generation: GenerationToken, + query: &str, + include_tests: bool, + offset: usize, + top_k: usize, + fallback_options: Option<&FallbackExactOptions>, + scope: &ExactEvidenceScope, + ) -> Result { + let is_ready = snapshot.is_some(); // Index loading can finish without changing snapshot_generation. Give // fallback results a separate memo key so they cannot be reused once // the full index is ready. @@ -538,9 +567,8 @@ impl ExactLane { self.memo.get_or_verify(&key, offset, top_k, || { if is_ready { - let snapshot = index.unwrap().snapshot(); Ok(self.execute_ready_mode_scoped( - &snapshot, + snapshot.unwrap(), project_root, query, include_tests, diff --git a/crates/aft/src/commands/semantic_search/mod.rs b/crates/aft/src/commands/semantic_search/mod.rs index 7ee71f76ed..785e61d0b5 100644 --- a/crates/aft/src/commands/semantic_search/mod.rs +++ b/crates/aft/src/commands/semantic_search/mod.rs @@ -581,7 +581,22 @@ impl extensions::ReadinessSource for RuntimeReadinessSource<'_> { }, }; - let trigram = + let trigram = if let Some(snapshot) = self.ctx.lexical_view_snapshot("aft_search") { + match snapshot { + Ok(snapshot) => TrigramReadiness { + status: IndexStatus::Ready, + snapshot: Some(Arc::new(snapshot)), + evicted: false, + lock_contended: false, + }, + Err(_) => TrigramReadiness { + status: IndexStatus::Fallback, + snapshot: None, + evicted: false, + lock_contended: false, + }, + } + } else { match try_read_with_budget(self.ctx.search_index(), INTERACTIVE_ARTIFACT_READ_BUDGET) { Some(index) if index.as_ref().is_some_and(SearchIndex::is_ready) => { TrigramReadiness { @@ -629,7 +644,8 @@ impl extensions::ReadinessSource for RuntimeReadinessSource<'_> { evicted: false, lock_contended: true, }, - }; + } + }; let symbol_cache = self.ctx.symbol_cache(); let symbol = match try_read_with_budget(&symbol_cache, INTERACTIVE_ARTIFACT_READ_BUDGET) { @@ -661,6 +677,9 @@ impl extensions::ReadinessSource for RuntimeReadinessSource<'_> { } fn bounded_first_search_wait(&self) -> extensions::ReadinessWait { + if self.ctx.lexical_views_enabled() { + return extensions::ReadinessWait::Completed; + } // A writer-held pointer cannot be advanced by the loader drain below; // preserve the interactive contention bound instead of sleeping 2.5s. if matches!( @@ -791,6 +810,8 @@ fn handle_semantic_search_with_disk_fallback( ctx: &AppContext, allow_disk_fallback: bool, ) -> Response { + let config = ctx.config(); + let _pin = config.views.enabled.then(|| ctx.pin_config_to(config)); use extensions::{RawQuery, Root, Token}; let page_request = match paging::parse_public_page_request(&req.params) { @@ -926,6 +947,11 @@ fn handle_semantic_search_with_disk_fallback( // The extensions come from the search_b2 install point: A-side defaults // until campaign B2 installs its router, plans, variants and readiness. let extensions = crate::search_b2::install_defaults(); + if external_root.is_none() { + if let Some(Err(error)) = ctx.lexical_view_snapshot("aft_search") { + return Response::error(&req.id, "internal_error", error); + } + } if let Some((pattern_text, compiled)) = pattern { return handle_pattern_request( req, @@ -3994,7 +4020,12 @@ fn regex_route_collection( compiled: &pattern_compile::CompiledPattern, include_tests: bool, ) -> Option> { - let snapshot = { + let snapshot = if let Some(snapshot) = ctx.lexical_view_snapshot("aft_search") { + match snapshot { + Ok(snapshot) => snapshot, + Err(error) => return Some(Err(std::io::Error::other(error))), + } + } else { #[cfg(test)] regex_route_lock_hook(false); let Some(guard) = @@ -4836,8 +4867,12 @@ fn run_engine_ranking( return Err("request_cancelled".to_string()); } } - let context_index = borrowed_index - .is_none() + let view_snapshot = if borrowed_index.is_none() { + ctx.lexical_view_snapshot("aft_search").transpose()? + } else { + None + }; + let context_index = (borrowed_index.is_none() && view_snapshot.is_none()) .then(|| try_read_with_budget(ctx.search_index(), INTERACTIVE_ARTIFACT_READ_BUDGET)); let empty_index = SearchIndex::new(); let index = borrowed_index @@ -4849,8 +4884,10 @@ fn run_engine_ranking( .and_then(|guard| guard.as_ref()) }) .unwrap_or(&empty_index); - let generation = borrowed_index - .map(|(_, generation)| generation.clone()) + let generation = view_snapshot + .as_ref() + .map(|snapshot| GenerationToken::new_with_str(&snapshot.content_generation())) + .or_else(|| borrowed_index.map(|(_, generation)| generation.clone())) .unwrap_or_else(|| { GenerationToken::new_with_str(&format!( "{}:{}", @@ -4858,7 +4895,8 @@ fn run_engine_ranking( ctx.semantic_index_rx_generation() )) }); - let snapshot = index.snapshot(); + let index_ready = view_snapshot.is_some() || index.is_ready(); + let snapshot = view_snapshot.unwrap_or_else(|| index.snapshot()); let content_tokens = query_shape::extract_content_tokens(query); let token_refs = content_tokens .iter() @@ -4944,8 +4982,8 @@ fn run_engine_ranking( let mut exact_candidates = if plan.contains(SearchLaneKind::Exact) || plan.shape == SearchShape::Identifier { let outcome = exact_lane::ExactLane::with_memo(ctx.search_exact_memo()) - .search_scoped( - Some(&index), + .search_scoped_snapshot( + index_ready.then_some(&snapshot), project_root, generation.clone(), exact_input, @@ -5012,8 +5050,8 @@ fn run_engine_ranking( for (fact_input, exact_form) in fact_inputs { // Only phrase evidence is kept from these passes. let outcome = lane - .search_scoped( - Some(&index), + .search_scoped_snapshot( + index_ready.then_some(&snapshot), project_root, generation.clone(), &fact_input, @@ -5082,35 +5120,35 @@ fn run_engine_ranking( && exact_candidates.is_empty() && exact_disclosures.is_empty() && index_disclosures.is_empty() - && index.is_ready()) - .then(|| nearest_names::looked_up_identifiers(exact_input)) - .flatten() - .filter(|identifiers| { - // Absence is checked over every indexed file, tests included: a name - // that only a test uses still exists, and the ordinary ranking says - // more about it than a not-found answer would. - identifiers.iter().all(|identifier| { - snapshot - .whole_corpus_exact_pass(identifier, project_root, None) - .map(|matches| matches.is_empty()) - .unwrap_or_else(|error| { - index_disclosures.push(error.to_string()); - false - }) + && index_ready) + .then(|| nearest_names::looked_up_identifiers(exact_input)) + .flatten() + .filter(|identifiers| { + // Absence is checked over every indexed file, tests included: a name + // that only a test uses still exists, and the ordinary ranking says + // more about it than a not-found answer would. + identifiers.iter().all(|identifier| { + snapshot + .whole_corpus_exact_pass(identifier, project_root, None) + .map(|matches| matches.is_empty()) + .unwrap_or_else(|error| { + index_disclosures.push(error.to_string()); + false + }) + }) }) - }) - .map(|identifiers| { - let target = identifiers.last().expect("at least one identifier"); - nearest_names::NotFoundAnswer { - query: exact_lane::exact_phrase(exact_input).to_string(), - names: nearest_names::nearest_names( - target, - lexical_candidates - .iter() - .map(|candidate| candidate.path.as_path()), - ), - } - }); + .map(|identifiers| { + let target = identifiers.last().expect("at least one identifier"); + nearest_names::NotFoundAnswer { + query: exact_lane::exact_phrase(exact_input).to_string(), + names: nearest_names::nearest_names( + target, + lexical_candidates + .iter() + .map(|candidate| candidate.path.as_path()), + ), + } + }); // A code literal that no indexed file contains (tests included) gets the // same kind of answer: the reply says so first, and the files that only // share its words are labelled similar rather than presented as matches. @@ -5125,26 +5163,26 @@ fn run_engine_ranking( let literal_absent = (not_found.is_none() && plan.shape == SearchShape::CodeLiteral && exact_disclosures.is_empty() - && index.is_ready()) - .then(|| exact_lane::exact_phrase(exact_input)) - .filter(|phrase| phrase.len() >= 3) - .filter(|phrase| { - match literal_presence( - ctx, - &snapshot, - project_root, - &generation, - phrase, - &exact_candidates, - ) { - Ok(presence) => presence == memo::LiteralPresence::Absent, - Err(error) => { - index_disclosures.push(error.to_string()); - false + && index_ready) + .then(|| exact_lane::exact_phrase(exact_input)) + .filter(|phrase| phrase.len() >= 3) + .filter(|phrase| { + match literal_presence( + ctx, + &snapshot, + project_root, + &generation, + phrase, + &exact_candidates, + ) { + Ok(presence) => presence == memo::LiteralPresence::Absent, + Err(error) => { + index_disclosures.push(error.to_string()); + false + } } - } - }) - .map(str::to_string); + }) + .map(str::to_string); let path_lookup_candidates = if plan.contains(SearchLaneKind::PathLookup) { let query_path_tokens = query @@ -7377,6 +7415,9 @@ fn search_index_ready_with_budget( ctx: &AppContext, wait_budget: Duration, ) -> Result { + if let Some(snapshot) = ctx.lexical_view_snapshot("aft_search") { + return Ok(snapshot.is_ok()); + } let deadline = Instant::now() + wait_budget; loop { if search_cancellation_requested() { diff --git a/crates/aft/src/commands/view_token.rs b/crates/aft/src/commands/view_token.rs new file mode 100644 index 0000000000..fb14ca48a2 --- /dev/null +++ b/crates/aft/src/commands/view_token.rs @@ -0,0 +1,70 @@ +//! Internal provisioning request, deliberately not an agent tool. Tokens are +//! advisory and are never a substitute for a driver's checkout verification. + +use crate::{ + context::AppContext, + protocol::{RawRequest, Response}, +}; + +pub fn handle_view_token(request: &RawRequest, context: &AppContext) -> Response { + if !context.config().views.enabled { + return Response::error( + &request.id, + "VIEW_TOKEN", + "view tokens require views to be enabled", + ); + } + let Some(root) = request.params.get("root").and_then(|v| v.as_str()) else { + return Response::error(&request.id, "VIEW_TOKEN", "root is required"); + }; + let Some(tree) = request.params.get("expected_tree").and_then(|v| v.as_str()) else { + return Response::error(&request.id, "VIEW_TOKEN", "expected_tree is required"); + }; + let Some(project) = context.canonical_cache_root_opt() else { + return Response::error( + &request.id, + "VIEW_TOKEN", + "view token requires a configured repository", + ); + }; + let storage = context.storage_paths().artifacts; + let family = match crate::search_index::artifact_cache_key_read_only(&project, &storage) { + Ok(family) => family, + Err(error) => return Response::error(&request.id, "VIEW_TOKEN", &error.to_string()), + }; + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64; + match crate::views::registry::mint_view_token( + &storage, + &family, + std::path::Path::new(root), + tree, + now, + ) { + Ok(token) => Response::success( + &request.id, + serde_json::json!({ "token": token.token, "expires_ms": token.expires_ms, "advisory": true }), + ), + Err(error) => Response::error(&request.id, "VIEW_TOKEN", &error.to_string()), + } +} + +#[cfg(test)] +mod tests { + #[test] + fn view_token_request_with_views_off_creates_no_v3_files() { + let storage = tempfile::tempdir().unwrap(); + let context = crate::context::AppContext::new( + Box::new(crate::parser::TreeSitterProvider::new()), + crate::config::Config { + storage_dir: Some(storage.path().to_path_buf()), + ..Default::default() + }, + ); + let request = serde_json::from_value(serde_json::json!({ "id": "token", "command": "view_token", "root": "/future", "expected_tree": "a".repeat(40) })).unwrap(); + assert!(!super::handle_view_token(&request, &context).success); + assert!(!storage.path().join("blobs/v3").exists()); + } +} diff --git a/crates/aft/src/context.rs b/crates/aft/src/context.rs index 48e3b14360..b9b33e696d 100644 --- a/crates/aft/src/context.rs +++ b/crates/aft/src/context.rs @@ -294,6 +294,12 @@ thread_local! { thread_local! { static CHECKOUT_QUERY_OUTCOMES: std::cell::RefCell)>> = const { std::cell::RefCell::new(Vec::new()) }; + static LEXICAL_QUERY_SNAPSHOTS: std::cell::RefCell)>> = + const { std::cell::RefCell::new(Vec::new()) }; + /// The call-graph reader each pinned request acquired from its checkout + /// view, so every graph read in one request answers from one generation. + static CALLGRAPH_QUERY_READERS: std::cell::RefCell, crate::views::callgraph::CallgraphViewError>)>> = + const { std::cell::RefCell::new(Vec::new()) }; } #[cfg(test)] @@ -384,6 +390,10 @@ pub struct ConfigPinGuard { impl Drop for ConfigPinGuard { fn drop(&mut self) { if let Some(key) = self.key { + LEXICAL_QUERY_SNAPSHOTS + .with(|snapshots| snapshots.borrow_mut().retain(|(id, _)| *id != key)); + CALLGRAPH_QUERY_READERS + .with(|readers| readers.borrow_mut().retain(|(id, _)| *id != key)); CHECKOUT_QUERY_OUTCOMES.with(|outcomes| { outcomes .borrow_mut() @@ -1720,6 +1730,9 @@ pub(crate) struct WatcherCounters { pub(crate) bash_flush: Arc, pub(crate) bash_changes: std::sync::OnceLock>, overflow_ignore_evidence: AtomicU8, + /// Legacy rescan work coalesces repeated drops, but a newly installed views + /// snapshot must still learn that delivery was lost during that work. + checkout_watcher_gap_pending: AtomicBool, fsevents_stream_creations_total: AtomicU64, raw_events_total: AtomicU64, raw_events_since_last_rescan: AtomicU64, @@ -1891,6 +1904,8 @@ impl WatcherCounters { .is_ok() { self.overflows_during_rescan.fetch_add(1, Ordering::Relaxed); + self.checkout_watcher_gap_pending + .store(true, Ordering::Release); return true; } } @@ -4544,6 +4559,10 @@ pub struct AppContext { #[cfg(test)] view_publication_attempt_starts: parking_lot::Mutex>, checkout_driver: crate::views::first_load::InstalledDriver, + lexical_view: parking_lot::Mutex>>, + lexical_routes: [AtomicU64; 3], + callgraph_view: parking_lot::Mutex>>, + callgraph_view_routes: AtomicU64, /// The views-on semantic lane of this root (see `views::semantic_runtime`). checkout_semantic: Arc, /// Wall-clock milliseconds at which the completion drain should next @@ -4601,6 +4620,8 @@ pub struct AppContext { // probe again after repeated load disconnects. search_index_disconnect_reschedule: parking_lot::Mutex<(u64, u32, Option)>, search_persist_epoch: crate::root_cache::ArtifactPublishEpoch, + legacy_search_publication: + parking_lot::Mutex>, pub(crate) search_corpus_refresh_lane: Arc, pending_search_index_paths: parking_lot::Mutex>, symbol_cache: SharedSymbolCache, @@ -5391,6 +5412,10 @@ impl AppContext { #[cfg(test)] view_publication_attempt_starts: parking_lot::Mutex::new(Vec::new()), checkout_driver: crate::views::first_load::InstalledDriver::default(), + lexical_view: parking_lot::Mutex::new(None), + lexical_routes: Default::default(), + callgraph_view: parking_lot::Mutex::new(None), + callgraph_view_routes: AtomicU64::new(0), checkout_semantic: Arc::default(), view_disk_limits_next_ms, checkout_query_runtime: RwLock::new(None), @@ -5425,6 +5450,7 @@ impl AppContext { search_pending_install: Arc::default(), search_index_disconnect_reschedule: parking_lot::Mutex::new((0, 0, None)), search_persist_epoch: crate::root_cache::ArtifactPublishEpoch::default(), + legacy_search_publication: Default::default(), search_corpus_refresh_lane: Arc::default(), pending_search_index_paths: parking_lot::Mutex::new(BTreeSet::new()), symbol_cache, @@ -8952,6 +8978,11 @@ impl AppContext { return None; }; let mut next = next; + if next.views.enabled { + self.legacy_search_publication.lock().revoke(); + } else if guard.views.enabled { + *self.legacy_search_publication.lock() = Arc::default(); + } next.aft_search_registered = next.tool_registered("aft_search"); #[cfg(test)] let next = self.app.isolate_test_config(next); @@ -8980,6 +9011,10 @@ impl AppContext { }); self.reconcile_rerank_backend(&next); self.arm_view_disk_limits(next.views.enabled); + if !next.views.enabled || project_root_changed { + self.lexical_view.lock().take(); + self.callgraph_view.lock().take(); + } if project_root_changed { self.path_restriction_root_memo.lock().take(); *self @@ -10514,6 +10549,12 @@ impl AppContext { pub(crate) fn record_checkout_watcher_change(&self, path: &Path) { self.reset_view_publication_retry(); + if let Some(view) = self.lexical_view.lock().as_ref() { + view.record_change(path); + } + if let Some(view) = self.callgraph_view.lock().as_ref() { + view.record_change(path); + } if let Some(driver) = self.checkout_driver.get() { driver.record_absolute_change(path); } @@ -10522,6 +10563,43 @@ impl AppContext { self.checkout_semantic.wake(); } + pub(crate) fn record_checkout_watcher_overflow(&self) { + self.reset_view_publication_retry(); + if let Some(view) = self.lexical_view.lock().as_ref() { + view.record_overflow(); + } + if let Some(view) = self.callgraph_view.lock().as_ref() { + view.record_overflow(); + } + if let Some(driver) = self.checkout_driver.get() { + driver.record_watcher_overflow(); + } + self.checkout_semantic.wake(); + } + + pub(crate) fn record_checkout_watcher_cookie(&self, path: &Path) { + self.record_checkout_pending_watcher_gap(); + if let Some(view) = self.lexical_view.lock().as_ref() { + view.record_cookie(path); + } + if let Some(view) = self.callgraph_view.lock().as_ref() { + view.record_cookie(path); + } + if let Some(driver) = self.checkout_driver.get() { + driver.record_watcher_cookie(path); + } + } + + pub(crate) fn record_checkout_pending_watcher_gap(&self) { + if self + .watcher_counters() + .checkout_watcher_gap_pending + .swap(false, Ordering::AcqRel) + { + self.record_checkout_watcher_overflow(); + } + } + pub(crate) fn view_publication_retry( &self, ) -> &parking_lot::Mutex { @@ -10879,6 +10957,36 @@ impl AppContext { Err(_) => CallgraphStoreAccess::Building, }; } + if self.callgraph_views_enabled() { + // Warming runs on the transport loop and must not bind a view + // there; only a use of the graph acquires it. + if !demand { + return CallgraphStoreAccess::Building; + } + return match self.callgraph_view_reader() { + Some(Ok(store)) => CallgraphStoreAccess::Ready(store), + // A lock held on the view's database says nothing about the + // graph: report SQLite's busy error, which callers present as + // building, retry shortly. + Some(Err(error)) if error.busy => CallgraphStoreAccess::Error( + CallGraphStoreError::Sqlite(rusqlite::Error::SqliteFailure( + rusqlite::ffi::Error::new(rusqlite::ffi::SQLITE_BUSY), + Some(error.reason), + )), + ), + Some(Err(error)) + if error + .reason + .contains(crate::views::read::CALLGRAPH_DISABLED) => + { + CallgraphStoreAccess::Error(CallGraphStoreError::Unavailable(error.reason)) + } + Some(Err(error)) => CallgraphStoreAccess::Error(CallGraphStoreError::Unavailable( + format!("callgraph view unavailable: {error}"), + )), + None => CallgraphStoreAccess::Unavailable, + }; + } if self.config().views.enabled && self.config().indexes.callgraph { let on_demand = self.callgraph_build_on_demand(); // An on-demand root that cannot yet compare its files with the @@ -11898,6 +12006,217 @@ impl AppContext { &self.search_index } + pub(crate) fn legacy_search_publication_gate( + &self, + ) -> Arc { + self.legacy_search_publication.lock().clone() + } + + pub(crate) fn lexical_views_enabled(&self) -> bool { + let config = self.config(); + config.views.enabled + && config.indexes.trigram + && config.project_root.is_some() + && self.heavy_root_work_allowed() + && self.cache_unavailable_reason().is_none() + } + + pub(crate) fn lexical_view_is_ready(&self) -> bool { + self.lexical_view + .lock() + .as_ref() + .is_some_and(|view| view.is_ready()) + } + + #[cfg(test)] + pub(crate) fn lexical_source_for_test( + &self, + ) -> Arc { + self.lexical_view.lock().as_ref().unwrap().source_for_test() + } + + #[cfg(test)] + pub(crate) fn install_lexical_source_for_test( + &self, + source: Arc, + ) { + let root = self.canonical_cache_root(); + let family = self.memoized_artifact_cache_key(&root); + *self.lexical_view.lock() = + Some(Arc::new(crate::views::trigram::LexicalView::with_source( + root, + self.storage_paths(), + family, + crate::blob_store::v2::TrigramPolicy { + max_file_size: self.config().search_index_max_file_size, + }, + source, + ))); + } + + /// With views enabled, lexical queries use the checkout-local view rather + /// than the legacy SearchIndex cache. The separate lexical driver keeps + /// lexical service independent of semantic models. + pub(crate) fn lexical_view_snapshot( + &self, + tool: &str, + ) -> Option> { + if !self.lexical_views_enabled() { + return None; + } + if let Some(snapshot) = LEXICAL_QUERY_SNAPSHOTS.with(|snapshots| { + snapshots + .borrow() + .iter() + .find(|(id, _)| *id == self.config_pin_key()) + .map(|(_, snapshot)| snapshot.clone()) + }) { + return Some(snapshot); + } + let config = self.config(); + let root = self.canonical_cache_root(); + let storage = self.storage_paths(); + let policy = crate::blob_store::v2::TrigramPolicy { + max_file_size: config.search_index_max_file_size, + }; + let family = self.memoized_artifact_cache_key(&root); + let view = { + let mut slot = self.lexical_view.lock(); + if !slot + .as_ref() + .is_some_and(|view| view.matches(&root, &storage, &family, policy)) + { + match crate::views::trigram::LexicalView::new(root, storage, family, policy) { + Ok(view) => *slot = Some(Arc::new(view)), + Err(error) => return Some(Err(error)), + } + } + slot.as_ref().unwrap().clone() + }; + self.record_checkout_pending_watcher_gap(); + let result = view.snapshot(); + if pinned_config_for(self.config_pin_key()).is_some() { + LEXICAL_QUERY_SNAPSHOTS.with(|snapshots| { + snapshots + .borrow_mut() + .push((self.config_pin_key(), result.clone())) + }); + } + if result.is_ok() && tool != "bind" { + let index = match tool { + "grep" => 0, + "glob" => 1, + _ => 2, + }; + self.lexical_routes[index].fetch_add(1, Ordering::Relaxed); + } + Some(result) + } + + /// Executed v3 lexical acquisitions, ordered as grep, glob, aft_search. + pub fn lexical_view_route_counts(&self) -> [u64; 3] { + std::array::from_fn(|index| self.lexical_routes[index].load(Ordering::Relaxed)) + } + + /// With views enabled, call-graph reads (navigation, zoom, inspect) use + /// the checkout's v3 view rather than the legacy store or a v1 view. An + /// explicitly installed checkout query runtime keeps precedence. + pub(crate) fn callgraph_views_enabled(&self) -> bool { + let config = self.config(); + config.views.enabled + && config.indexes.callgraph + && config.project_root.is_some() + && self.heavy_root_work_allowed() + && self.cache_unavailable_reason().is_none() + && !self.checkout_query_runtime_active() + } + + pub(crate) fn callgraph_view_is_ready(&self) -> bool { + self.callgraph_view + .lock() + .as_ref() + .is_some_and(|view| view.is_ready()) + } + + #[cfg(test)] + pub(crate) fn install_callgraph_source_for_test( + &self, + source: Arc, + ) { + let root = self.canonical_cache_root(); + let family = self.memoized_artifact_cache_key(&root); + *self.callgraph_view.lock() = Some(Arc::new( + crate::views::callgraph::CallgraphView::with_source( + root, + self.storage_paths(), + family, + crate::blob_store::v2::TrigramPolicy { + max_file_size: self.config().search_index_max_file_size, + }, + source, + ), + )); + } + + /// The call-graph reader of this checkout's view, acquired once per + /// pinned request. `None` means views do not route call-graph reads; an + /// acquisition error is final and never answered from another store. + pub(crate) fn callgraph_view_reader( + &self, + ) -> Option, crate::views::callgraph::CallgraphViewError>> + { + if !self.callgraph_views_enabled() { + return None; + } + if let Some(reader) = CALLGRAPH_QUERY_READERS.with(|readers| { + readers + .borrow() + .iter() + .find(|(id, _)| *id == self.config_pin_key()) + .map(|(_, reader)| reader.clone()) + }) { + return Some(reader); + } + let config = self.config(); + let root = self.canonical_cache_root(); + let storage = self.storage_paths(); + let policy = crate::blob_store::v2::TrigramPolicy { + max_file_size: config.search_index_max_file_size, + }; + let family = self.memoized_artifact_cache_key(&root); + let view = { + let mut slot = self.callgraph_view.lock(); + if !slot + .as_ref() + .is_some_and(|view| view.matches(&root, &storage, &family, policy)) + { + match crate::views::callgraph::CallgraphView::new(root, storage, family, policy) { + Ok(view) => *slot = Some(Arc::new(view)), + Err(error) => return Some(Err(error.into())), + } + } + slot.as_ref().unwrap().clone() + }; + self.record_checkout_pending_watcher_gap(); + let result = view.reader(); + if pinned_config_for(self.config_pin_key()).is_some() { + CALLGRAPH_QUERY_READERS.with(|readers| { + readers + .borrow_mut() + .push((self.config_pin_key(), result.clone())) + }); + } + if result.is_ok() { + self.callgraph_view_routes.fetch_add(1, Ordering::Relaxed); + } + Some(result) + } + + /// Executed v3 call-graph acquisitions (one per pinned request). + pub fn callgraph_view_route_count(&self) -> u64 { + self.callgraph_view_routes.load(Ordering::Relaxed) + } + pub(crate) fn search_exact_memo( &self, ) -> Arc { @@ -12341,6 +12660,10 @@ impl AppContext { } fn search_index_shutdown_outcome(&self) -> SearchIndexShutdownOutcome { + if self.config().views.enabled { + return SearchIndexShutdownOutcome::Skipped; + } + let _publication = self.legacy_search_publication_gate().install(); use SearchIndexShutdownOutcome::{Failed, Flushed, Skipped}; if self.cache_unavailable_reason().is_some() { return Failed("cache root is unavailable"); @@ -20125,10 +20448,15 @@ mod callgraph_store_for_ops_tests { assert!(!message.contains("retry shortly"), "{message}"); } + /// With views on, a commit that changes the graph is answered from the + /// checkout as it now is: the stale legacy store, which still holds the + /// removed call, never answers. #[test] - fn views_pending_head_callers_reports_building_instead_of_serving_legacy() { + fn views_changed_head_callers_answer_the_current_tree_not_the_stale_legacy_store() { let fixture = ViewsCallgraphFixture::new(); - fixture.publish(); + let before = fixture.callers(); + assert!(before.success, "{:#}", before.data); + assert_eq!(before.data["total_callers"], 1, "{:#}", before.data); fixture.commit_source( "pub fn caller() {}\npub fn target() {}\n", "remove stale call", @@ -20145,55 +20473,53 @@ mod callgraph_store_for_ops_tests { let response = fixture.callers(); - assert!(!response.success, "pending HEAD must not serve stale rows"); - assert_eq!(response.data["code"], "callgraph_building"); + assert!(response.success, "{:#}", response.data); assert_eq!( - response.data["message"], - "callers: callgraph store is building in the background; retry shortly" + response.data["total_callers"], 0, + "the changed HEAD must not serve stale rows: {:#}", + response.data ); } impl ViewsCallgraphFixture { - /// Publish the current HEAD as a generation without call graph data, - /// as a session with the call graph off does, and pin it as this - /// context's view, as configure does when it loads that generation. - fn publish_without_callgraph(&self) -> String { - let view = self.ctx.view_runtime_snapshot().expect("view runtime"); - let head = crate::alias::head_tree_entries(&self.root).expect("HEAD entries"); - let desired_head = crate::views::assembly::head_tree_fingerprint(&head); - let report = crate::views::assembly::publish_checkout( - &crate::views::assembly::AssemblyRequest { - storage: view.storage.clone(), - project_root: self.root.clone(), - family: view.family.clone(), - scope: view.scope.clone(), - desired_head: desired_head.clone(), - changed_paths: Default::default(), - semantic_keys: Default::default(), - require_semantic: false, - allow_blob_put: true, - callgraph: false, - }, + /// Hold this checkout's v3 call-graph view database busy the way a + /// writer does mid-maintenance: an exclusive lock that a reader cannot + /// get past. Binds the view first (one answered query), then drops + /// this context's view (turning views off and on again) so the next + /// query must open the database, and waits for the deferred checkpoint + /// so only the returned connection holds the file. + fn hold_checkout_view_derived_busy(&self) -> rusqlite::Connection { + let bound = self.callers(); + assert!(bound.success, "{:#}", bound.data); + self.ctx + .update_config(|config| config.views.enabled = false); + self.ctx.update_config(|config| config.views.enabled = true); + let storage = self.ctx.storage_paths().artifacts; + let family = self.ctx.memoized_artifact_cache_key(&self.root); + let member = crate::blob_store::v3::view_dir( + &storage, + &family, + &crate::views::callgraph::view_scope(&self.root), ) - .expect("keyless view publication"); - assert!(report.published, "keyless generation must publish"); - let manifest = report.manifest.expect("published manifest"); - assert!(crate::views::assembly::manifest_lacks_callgraph(&manifest)); - let generation = report.generation.expect("published generation"); - let pin = - crate::pins::QueryPin::acquire(&view.view_dir, &generation).expect("query pin"); - self.ctx.install_view_runtime( - ViewRuntimeSnapshot { - generation: Some(generation.clone()), - manifest: Some(manifest), - head_fingerprint: desired_head, - pending_paths: BTreeSet::new(), - pending_inputs: BTreeMap::new(), - ..view - }, - Some(pin), + .expect("v3 member directory"); + crate::views::wait_for_derived_checkpoint_for_test(&member); + let generation = crate::views::ViewStore::open_dir(member.clone()) + .expect("v3 member view") + .current_generation() + .expect("v3 pointer") + .expect("published v3 generation"); + let path = + crate::views::resolve_derived_path(&member, &generation).expect("derived path"); + assert!( + path.is_file(), + "derived database {} missing", + path.display() ); - generation + let holder = rusqlite::Connection::open(&path).expect("derived writer"); + holder + .execute_batch("PRAGMA locking_mode=EXCLUSIVE; BEGIN EXCLUSIVE;") + .expect("exclusive derived lock"); + holder } /// Hold the derived database of the pinned generation busy the way a @@ -20220,43 +20546,46 @@ mod callgraph_store_for_ops_tests { } } - /// Whether a generation carries a call graph is a property of its - /// manifest alone. A generation published with the call graph off must - /// answer "disabled" even while its derived database is busy; answering - /// "building, retry" would send the agent into a retry loop for a graph - /// that will never be built from this generation. + /// A call graph turned off is never built and never answered: with views + /// on and `indexes.callgraph=false` the call-graph tools report the index + /// off without binding a checkout view. #[test] - fn views_keyless_generation_answers_disabled_while_derived_store_is_busy() { + fn views_callgraph_index_off_answers_off_without_binding_a_view() { let fixture = ViewsCallgraphFixture::new(); - fixture.publish_without_callgraph(); - let _busy = fixture.hold_pinned_derived_busy(); + fixture + .ctx + .update_config(|config| config.indexes.callgraph = false); let response = fixture.callers(); assert!(!response.success, "{:#}", response.data); assert_eq!( - response.data["code"], "callgraph_unavailable", - "{:#}", - response.data - ); - assert!( - response.data["message"] - .as_str() - .is_some_and(|message| message.contains(crate::views::read::CALLGRAPH_DISABLED)), + response.data["code"], "callgraph_off", "{:#}", response.data ); + assert!(matches!( + fixture.ctx.callgraph_store_for_ops(), + CallgraphStoreAccess::Off + )); + assert_eq!(fixture.ctx.callgraph_view_route_count(), 0); + let member = crate::blob_store::v3::view_dir( + &fixture.ctx.storage_paths().artifacts, + &fixture.ctx.memoized_artifact_cache_key(&fixture.root), + &crate::views::callgraph::view_scope(&fixture.root), + ) + .unwrap(); + assert!(!member.exists()); } - /// The counterpart: a generation that does carry a call graph, read while + /// A generation that does carry a call graph, read while /// its derived database is busy, is temporarily unreadable and says so as /// a retryable `callgraph_building`. This also proves the busy hold above /// really blocks a reader that opens the store. #[test] fn views_keyed_generation_answers_building_while_derived_store_is_busy() { let fixture = ViewsCallgraphFixture::new(); - fixture.publish(); - let _busy = fixture.hold_pinned_derived_busy(); + let busy = fixture.hold_checkout_view_derived_busy(); let response = fixture.callers(); @@ -20270,13 +20599,15 @@ mod callgraph_store_for_ops_tests { response.data["message"], "callers: persisted callgraph store is busy; retry shortly" ); + drop(busy); + let response = fixture.callers(); + assert!(response.success, "{:#}", response.data); } #[test] fn views_keyed_generation_inspect_reports_pending_while_derived_store_is_busy() { let fixture = ViewsCallgraphFixture::new(); fixture.ctx.set_harness(crate::harness::Harness::Runner); - fixture.publish(); let inspect = || { crate::commands::inspect::handle_inspect( &RawRequest { @@ -20292,7 +20623,7 @@ mod callgraph_store_for_ops_tests { &fixture.ctx, ) }; - let busy = fixture.hold_pinned_derived_busy(); + let busy = fixture.hold_checkout_view_derived_busy(); let response = inspect(); @@ -20438,7 +20769,6 @@ mod callgraph_store_for_ops_tests { #[test] fn views_keyed_generation_zoom_reports_building_while_derived_store_is_busy() { let fixture = ViewsCallgraphFixture::new(); - fixture.publish(); let zoom = || { crate::commands::zoom::handle_zoom( &RawRequest { @@ -20455,7 +20785,7 @@ mod callgraph_store_for_ops_tests { &fixture.ctx, ) }; - let busy = fixture.hold_pinned_derived_busy(); + let busy = fixture.hold_checkout_view_derived_busy(); let response = zoom(); diff --git a/crates/aft/src/db/file_identity.rs b/crates/aft/src/db/file_identity.rs index 2e2bc939b6..22d06c9913 100644 --- a/crates/aft/src/db/file_identity.rs +++ b/crates/aft/src/db/file_identity.rs @@ -400,16 +400,20 @@ impl IdentityConnection { pub(crate) fn open(path: impl AsRef, seam: &'static str) -> rusqlite::Result { super::lifecycle::production_write_gate(path.as_ref())?; + crate::views::donor::refuse_sealed_write(path.as_ref())?; let _guard = filesystem_guard(); crate::private_storage::prepare_sqlite(path.as_ref(), rusqlite::OpenFlags::default())?; + // Long view-store paths (and their `-journal`/`-wal` sidecars) pass + // Windows' MAX_PATH; `sqlite_open_path` spells those in `\\?\` form. + let sqlite_path = super::lifecycle::sqlite_open_path(path.as_ref()); #[cfg(test)] let connection = crate::views::materialization::open_probe_connection( - path.as_ref(), + &sqlite_path, rusqlite::OpenFlags::default(), )?; #[cfg(not(test))] let connection = crate::views::write_accounting::open_connection( - path.as_ref(), + &sqlite_path, rusqlite::OpenFlags::default(), )?; Ok(Self::new(connection, seam)) @@ -422,14 +426,24 @@ impl IdentityConnection { ) -> rusqlite::Result { if !flags.contains(rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY) { super::lifecycle::production_write_gate(path.as_ref())?; + crate::views::donor::refuse_sealed_write(path.as_ref())?; } let _guard = filesystem_guard(); crate::private_storage::prepare_sqlite(path.as_ref(), flags)?; + // A `file:` URI is SQLite syntax, not a filesystem path, so it is + // passed through; plain paths go through `sqlite_open_path`, which + // adds the Windows long-path spelling when a path needs it. + let sqlite_path = if flags.contains(rusqlite::OpenFlags::SQLITE_OPEN_URI) + && path.as_ref().to_string_lossy().starts_with("file:") + { + std::borrow::Cow::Borrowed(path.as_ref()) + } else { + super::lifecycle::sqlite_open_path(path.as_ref()) + }; #[cfg(test)] - let connection = - crate::views::materialization::open_probe_connection(path.as_ref(), flags)?; + let connection = crate::views::materialization::open_probe_connection(&sqlite_path, flags)?; #[cfg(not(test))] - let connection = crate::views::write_accounting::open_connection(path.as_ref(), flags)?; + let connection = crate::views::write_accounting::open_connection(&sqlite_path, flags)?; Ok(Self::new(connection, seam)) } diff --git a/crates/aft/src/executor/view_publication_tests.rs b/crates/aft/src/executor/view_publication_tests.rs index 1dd9fc68c3..c577473d74 100644 --- a/crates/aft/src/executor/view_publication_tests.rs +++ b/crates/aft/src/executor/view_publication_tests.rs @@ -1783,63 +1783,6 @@ fn inspect_view_pending_publication_reschedules_dead_code_without_another_inspec ); } -#[test] -fn inspect_view_pending_reply_names_publication_not_changed_sources() { - use crate::inspect::{InspectCategory, InspectSnapshot, JobScope}; - let fixture = Fixture::new(); - fixture.ctx.set_harness(crate::harness::Harness::Opencode); - let root = fixture.ctx.canonical_cache_root(); - let family = crate::search_index::artifact_cache_key(&root); - crate::root_cache::configure_artifact_access(&root, &family, false); - let snapshot = InspectSnapshot { - project_root: root.clone(), - inspect_dir: fixture.ctx.inspect_dir(), - config: fixture.ctx.config(), - symbol_cache: fixture.ctx.symbol_cache(), - inspect_writer: true, - callgraph_writer: true, - }; - let initial = fixture - .ctx - .inspect_manager() - .tier2_run_with_reuse_blocking_fresh( - snapshot, - InspectCategory::DeadCode, - JobScope::for_project(root), - ); - assert_eq!(initial.payload().unwrap()["callgraph_available"], true); - fixture.change("waiting_for_view"); - let response = crate::commands::inspect::handle_inspect_warm_for_test( - &crate::protocol::RawRequest { - id: "pending-view".into(), - command: "inspect".into(), - params: serde_json::json!({"scope": "tracked.rs", "sections": ["dead_code"]}), - session_id: None, - lsp_hints: None, - }, - &fixture.ctx, - ); - assert!(response.success, "{:?}", response.data); - let text = response.data["text"].as_str().unwrap(); - assert!( - text.contains("dead code waits for this checkout's index view to publish"), - "{text}" - ); - assert!( - text.contains("publication schedules a Tier-2 refresh"), - "{text}" - ); - assert!(!text.contains("sources or configuration changed"), "{text}"); - assert!( - !text.contains("cached source contributions, file set, or analysis configuration changed"), - "{text}" - ); - assert_eq!( - response.data["summary"]["dead_code"]["gaps"][0]["kind"], - "view_pending" - ); -} - #[test] fn publication_busy_readiness_uses_backoff_and_recovers_after_unlock() { let fixture = Fixture::new(); @@ -2660,13 +2603,15 @@ fn publication_build_does_not_delay_same_root_bind_and_read() { Lane::PureRead, "view.read".to_owned(), Box::new(|ctx| { + // The checkout view answers from the tree as it now is, without + // waiting for the held publication. let CallgraphStoreAccess::Ready(store) = ctx.callgraph_store_for_ops() else { - panic!("previous view reader unavailable") + panic!("checkout view reader unavailable") }; assert!(crate::callgraph_store::CallGraphRead::node_for( &store, Path::new("tracked.rs"), - "previous" + "next" ) .is_ok()); Response::success("view.read", serde_json::json!({})) @@ -2684,7 +2629,7 @@ fn publication_build_does_not_delay_same_root_bind_and_read() { .expect("same-root read must complete while publication is held"); assert!( read_result.success, - "previous view read failed: {read_result:?}" + "same-root view read failed: {read_result:?}" ); assert_eq!( fixture.view.current_generation().unwrap().as_deref(), @@ -2751,77 +2696,6 @@ fn invalidated_publication_cancels_before_derived_and_removes_generation_files() .is_ok()); } -#[test] -fn generation_sweep_waits_for_the_last_query_handle() { - let fixture = Fixture::new(); - let CallgraphStoreAccess::Ready(previous_reader) = fixture.ctx.callgraph_store_for_ops() else { - panic!("previous reader unavailable") - }; - // The initial publication also owns a deferred checkpoint connection. Settle - // it before replacing the view, so the old query handle is the only keeper - // of the obsolete database whose lifetime this test asserts. - crate::views::wait_for_generation_maintenance_for_test(fixture.view.view_dir()); - // The replacing publication schedules its own generation cleanup, which - // would reclaim the old generation as soon as the reader drops, racing the - // explicit sweeps below. Park that worker at the start of each lap until - // they have run, so these sweeps alone decide what is removed. - let (release_cleanup, parked) = std::sync::mpsc::channel::<()>(); - let parked = Mutex::new(parked); - let parked_laps = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); - let counted = std::sync::Arc::clone(&parked_laps); - let parking = crate::storage_retention::observe_test_hook( - fixture.view.view_dir(), - std::sync::Arc::new(move |step| { - if step == "post-publication-sweep" { - counted.fetch_add(1, std::sync::atomic::Ordering::SeqCst); - // Returns once the sender is dropped. - let _ = parked.lock().recv_timeout(Duration::from_secs(60)); - } - }), - ); - fixture.change("next"); - fixture.schedule(); - fixture.wait_idle(); - assert_eq!(fixture.view.sweep_generations().unwrap(), 0); - assert!(fixture - .view - .derived_path(&fixture.initial) - .unwrap() - .is_file()); - assert!(crate::callgraph_store::CallGraphRead::node_for( - &previous_reader, - Path::new("tracked.rs"), - "previous" - ) - .is_ok()); - drop(previous_reader); - assert_eq!(fixture.view.sweep_generations().unwrap(), 1); - assert!(!fixture - .view - .derived_path(&fixture.initial) - .unwrap() - .exists()); - assert!(!fixture - .view - .manifest_path(&fixture.initial) - .unwrap() - .exists()); - // Let the parked cleanup run. It must find nothing more to remove, and it - // must actually have been parked, or the explicit sweeps above raced it. - drop(release_cleanup); - crate::views::wait_for_generation_maintenance_for_test(fixture.view.view_dir()); - drop(parking); - assert!( - parked_laps.load(std::sync::atomic::Ordering::SeqCst) > 0, - "the replacing publication's cleanup was never parked" - ); - assert!(!fixture - .view - .derived_path(&fixture.initial) - .unwrap() - .exists()); -} - #[test] fn publication_health_reports_root_and_each_off_lane_phase() { let fixture = Fixture::new(); diff --git a/crates/aft/src/feature_status.rs b/crates/aft/src/feature_status.rs index 64e0014396..f40b34dab2 100644 --- a/crates/aft/src/feature_status.rs +++ b/crates/aft/src/feature_status.rs @@ -271,6 +271,13 @@ pub fn storage_refusal( // index is being replaced or updated, which reports as building. fn observe_trigram(ctx: &AppContext) -> IndexObservation { + if ctx.lexical_views_enabled() { + return if ctx.lexical_view_is_ready() { + IndexObservation::ready() + } else { + IndexObservation::building() + }; + } let resident = match ctx.search_index().try_read() { Ok(guard) => guard.as_ref().map(|index| index.ready), Err(std::sync::TryLockError::Poisoned(poisoned)) => { @@ -396,6 +403,13 @@ pub const fn local_semantic_platform_supported() -> bool { } fn observe_callgraph(ctx: &AppContext) -> IndexObservation { + if ctx.callgraph_views_enabled() { + return if ctx.callgraph_view_is_ready() { + IndexObservation::ready() + } else { + IndexObservation::building() + }; + } let resident = match ctx.callgraph_store().try_read() { Ok(guard) => guard.is_some(), Err(std::sync::TryLockError::Poisoned(poisoned)) => poisoned.into_inner().is_some(), @@ -459,6 +473,7 @@ pub fn observe_callgraph_query(ctx: &AppContext) -> IndexObservation { /// transient busy or locked error while opening the published view database. pub fn observe_callgraph_query_with_contention(ctx: &AppContext) -> (IndexObservation, bool) { let served_by_view = ctx.checkout_query_runtime_active() + || ctx.callgraph_views_enabled() || (ctx.config().views.enabled && ctx .pinned_view_runtime() diff --git a/crates/aft/src/git_index_reader.rs b/crates/aft/src/git_index_reader.rs new file mode 100644 index 0000000000..4cf199de00 --- /dev/null +++ b/crates/aft/src/git_index_reader.rs @@ -0,0 +1,1637 @@ +//! Read-only Git index snapshots and counted, deadline-bound Git metadata queries. +//! +//! A snapshot records the index bytes observed, not proof that working-tree bytes +//! are clean. Callers must verify working-tree content independently. + +use crate::file_identity::FileIdentity; +use sha1::Digest; +use std::fs::{self, File, Metadata}; +use std::io::{self, Read, Write}; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output, Stdio}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::mpsc; +use std::thread; +use std::time::{Duration, Instant, SystemTime}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ObjectFormat { + Sha1, + Sha256, +} + +impl ObjectFormat { + /// The format whose object IDs are `hex_len` hex digits long. + pub fn from_hex_len(hex_len: usize) -> Option { + match hex_len { + 40 => Some(Self::Sha1), + 64 => Some(Self::Sha256), + _ => None, + } + } + + /// Git's name for the format (`extensions.objectFormat`). + pub fn name(self) -> &'static str { + match self { + Self::Sha1 => "sha1", + Self::Sha256 => "sha256", + } + } + + /// The object ID Git gives `bytes` stored as a blob: the digest of + /// `blob \0`. These are the exact bytes read, so the caller + /// must already know that no conversion applies between them and the blob. + pub fn blob_oid(self, bytes: &[u8]) -> Vec { + self.object_oid(b"blob", bytes) + } + + fn object_oid(self, kind: &[u8], body: &[u8]) -> Vec { + let mut object = Vec::with_capacity(body.len() + 32); + object.extend_from_slice(kind); + object.push(b' '); + object.extend_from_slice(body.len().to_string().as_bytes()); + object.push(0); + object.extend_from_slice(body); + self.digest(&object) + } + + fn oid_len(self) -> usize { + match self { + Self::Sha1 => 20, + Self::Sha256 => 32, + } + } + + fn digest(self, bytes: &[u8]) -> Vec { + match self { + Self::Sha1 => sha1::Sha1::digest(bytes).to_vec(), + Self::Sha256 => sha2::Sha256::digest(bytes).to_vec(), + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct IndexTime { + pub seconds: u32, + pub nanoseconds: u32, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct IndexStat { + pub ctime: IndexTime, + pub mtime: IndexTime, + pub dev: u32, + pub ino: u32, + pub mode: u32, + pub uid: u32, + pub gid: u32, + pub size: u32, +} + +/// Full-width lstat fields supplied by the caller; times are Unix seconds. +/// On Windows ctime here means Git's creation time, not change-sensitive evidence. +#[derive(Clone, Debug)] +pub struct WorktreeStat { + pub ctime_seconds: i64, + pub ctime_nanoseconds: u32, + pub mtime_seconds: i64, + pub mtime_nanoseconds: u32, + pub dev: u64, + pub ino: u64, + pub mode: u32, + pub size: u64, +} + +impl IndexStat { + /// Compare the recorded tuple only, never infer clean content from it. + /// Git stores low 32 bits, including for signed seconds. Git for Windows + /// does not record device/inode, so those fields cannot participate there. + pub fn matches_stat(&self, stat: &WorktreeStat) -> bool { + let identity_matches = + cfg!(windows) || (self.dev == stat.dev as u32 && self.ino == stat.ino as u32); + identity_matches + && self.size == stat.size as u32 + && self.mode == stat.mode + && self.ctime.seconds == stat.ctime_seconds as u32 + && self.ctime.nanoseconds == stat.ctime_nanoseconds + && self.mtime.seconds == stat.mtime_seconds as u32 + && self.mtime.nanoseconds == stat.mtime_nanoseconds + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum EntryException { + Unmerged(u8), + AssumeValid, + SkipWorktree, + IntentToAdd, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct IndexEntry { + /// Git paths are bytes, not necessarily UTF-8, and use '/' on every platform. + pub path: Vec, + pub oid: Vec, + pub stat: IndexStat, + pub stage: u8, + pub assume_valid: bool, + pub skip_worktree: bool, + pub intent_to_add: bool, +} + +impl IndexEntry { + pub fn exceptions(&self) -> Vec { + let mut exceptions = Vec::new(); + if self.stage != 0 { + exceptions.push(EntryException::Unmerged(self.stage)); + } + if self.assume_valid { + exceptions.push(EntryException::AssumeValid); + } + if self.skip_worktree { + exceptions.push(EntryException::SkipWorktree); + } + if self.intent_to_add { + exceptions.push(EntryException::IntentToAdd); + } + exceptions + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct IndexSnapshot { + pub version: u32, + pub object_format: ObjectFormat, + pub entries: Vec, + /// The verified trailer also identifies the exact index bytes observed. + pub checksum: Vec, + pub modified: SystemTime, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum StrictReason { + IndexLocked, + Unreadable(io::ErrorKind), + Unstable, + ChecksumMismatch, + UnsupportedVersion(u32), + RequiredExtension([u8; 4]), + SparseDirectory, + Malformed(&'static str), +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum IndexRead { + Snapshot(IndexSnapshot), + Strict(StrictReason), +} + +#[derive(Clone, Copy, Debug)] +pub struct ReadOptions { + pub attempts: usize, + /// Total budget for waiting on index.lock, shared by all attempts. + pub lock_wait: Duration, + pub retry_delay: Duration, +} + +impl Default for ReadOptions { + fn default() -> Self { + Self { + attempts: 3, + lock_wait: Duration::from_millis(100), + retry_delay: Duration::from_millis(5), + } + } +} + +/// Read the explicitly supplied index (including linked-worktree/custom indexes). +/// The caller supplies the repository's object format; no Git discovery or writes +/// are hidden in a snapshot read. Strict requires an independent worktree scan +/// rather than using the index to avoid content reads. +pub fn read_index(path: &Path, format: ObjectFormat, options: ReadOptions) -> IndexRead { + read_index_with_hook(path, format, options, || {}) +} + +fn read_index_with_hook( + path: &Path, + format: ObjectFormat, + options: ReadOptions, + mut after_read: impl FnMut(), +) -> IndexRead { + let mut lock_name = path.as_os_str().to_os_string(); + lock_name.push(".lock"); + let lock_path = PathBuf::from(lock_name); + let mut lock_remaining = options.lock_wait; + let mut reason = StrictReason::Unstable; + for attempt in 0..options.attempts { + if let Err(reason) = wait_for_unlock(&lock_path, &mut lock_remaining) { + return IndexRead::Strict(reason); + } + let result = read_once(path, format, &mut after_read); + // A lock appearing during the read invalidates even checksum-valid bytes. + let saw_lock = match wait_for_unlock(&lock_path, &mut lock_remaining) { + Ok(saw_lock) => saw_lock, + Err(reason) => return IndexRead::Strict(reason), + }; + match if saw_lock { + Err(StrictReason::Unstable) + } else { + result + } { + Ok(snapshot) => { + // If the writer replaced the pathname while we held the old open + // file, read_once rejects it rather than publishing the old index. + return IndexRead::Snapshot(snapshot); + } + Err(error) => { + let retry = matches!( + error, + StrictReason::Unstable + | StrictReason::ChecksumMismatch + | StrictReason::Unreadable(_) + ); + reason = error; + if !retry { + break; + } + } + } + if attempt + 1 < options.attempts { + thread::sleep(options.retry_delay); + } + } + IndexRead::Strict(reason) +} + +fn wait_for_unlock(path: &Path, remaining: &mut Duration) -> Result { + let start = Instant::now(); + let mut saw_lock = false; + loop { + match fs::symlink_metadata(path) { + Err(error) if error.kind() == io::ErrorKind::NotFound => { + *remaining = remaining.saturating_sub(start.elapsed()); + return Ok(saw_lock); + } + Err(error) => return Err(StrictReason::Unreadable(error.kind())), + Ok(_) => saw_lock = true, + } + if start.elapsed() >= *remaining { + *remaining = Duration::ZERO; + return Err(StrictReason::IndexLocked); + } + thread::sleep(Duration::from_millis(5).min(*remaining - start.elapsed().min(*remaining))); + } +} + +fn read_once( + path: &Path, + format: ObjectFormat, + after_read: &mut impl FnMut(), +) -> Result { + let io_reason = |error: io::Error| StrictReason::Unreadable(error.kind()); + let mut file = File::open(path).map_err(io_reason)?; + let before = file.metadata().map_err(io_reason)?; + let before_identity = FileIdentity::of_file(&file); + if !before.is_file() { + return Err(StrictReason::Malformed("index is not a regular file")); + } + let mut bytes = Vec::new(); + file.read_to_end(&mut bytes).map_err(io_reason)?; + after_read(); + let after = file.metadata().map_err(io_reason)?; + let after_identity = FileIdentity::of_file(&file); + let (current, current_identity) = crate::file_identity::metadata(path).map_err(io_reason)?; + if !same_snapshot(&before, before_identity, &after, after_identity) + || !same_snapshot(&after, after_identity, ¤t, current_identity) + || before.len() != bytes.len() as u64 + { + return Err(StrictReason::Unstable); + } + parse_index(&bytes, format, before.modified().map_err(io_reason)?) +} + +fn same_snapshot( + before: &Metadata, + before_identity: FileIdentity, + after: &Metadata, + after_identity: FileIdentity, +) -> bool { + before.len() == after.len() + && before.modified().ok().is_some() + && before.modified().ok() == after.modified().ok() + && before_identity != FileIdentity::Unavailable + && before_identity == after_identity +} + +struct Cursor<'a> { + bytes: &'a [u8], + pos: usize, +} + +impl<'a> Cursor<'a> { + fn take(&mut self, len: usize) -> Result<&'a [u8], StrictReason> { + let end = self + .pos + .checked_add(len) + .ok_or(StrictReason::Malformed("length overflow"))?; + let bytes = self + .bytes + .get(self.pos..end) + .ok_or(StrictReason::Malformed("truncated index"))?; + self.pos = end; + Ok(bytes) + } + + fn u32(&mut self) -> Result { + Ok(u32::from_be_bytes(self.take(4)?.try_into().unwrap())) + } + + fn u16(&mut self) -> Result { + Ok(u16::from_be_bytes(self.take(2)?.try_into().unwrap())) + } + + fn nul_string(&mut self) -> Result<&'a [u8], StrictReason> { + let len = self.bytes[self.pos..] + .iter() + .position(|&b| b == 0) + .ok_or(StrictReason::Malformed("unterminated path"))?; + let string = self.take(len)?; + self.take(1)?; + Ok(string) + } + + fn strip_count(&mut self) -> Result { + let mut byte = self.take(1)?[0]; + let mut count = usize::from(byte & 0x7f); + while byte & 0x80 != 0 { + byte = self.take(1)?[0]; + count = count + .checked_add(1) + .and_then(|n| n.checked_mul(128)) + .and_then(|n| n.checked_add(usize::from(byte & 0x7f))) + .ok_or(StrictReason::Malformed("v4 prefix overflow"))?; + } + Ok(count) + } +} + +fn parse_index( + bytes: &[u8], + format: ObjectFormat, + modified: SystemTime, +) -> Result { + let trailer = format.oid_len(); + let body_len = bytes + .len() + .checked_sub(trailer) + .filter(|&n| n >= 12) + .ok_or(StrictReason::ChecksumMismatch)?; + let (body, checksum) = bytes.split_at(body_len); + if format.digest(body) != checksum { + return Err(StrictReason::ChecksumMismatch); + } + let mut cursor = Cursor { + bytes: body, + pos: 0, + }; + if cursor.take(4)? != b"DIRC" { + return Err(StrictReason::Malformed("invalid signature")); + } + let version = cursor.u32()?; + if !(2..=4).contains(&version) { + return Err(StrictReason::UnsupportedVersion(version)); + } + let count = cursor.u32()?; + let mut entries: Vec = Vec::new(); + for _ in 0..count { + let start = cursor.pos; + let stat = IndexStat { + ctime: IndexTime { + seconds: cursor.u32()?, + nanoseconds: cursor.u32()?, + }, + mtime: IndexTime { + seconds: cursor.u32()?, + nanoseconds: cursor.u32()?, + }, + dev: cursor.u32()?, + ino: cursor.u32()?, + mode: cursor.u32()?, + uid: cursor.u32()?, + gid: cursor.u32()?, + size: cursor.u32()?, + }; + if stat.ctime.nanoseconds >= 1_000_000_000 || stat.mtime.nanoseconds >= 1_000_000_000 { + return Err(StrictReason::Malformed("invalid stat nanoseconds")); + } + let oid = cursor.take(trailer)?.to_vec(); + let flags = cursor.u16()?; + let extended = if flags & 0x4000 != 0 { + if version == 2 { + return Err(StrictReason::Malformed("extended flags in v2")); + } + let extended = cursor.u16()?; + if extended & !0x6000 != 0 { + return Err(StrictReason::Malformed("unknown extended entry flags")); + } + extended + } else { + 0 + }; + let path = if version == 4 { + let strip = cursor.strip_count()?; + let previous = entries.last().map_or(&[][..], |entry| &entry.path); + let prefix_len = previous + .len() + .checked_sub(strip) + .ok_or(StrictReason::Malformed("v4 prefix exceeds previous path"))?; + let mut path = previous[..prefix_len].to_vec(); + path.extend_from_slice(cursor.nul_string()?); + path + } else { + let path = cursor.nul_string()?.to_vec(); + let padding = (8 - (cursor.pos - start) % 8) % 8; + if cursor.take(padding)?.iter().any(|&byte| byte != 0) { + return Err(StrictReason::Malformed("nonzero entry padding")); + } + path + }; + if usize::from(flags & 0x0fff) != path.len().min(0x0fff) { + return Err(StrictReason::Malformed("entry path length mismatch")); + } + entries.push(IndexEntry { + path, + oid, + stat, + stage: ((flags >> 12) & 3) as u8, + assume_valid: flags & 0x8000 != 0, + skip_worktree: extended & 0x4000 != 0, + intent_to_add: extended & 0x2000 != 0, + }); + } + while cursor.pos < body.len() { + let signature: [u8; 4] = cursor.take(4)?.try_into().unwrap(); + let len = cursor.u32()? as usize; + cursor.take(len)?; + if !signature[0].is_ascii_uppercase() { + return Err(StrictReason::RequiredExtension(signature)); + } + } + // Split indexes can contain empty names; inspect extensions before validating + // paths so their mandatory 'link' extension is reported as the strict reason. + for (i, entry) in entries.iter().enumerate() { + if entry.stat.mode & 0o170000 == 0o040000 { + return Err(StrictReason::SparseDirectory); + } + if entry.path.is_empty() + || entry + .path + .split(|&b| b == b'/') + .any(|p| p.is_empty() || p == b"." || p == b"..") + { + return Err(StrictReason::Malformed("invalid repository-relative path")); + } + if i > 0 { + let previous = &entries[i - 1]; + if (&previous.path, previous.stage) >= (&entry.path, entry.stage) { + return Err(StrictReason::Malformed("entries out of order")); + } + } + } + Ok(IndexSnapshot { + version, + object_format: format, + entries, + checksum: checksum.to_vec(), + modified, + }) +} + +/// Why a coherent index snapshot cannot prove a full tree. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum TreeRefusal { + /// An unmerged, assume-valid, skip-worktree or intent-to-add entry. + Exception(Vec, EntryException), + /// A mode other than regular, executable, symlink or gitlink. + Mode(Vec, u32), + /// An empty, `.`, `..` or `.git` component. + Path(Vec), + /// The same path twice, or a path that is both a file and a directory. + Conflict(Vec), +} + +enum TreeNode<'a> { + Leaf { mode: u32, oid: &'a [u8] }, + Directory(std::collections::BTreeMap<&'a [u8], TreeNode<'a>>), +} + +/// Computes the root tree object ID that `git write-tree` would write for +/// this index, from every entry. Any entry that a plain tree cannot +/// represent exactly refuses the whole computation rather than being skipped, +/// so an equal result proves the full index, not a subset of it. +pub fn index_tree(snapshot: &IndexSnapshot) -> Result, TreeRefusal> { + let mut root = std::collections::BTreeMap::new(); + for entry in &snapshot.entries { + if let Some(exception) = entry.exceptions().into_iter().next() { + return Err(TreeRefusal::Exception(entry.path.clone(), exception)); + } + if !matches!(entry.stat.mode, 0o100644 | 0o100755 | 0o120000 | 0o160000) { + return Err(TreeRefusal::Mode(entry.path.clone(), entry.stat.mode)); + } + let components: Vec<&[u8]> = entry.path.split(|&b| b == b'/').collect(); + if components.iter().any(|component| { + component.is_empty() + || *component == b"." + || *component == b".." + || component.eq_ignore_ascii_case(b".git") + }) { + return Err(TreeRefusal::Path(entry.path.clone())); + } + let conflict = || TreeRefusal::Conflict(entry.path.clone()); + let (leaf, parents) = components.split_last().expect("split yields one item"); + let mut directory = &mut root; + for parent in parents { + let node = directory + .entry(*parent) + .or_insert_with(|| TreeNode::Directory(Default::default())); + directory = match node { + TreeNode::Directory(children) => children, + TreeNode::Leaf { .. } => return Err(conflict()), + }; + } + if directory + .insert( + leaf, + TreeNode::Leaf { + mode: entry.stat.mode, + oid: &entry.oid, + }, + ) + .is_some() + { + return Err(conflict()); + } + } + Ok(write_tree(snapshot.object_format, &root)) +} + +fn write_tree( + format: ObjectFormat, + children: &std::collections::BTreeMap<&[u8], TreeNode<'_>>, +) -> Vec { + // Git orders tree entries by name, comparing a directory as if its name + // ended in '/'. + let mut rows: Vec<(Vec, String, &[u8], Vec)> = children + .iter() + .map(|(name, node)| match node { + TreeNode::Leaf { mode, oid } => { + (name.to_vec(), format!("{mode:o}"), *name, oid.to_vec()) + } + TreeNode::Directory(grandchildren) => { + let mut key = name.to_vec(); + key.push(b'/'); + ( + key, + "40000".to_owned(), + *name, + write_tree(format, grandchildren), + ) + } + }) + .collect(); + rows.sort_by(|a, b| a.0.cmp(&b.0)); + let mut body = Vec::new(); + for (_, mode, name, oid) in rows { + body.extend_from_slice(mode.as_bytes()); + body.push(b' '); + body.extend_from_slice(name); + body.push(0); + body.extend_from_slice(&oid); + } + format.object_oid(b"tree", &body) +} + +/// Git metadata queries for one snapshot evaluation. Every spawned Git child, including a +/// failed or timed-out command, increments the counter. Output remains binary +/// because the NUL-delimited protocols must preserve arbitrary Git path bytes. +/// Callers must treat unsuccessful exit status as strict, not as empty evidence. +pub struct GitQueries { + root: PathBuf, + timeout: Duration, + spawned: AtomicU64, +} + +impl GitQueries { + pub fn new(root: PathBuf, timeout: Duration) -> Self { + Self { + root, + timeout, + spawned: AtomicU64::new(0), + } + } + + pub fn spawned(&self) -> u64 { + self.spawned.load(Ordering::Relaxed) + } + + pub fn head_tree(&self) -> io::Result { + self.run(&["rev-parse", "HEAD^{tree}"], Vec::new()) + } + + pub fn diff_trees(&self, old: &str, new: &str) -> io::Result { + // Only object IDs, never revision expressions or command-line options. + let valid = + |oid: &str| matches!(oid.len(), 40 | 64) && oid.bytes().all(|b| b.is_ascii_hexdigit()); + if !valid(old) || !valid(new) || old.len() != new.len() { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "invalid tree OID", + )); + } + self.run( + &["diff-tree", "-r", "-z", "--no-renames", old, new, "--"], + Vec::new(), + ) + } + + pub fn check_attributes(&self, paths: &[&[u8]]) -> io::Result { + let mut input = Vec::new(); + for path in paths { + if path.contains(&0) { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "NUL in Git path", + )); + } + input.extend_from_slice(path); + input.push(0); + } + self.run( + &[ + "check-attr", + "--stdin", + "-z", + "filter", + "text", + "eol", + "working-tree-encoding", + "ident", + ], + input, + ) + } + + /// Return config values with their origins for the caller to interpret and + /// retain. Callers should issue this query only once per snapshot evaluation. + pub fn config(&self) -> io::Result { + self.run(&["config", "--list", "--show-origin", "-z"], Vec::new()) + } + /// Ask Git for its compiled/default attribute path rather than guessing an + /// installation prefix. Older Git versions may refuse this variable. + pub fn attribute_source(&self, global: bool) -> io::Result { + self.run( + &[ + "var", + if global { + "GIT_ATTR_GLOBAL" + } else { + "GIT_ATTR_SYSTEM" + }, + ], + Vec::new(), + ) + } + + fn run(&self, args: &[&str], input: Vec) -> io::Result { + let mut command = Command::new("git"); + command + .current_dir(&self.root) + .args(args) + .env("GIT_OPTIONAL_LOCKS", "0") + .env("LC_ALL", "C"); + #[cfg(test)] + crate::test_env::apply_hermetic_git_env(&mut command); + pump(&mut command, input, self.timeout, &self.spawned) + } +} + +enum PumpResult { + Input(io::Result<()>), + Stdout(io::Result>), + Stderr(io::Result>), +} + +fn pump( + command: &mut Command, + input: Vec, + timeout: Duration, + spawned: &AtomicU64, +) -> io::Result { + crate::format::isolate_in_process_group(command); + let deadline = Instant::now() + timeout; + let mut child = command + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn()?; + spawned.fetch_add(1, Ordering::Relaxed); + crate::views::io::WORK + .git_subprocesses + .fetch_add(1, Ordering::Relaxed); + let mut stdin = child.stdin.take().expect("piped stdin"); + let mut stdout = child.stdout.take().expect("piped stdout"); + let mut stderr = child.stderr.take().expect("piped stderr"); + let (tx, rx) = mpsc::channel(); + let input_tx = tx.clone(); + thread::spawn(move || { + let result = stdin.write_all(&input); + drop(stdin); + let _ = input_tx.send(PumpResult::Input(result)); + }); + let stdout_tx = tx.clone(); + thread::spawn(move || { + let mut bytes = Vec::new(); + let result = stdout.read_to_end(&mut bytes).map(|_| bytes); + let _ = stdout_tx.send(PumpResult::Stdout(result)); + }); + thread::spawn(move || { + let mut bytes = Vec::new(); + let result = stderr.read_to_end(&mut bytes).map(|_| bytes); + let _ = tx.send(PumpResult::Stderr(result)); + }); + let mut status = None; + let mut input_done = false; + let mut output = None; + let mut error_output = None; + let result = (|| loop { + if Instant::now() >= deadline { + return Err(io::Error::new( + io::ErrorKind::TimedOut, + "Git metadata deadline exceeded", + )); + } + if status.is_none() { + status = child.try_wait()?; + } + if status.is_some() && input_done && output.is_some() && error_output.is_some() { + return Ok(Output { + status: status.unwrap(), + stdout: output.take().unwrap(), + stderr: error_output.take().unwrap(), + }); + } + // Completion includes EOF on both pipes and the stdin writer, not just + // process exit. A descendant retaining a pipe must not defeat the deadline. + match rx.recv_timeout( + deadline + .saturating_duration_since(Instant::now()) + .min(Duration::from_millis(5)), + ) { + Ok(PumpResult::Input(result)) => { + result?; + input_done = true; + } + Ok(PumpResult::Stdout(result)) => output = Some(result?), + Ok(PumpResult::Stderr(result)) => error_output = Some(result?), + Err(mpsc::RecvTimeoutError::Timeout) => {} + Err(mpsc::RecvTimeoutError::Disconnected) => { + if !input_done || output.is_none() || error_output.is_none() { + return Err(io::Error::other("Git pipe pump disconnected")); + } + thread::sleep(Duration::from_millis(1)); + } + } + })(); + if result.is_err() { + crate::format::kill_process_tree(&mut child); + let _ = child.wait(); + } + result +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + fn git(root: &Path, args: &[&str], input: &[u8]) -> Output { + let mut command = Command::new("git"); + command + .current_dir(root) + .args(args) + .env("GIT_OPTIONAL_LOCKS", "0"); + crate::test_env::apply_hermetic_git_env(&mut command); + let output = pump( + &mut command, + input.to_vec(), + Duration::from_secs(15), + &AtomicU64::new(0), + ) + .unwrap(); + assert!( + output.status.success(), + "git {args:?}: {}", + String::from_utf8_lossy(&output.stderr) + ); + output + } + + fn fixture(format: ObjectFormat) -> TempDir { + let dir = tempfile::tempdir().unwrap(); + git( + dir.path(), + &[ + "init", + if format == ObjectFormat::Sha1 { + "--object-format=sha1" + } else { + "--object-format=sha256" + }, + ], + &[], + ); + git( + dir.path(), + &["config", "user.email", "index-reader@example.invalid"], + &[], + ); + git(dir.path(), &["config", "user.name", "Index Reader"], &[]); + git(dir.path(), &["config", "core.autocrlf", "false"], &[]); + git(dir.path(), &["config", "core.splitIndex", "false"], &[]); + for path in [ + "plain", + "skip", + "dir/sub/alpha", + "dir/sub/alphabet", + "with space", + ] { + let path = dir.path().join(path); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(path, b"hello\n").unwrap(); + } + // A long first path followed by a short unrelated one exercises the + // multi-byte remove-count encoding in Git's real v4 prefix compressor. + fs::write(dir.path().join("a".repeat(140)), b"long\n").unwrap(); + git(dir.path(), &["add", "."], &[]); + git( + dir.path(), + &["-c", "commit.gpgsign=false", "commit", "-m", "fixture"], + &[], + ); + dir + } + + fn index(root: &Path) -> PathBuf { + root.join(".git/index") + } + + fn read(root: &Path, format: ObjectFormat) -> IndexRead { + read_index(&index(root), format, ReadOptions::default()) + } + + fn snapshot(root: &Path, format: ObjectFormat) -> IndexSnapshot { + match read(root, format) { + IndexRead::Snapshot(snapshot) => snapshot, + other => panic!("expected snapshot: {other:?}"), + } + } + + fn hex(bytes: &[u8]) -> String { + bytes.iter().map(|b| format!("{b:02x}")).collect() + } + + fn assert_matches_git(root: &Path, snapshot: &IndexSnapshot) { + let mut actual = Vec::new(); + for entry in &snapshot.entries { + actual.extend_from_slice( + format!( + "{:06o} {} {}\t", + entry.stat.mode, + hex(&entry.oid), + entry.stage + ) + .as_bytes(), + ); + actual.extend_from_slice(&entry.path); + actual.push(0); + } + assert_eq!( + actual, + git(root, &["ls-files", "--stage", "-z"], &[]).stdout + ); + let plain = snapshot + .entries + .iter() + .find(|e| e.path == b"plain") + .unwrap(); + assert_eq!(plain.stat.size, 6); + assert_eq!(plain.stat.mode, 0o100644); + assert_eq!(snapshot.entries.len(), 6); + } + + #[test] + fn real_git_v2_golden() { + let dir = fixture(ObjectFormat::Sha1); + git( + dir.path(), + &["update-index", "--assume-unchanged", "plain"], + &[], + ); + git(dir.path(), &["update-index", "--index-version", "2"], &[]); + let snapshot = snapshot(dir.path(), ObjectFormat::Sha1); + assert_eq!(snapshot.version, 2); + assert_matches_git(dir.path(), &snapshot); + assert_eq!( + snapshot + .entries + .iter() + .find(|e| e.path == b"plain") + .unwrap() + .exceptions(), + vec![EntryException::AssumeValid] + ); + } + + #[test] + fn real_git_v3_golden_and_extended_flags() { + let dir = fixture(ObjectFormat::Sha1); + git( + dir.path(), + &["update-index", "--skip-worktree", "skip"], + &[], + ); + fs::write(dir.path().join("intent"), b"not staged\n").unwrap(); + git(dir.path(), &["add", "--intent-to-add", "intent"], &[]); + git(dir.path(), &["update-index", "--index-version", "3"], &[]); + let snapshot = snapshot(dir.path(), ObjectFormat::Sha1); + assert_eq!(snapshot.version, 3); + assert_eq!( + snapshot + .entries + .iter() + .find(|e| e.path == b"skip") + .unwrap() + .exceptions(), + vec![EntryException::SkipWorktree] + ); + assert_eq!( + snapshot + .entries + .iter() + .find(|e| e.path == b"intent") + .unwrap() + .exceptions(), + vec![EntryException::IntentToAdd] + ); + assert_eq!(snapshot.entries.len(), 7); + } + + #[test] + fn real_git_v4_golden_prefix_compression() { + let dir = fixture(ObjectFormat::Sha1); + git(dir.path(), &["update-index", "--index-version", "4"], &[]); + let snapshot = snapshot(dir.path(), ObjectFormat::Sha1); + assert_eq!(snapshot.version, 4); + assert_matches_git(dir.path(), &snapshot); + assert_eq!(snapshot.entries[0].path, vec![b'a'; 140]); + assert_eq!(snapshot.entries[1].path, b"dir/sub/alpha"); + assert_eq!(snapshot.entries[2].path, b"dir/sub/alphabet"); + } + + #[test] + fn real_git_sha256_golden_all_versions() { + let dir = fixture(ObjectFormat::Sha256); + for version in ["2", "3", "4"] { + if version == "3" { + git( + dir.path(), + &["update-index", "--skip-worktree", "skip"], + &[], + ); + } + git( + dir.path(), + &["update-index", "--index-version", version], + &[], + ); + let snapshot = snapshot(dir.path(), ObjectFormat::Sha256); + assert_eq!(snapshot.version.to_string(), version); + assert_eq!(snapshot.checksum.len(), 32); + assert!(snapshot.entries.iter().all(|e| e.oid.len() == 32)); + assert_matches_git(dir.path(), &snapshot); + } + assert_eq!( + read(dir.path(), ObjectFormat::Sha1), + IndexRead::Strict(StrictReason::ChecksumMismatch) + ); + } + + #[test] + fn real_git_unmerged_stages_are_exceptions() { + let dir = fixture(ObjectFormat::Sha1); + let oid = git(dir.path(), &["rev-parse", "HEAD:plain"], &[]).stdout; + let oid = String::from_utf8(oid).unwrap(); + let records: String = (1..=3) + .map(|stage| format!("100644 {} {stage}\tconflict\n", oid.trim())) + .collect(); + git( + dir.path(), + &["update-index", "--index-info"], + records.as_bytes(), + ); + let snapshot = snapshot(dir.path(), ObjectFormat::Sha1); + let stages: Vec<_> = snapshot + .entries + .iter() + .filter(|e| e.path == b"conflict") + .map(|e| e.exceptions()) + .collect(); + assert_eq!( + stages, + vec![ + vec![EntryException::Unmerged(1)], + vec![EntryException::Unmerged(2)], + vec![EntryException::Unmerged(3)] + ] + ); + } + + #[test] + fn real_git_split_index_is_strict() { + let dir = fixture(ObjectFormat::Sha1); + git(dir.path(), &["update-index", "--split-index"], &[]); + assert_eq!( + read(dir.path(), ObjectFormat::Sha1), + IndexRead::Strict(StrictReason::RequiredExtension(*b"link")) + ); + } + + #[test] + fn real_git_sparse_index_is_strict() { + let dir = fixture(ObjectFormat::Sha1); + git( + dir.path(), + &["sparse-checkout", "init", "--cone", "--sparse-index"], + &[], + ); + git( + dir.path(), + &["sparse-checkout", "set", "--sparse-index", "other"], + &[], + ); + assert_eq!( + read(dir.path(), ObjectFormat::Sha1), + IndexRead::Strict(StrictReason::RequiredExtension(*b"sdir")) + ); + } + + #[test] + fn held_index_lock_waits_then_strict_without_touching_it() { + let dir = fixture(ObjectFormat::Sha1); + let lock = dir.path().join(".git/index.lock"); + fs::write(&lock, b"writer owns this").unwrap(); + let options = ReadOptions { + lock_wait: Duration::from_millis(20), + ..ReadOptions::default() + }; + let start = Instant::now(); + assert_eq!( + read_index(&index(dir.path()), ObjectFormat::Sha1, options), + IndexRead::Strict(StrictReason::IndexLocked) + ); + assert!(start.elapsed() >= options.lock_wait); + assert_eq!(fs::read(lock).unwrap(), b"writer owns this"); + } + + #[test] + fn released_index_lock_allows_snapshot() { + let dir = fixture(ObjectFormat::Sha1); + let lock = dir.path().join(".git/index.lock"); + fs::write(&lock, b"writer").unwrap(); + let writer = thread::spawn(move || { + thread::sleep(Duration::from_millis(20)); + fs::remove_file(lock).unwrap(); + }); + let result = read_index( + &index(dir.path()), + ObjectFormat::Sha1, + ReadOptions { + lock_wait: Duration::from_secs(5), + ..ReadOptions::default() + }, + ); + writer.join().unwrap(); + assert!(matches!(result, IndexRead::Snapshot(_))); + } + + #[test] + fn real_index_torn_read_retries_then_recovers() { + let dir = fixture(ObjectFormat::Sha1); + let path = index(dir.path()); + let original = fs::read(&path).unwrap(); + let mut attempts = 0; + let result = + read_index_with_hook(&path, ObjectFormat::Sha1, ReadOptions::default(), || { + attempts += 1; + match attempts { + 1 => fs::write(&path, &original[..original.len() / 2]).unwrap(), + 2 => fs::write(&path, &original).unwrap(), + _ => {} + } + }); + assert_eq!(attempts, 3); + assert!(matches!(result, IndexRead::Snapshot(_))); + } + + #[test] + fn real_index_persistently_torn_read_is_strict() { + let dir = fixture(ObjectFormat::Sha1); + let path = index(dir.path()); + let original = fs::read(&path).unwrap(); + let mut attempts = 0; + let result = + read_index_with_hook(&path, ObjectFormat::Sha1, ReadOptions::default(), || { + attempts += 1; + fs::write( + &path, + if attempts % 2 == 0 { + &original[..] + } else { + &original[..original.len() / 2] + }, + ) + .unwrap(); + }); + assert_eq!(attempts, 3); + assert_eq!(result, IndexRead::Strict(StrictReason::Unstable)); + } + + #[test] + fn checksum_mismatch_retries_then_strict() { + let dir = fixture(ObjectFormat::Sha1); + let path = index(dir.path()); + let mut bytes = fs::read(&path).unwrap(); + bytes[20] ^= 1; + fs::write(&path, bytes).unwrap(); + let mut attempts = 0; + let result = + read_index_with_hook(&path, ObjectFormat::Sha1, ReadOptions::default(), || { + attempts += 1 + }); + assert_eq!(attempts, 3); + assert_eq!(result, IndexRead::Strict(StrictReason::ChecksumMismatch)); + } + + #[test] + fn replacing_index_with_same_size_and_mtime_is_not_coherent() { + let dir = fixture(ObjectFormat::Sha1); + let path = index(dir.path()); + let original = fs::read(&path).unwrap(); + let mtime = filetime::FileTime::from_last_modification_time(&fs::metadata(&path).unwrap()); + let result = read_index_with_hook( + &path, + ObjectFormat::Sha1, + ReadOptions { + attempts: 1, + ..ReadOptions::default() + }, + || { + let replacement = path.with_extension("replacement"); + fs::write(&replacement, &original).unwrap(); + filetime::set_file_mtime(&replacement, mtime).unwrap(); + // Windows cannot rename over an existing file. Moving the old name + // first keeps the original open handle alive on all three platforms. + fs::rename(&path, path.with_extension("old")).unwrap(); + fs::rename(replacement, &path).unwrap(); + }, + ); + assert_eq!(result, IndexRead::Strict(StrictReason::Unstable)); + } + + #[test] + fn same_size_in_place_mtime_change_is_not_coherent() { + let dir = fixture(ObjectFormat::Sha1); + let path = index(dir.path()); + let result = read_index_with_hook( + &path, + ObjectFormat::Sha1, + ReadOptions { + attempts: 1, + ..ReadOptions::default() + }, + || { + filetime::set_file_mtime(&path, filetime::FileTime::from_unix_time(1, 123)) + .unwrap(); + }, + ); + assert_eq!(result, IndexRead::Strict(StrictReason::Unstable)); + } + + fn append_extension(bytes: &[u8], signature: &[u8; 4], payload: &[u8]) -> Vec { + let mut body = bytes[..bytes.len() - 20].to_vec(); + body.extend_from_slice(signature); + body.extend_from_slice(&(payload.len() as u32).to_be_bytes()); + body.extend_from_slice(payload); + let digest = ObjectFormat::Sha1.digest(&body); + body.extend_from_slice(&digest); + body + } + + #[test] + fn optional_extensions_are_ignored_required_extensions_are_strict() { + let dir = fixture(ObjectFormat::Sha1); + let path = index(dir.path()); + let original = fs::read(&path).unwrap(); + let expected = snapshot(dir.path(), ObjectFormat::Sha1).entries; + for signature in [b"ABCD", b"Z123"] { + fs::write( + &path, + append_extension(&original, signature, b"optional payload"), + ) + .unwrap(); + assert_eq!(snapshot(dir.path(), ObjectFormat::Sha1).entries, expected); + } + for signature in [b"abcd", b"0123", b"link", b"sdir"] { + fs::write( + &path, + append_extension(&original, signature, b"required payload"), + ) + .unwrap(); + assert_eq!( + read(dir.path(), ObjectFormat::Sha1), + IndexRead::Strict(StrictReason::RequiredExtension(*signature)) + ); + } + } + + fn seal(mut body: Vec) -> Vec { + body.extend_from_slice(&ObjectFormat::Sha1.digest(&body)); + body + } + + #[test] + fn malformed_entries_extensions_and_versions_fail_closed() { + let dir = fixture(ObjectFormat::Sha1); + git(dir.path(), &["update-index", "--index-version", "4"], &[]); + let path = index(dir.path()); + let original = fs::read(&path).unwrap(); + let body = &original[..original.len() - 20]; + let mut invalid_prefix = body.to_vec(); + invalid_prefix[12 + 40 + 20 + 2] = 1; + let mut invalid_version = body.to_vec(); + invalid_version[4..8].copy_from_slice(&5u32.to_be_bytes()); + let mut invalid_count = body.to_vec(); + invalid_count[8..12].copy_from_slice(&u32::MAX.to_be_bytes()); + let mut invalid_nanoseconds = body.to_vec(); + invalid_nanoseconds[16..20].copy_from_slice(&1_000_000_000u32.to_be_bytes()); + let mut truncated_extension = body.to_vec(); + truncated_extension.extend_from_slice(b"ABCD\0\0\0\x10short"); + for malformed in [ + invalid_prefix, + invalid_version, + invalid_count, + invalid_nanoseconds, + truncated_extension, + body[..16].to_vec(), + ] { + fs::write(&path, seal(malformed)).unwrap(); + assert!(matches!( + read(dir.path(), ObjectFormat::Sha1), + IndexRead::Strict(_) + )); + } + } + + #[test] + fn stat_comparison_uses_low_32_bits_and_windows_unrecorded_identity() { + let stat = IndexStat { + ctime: IndexTime { + seconds: u32::MAX, + nanoseconds: 321, + }, + mtime: IndexTime { + seconds: 7, + nanoseconds: 654, + }, + dev: 3, + ino: 4, + mode: 0o100644, + uid: 10, + gid: 20, + size: 5, + }; + let mut observed = WorktreeStat { + ctime_seconds: -1, + ctime_nanoseconds: 321, + mtime_seconds: (1i64 << 32) + 7, + mtime_nanoseconds: 654, + dev: (1u64 << 32) + 3, + ino: (1u64 << 32) + 4, + mode: 0o100644, + size: (1u64 << 32) + 5, + }; + assert!(stat.matches_stat(&observed)); + observed.size += 1; + assert!(!stat.matches_stat(&observed)); + observed.size -= 1; + observed.dev = 0; + observed.ino = 0; + assert_eq!(stat.matches_stat(&observed), cfg!(windows)); + observed.mtime_nanoseconds += 1; + assert!(!stat.matches_stat(&observed)); + } + + #[test] + fn real_git_preserves_non_utf8_paths() { + let dir = fixture(ObjectFormat::Sha1); + let name = b"non-utf8-\xff"; + // Stage the entry through git's own index input instead of creating + // the file: macOS (APFS) and Windows refuse non-UTF-8 file names, but + // an index can still carry one from a commit made elsewhere. + let oid = git(dir.path(), &["hash-object", "-w", "--stdin"], b"raw\n").stdout; + let mut line = b"100644 ".to_vec(); + line.extend_from_slice(String::from_utf8(oid).unwrap().trim().as_bytes()); + line.push(b'\t'); + line.extend_from_slice(name); + line.push(b'\n'); + git( + dir.path(), + &["update-index", "--add", "--index-info"], + &line, + ); + git(dir.path(), &["update-index", "--index-version", "4"], &[]); + assert!(snapshot(dir.path(), ObjectFormat::Sha1) + .entries + .iter() + .any(|e| e.path == name)); + } + + fn trimmed_hex(output: Output) -> String { + String::from_utf8(output.stdout).unwrap().trim().to_owned() + } + + #[test] + fn index_tree_and_blob_oids_match_git_for_both_object_formats() { + for format in [ObjectFormat::Sha1, ObjectFormat::Sha256] { + let dir = fixture(format); + // Names whose Git tree order differs from plain byte order once + // the directory `a` is compared as `a/`. + for path in ["order/a-b", "order/a/x", "order/a.b", "order/a0"] { + let path = dir.path().join(path); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + let contents = path_bytes(&path); + fs::write(path, contents).unwrap(); + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let tool = dir.path().join("tool.sh"); + fs::write(&tool, b"#!/bin/sh\n").unwrap(); + fs::set_permissions(&tool, fs::Permissions::from_mode(0o755)).unwrap(); + std::os::unix::fs::symlink("plain", dir.path().join("link")).unwrap(); + } + git(dir.path(), &["add", "."], &[]); + let commit = trimmed_hex(git(dir.path(), &["rev-parse", "HEAD"], &[])); + git( + dir.path(), + &[ + "update-index", + "--add", + "--cacheinfo", + &format!("160000,{commit},module"), + ], + &[], + ); + let snapshot = snapshot(dir.path(), format); + assert_eq!( + hex(&index_tree(&snapshot).unwrap()), + trimmed_hex(git(dir.path(), &["write-tree"], &[])), + "{format:?}" + ); + let bytes = b"blob bytes\0with a NUL\n"; + assert_eq!( + hex(&format.blob_oid(bytes)), + trimmed_hex(git(dir.path(), &["hash-object", "--stdin"], bytes)) + ); + assert_eq!( + ObjectFormat::from_hex_len(hex(&format.blob_oid(b"")).len()), + Some(format) + ); + } + } + + fn path_bytes(path: &Path) -> Vec { + path.file_name() + .unwrap() + .to_string_lossy() + .as_bytes() + .to_vec() + } + + #[test] + fn index_tree_refuses_real_git_exception_entries() { + let dir = fixture(ObjectFormat::Sha1); + let tree = |dir: &TempDir| index_tree(&snapshot(dir.path(), ObjectFormat::Sha1)); + assert!(tree(&dir).is_ok()); + fs::write(dir.path().join("new"), b"new\n").unwrap(); + git(dir.path(), &["add", "-N", "new"], &[]); + assert_eq!( + tree(&dir), + Err(TreeRefusal::Exception( + b"new".to_vec(), + EntryException::IntentToAdd + )) + ); + git(dir.path(), &["update-index", "--force-remove", "new"], &[]); + git( + dir.path(), + &["update-index", "--skip-worktree", "skip"], + &[], + ); + assert_eq!( + tree(&dir), + Err(TreeRefusal::Exception( + b"skip".to_vec(), + EntryException::SkipWorktree + )) + ); + git( + dir.path(), + &["update-index", "--no-skip-worktree", "skip"], + &[], + ); + git( + dir.path(), + &["update-index", "--assume-unchanged", "plain"], + &[], + ); + assert_eq!( + tree(&dir), + Err(TreeRefusal::Exception( + b"plain".to_vec(), + EntryException::AssumeValid + )) + ); + } + + #[test] + fn index_tree_refuses_modes_paths_and_conflicts_a_tree_cannot_hold() { + let entry = |path: &[u8], mode: u32| IndexEntry { + path: path.to_vec(), + oid: vec![7; 20], + stat: IndexStat { + ctime: IndexTime { + seconds: 0, + nanoseconds: 0, + }, + mtime: IndexTime { + seconds: 0, + nanoseconds: 0, + }, + dev: 0, + ino: 0, + mode, + uid: 0, + gid: 0, + size: 0, + }, + stage: 0, + assume_valid: false, + skip_worktree: false, + intent_to_add: false, + }; + let tree = |entries: Vec| { + index_tree(&IndexSnapshot { + version: 2, + object_format: ObjectFormat::Sha1, + entries, + checksum: Vec::new(), + modified: SystemTime::UNIX_EPOCH, + }) + }; + assert!(tree(vec![entry(b"a", 0o100644), entry(b"b/c", 0o120000)]).is_ok()); + assert_eq!( + tree(vec![entry(b"a", 0o100664)]), + Err(TreeRefusal::Mode(b"a".to_vec(), 0o100664)) + ); + assert_eq!( + tree(vec![entry(b"x/.GIT/y", 0o100644)]), + Err(TreeRefusal::Path(b"x/.GIT/y".to_vec())) + ); + assert_eq!( + tree(vec![entry(b"a", 0o100644), entry(b"a/b", 0o100644)]), + Err(TreeRefusal::Conflict(b"a/b".to_vec())) + ); + assert_eq!( + tree(vec![entry(b"a/b", 0o100644), entry(b"a", 0o100644)]), + Err(TreeRefusal::Conflict(b"a".to_vec())) + ); + assert_eq!( + tree(vec![entry(b"a", 0o100644), entry(b"a", 0o100755)]), + Err(TreeRefusal::Conflict(b"a".to_vec())) + ); + } + + #[test] + fn counted_read_only_git_queries_and_large_bidirectional_pump() { + let dir = fixture(ObjectFormat::Sha1); + let path = index(dir.path()); + let original = fs::read(&path).unwrap(); + fs::write( + dir.path().join(".gitattributes"), + b"plain text eol=lf filter=demo\n", + ) + .unwrap(); + let queries = GitQueries::new(dir.path().to_path_buf(), Duration::from_secs(15)); + let first = queries.head_tree().unwrap(); + assert!(first.status.success()); + let first = String::from_utf8(first.stdout).unwrap(); + git(dir.path(), &["rm", "with space"], &[]); + git( + dir.path(), + &["-c", "commit.gpgsign=false", "commit", "-m", "second"], + &[], + ); + let second = queries.head_tree().unwrap(); + let second = String::from_utf8(second.stdout).unwrap(); + let after_commit = fs::read(&path).unwrap(); + let diff = queries.diff_trees(first.trim(), second.trim()).unwrap(); + assert!(diff.status.success()); + assert!(diff.stdout.ends_with(b"\0with space\0")); + assert!(queries.diff_trees("--bad", second.trim()).is_err()); + // Both the request and reply exceed pipe capacity, so serial write/read + // would deadlock. Repeating paths is valid check-attr input on every OS. + let paths = vec![b"plain".as_slice(); 30_000]; + let attrs = queries.check_attributes(&paths).unwrap(); + assert!( + attrs.status.success(), + "{}", + String::from_utf8_lossy(&attrs.stderr) + ); + assert_eq!(attrs.stdout.len(), b"plain\0filter\0demo\0plain\0text\0set\0plain\0eol\0lf\0plain\0working-tree-encoding\0unspecified\0plain\0ident\0unspecified\0".len() * paths.len()); + assert!(attrs + .stdout + .starts_with(b"plain\0filter\0demo\0plain\0text\0set\0")); + let config = queries.config().unwrap(); + assert!(config.status.success()); + assert!(config + .stdout + .windows(b"core.autocrlf\nfalse\0".len()) + .any(|w| w == b"core.autocrlf\nfalse\0")); + assert_eq!(queries.spawned(), 5); + assert_eq!(fs::read(&path).unwrap(), after_commit); + assert!(!dir.path().join(".git/index.lock").exists()); + assert_ne!(original, after_commit); + } + + #[test] + fn git_pump_deadline_and_spawn_accounting() { + // Re-exec this libtest binary with a private ignored sleeper, avoiding + // shell syntax and utilities unavailable on native Windows runners. + let mut command = Command::new(std::env::current_exe().unwrap()); + command.args([ + "--ignored", + "--exact", + "git_index_reader::tests::pump_sleep_child", + "--nocapture", + ]); + let spawned = AtomicU64::new(0); + let start = Instant::now(); + let error = pump( + &mut command, + vec![b'x'; 1024 * 1024], + Duration::from_millis(100), + &spawned, + ) + .unwrap_err(); + assert_eq!(error.kind(), io::ErrorKind::TimedOut); + assert_eq!(spawned.load(Ordering::Relaxed), 1); + assert!(start.elapsed() < Duration::from_secs(10)); + let mut missing = Command::new("aft-nonexistent-index-reader-test-command"); + assert!(pump(&mut missing, Vec::new(), Duration::from_secs(1), &spawned).is_err()); + assert_eq!(spawned.load(Ordering::Relaxed), 1); + } + + #[test] + #[ignore = "subprocess helper; invoked explicitly by git_pump_deadline_and_spawn_accounting"] + fn pump_sleep_child() { + thread::sleep(Duration::from_secs(60)); + } +} +#[test] +fn attribute_source_queries_count_both_spawns_even_on_unsupported_git() { + let root = tempfile::tempdir().unwrap(); + let queries = GitQueries::new(root.path().to_owned(), Duration::from_secs(2)); + let before = crate::views::io::WORK + .git_subprocesses + .load(Ordering::Relaxed); + queries.attribute_source(false).unwrap(); + queries.attribute_source(true).unwrap(); + assert_eq!(queries.spawned(), 2); + assert!( + crate::views::io::WORK + .git_subprocesses + .load(Ordering::Relaxed) + >= before + 2 + ); +} diff --git a/crates/aft/src/grep_executor.rs b/crates/aft/src/grep_executor.rs index d366bc57a9..cd91f71ff1 100644 --- a/crates/aft/src/grep_executor.rs +++ b/crates/aft/src/grep_executor.rs @@ -845,7 +845,20 @@ fn execute_root_profiled( let snapshot_started = Instant::now(); let ignored_target = root.ignored_target; let mut snapshot_timed_out = false; - let indexed_snapshot = + let view_snapshot = (root.use_index && !ignored_target) + .then(|| ctx.lexical_view_snapshot("grep")) + .flatten(); + let indexed_snapshot = if let Some(snapshot) = view_snapshot { + match snapshot { + Ok(snapshot) => Some(snapshot), + Err(error) => { + return ( + failed_index_grep(std::io::Error::other(error)), + GrepExecutionPhaseTimings::default(), + ) + } + } + } else { match try_read_with_budget(ctx.search_index(), INTERACTIVE_ARTIFACT_READ_BUDGET) { Some(search_index) => match search_index.as_ref() { Some(index) if index.ready && root.use_index && !ignored_target => { @@ -857,7 +870,8 @@ fn execute_root_profiled( snapshot_timed_out = true; None } - }; + } + }; let snapshot_acquire = snapshot_started.elapsed(); let mut index_lacks_scope = ignored_target; if let Some(snapshot) = indexed_snapshot { diff --git a/crates/aft/src/inspect/manager.rs b/crates/aft/src/inspect/manager.rs index c6dcc0a606..cfe9b5ca57 100644 --- a/crates/aft/src/inspect/manager.rs +++ b/crates/aft/src/inspect/manager.rs @@ -5781,16 +5781,6 @@ fn current_view_projection_result( result } -/// The reader Tier-2 dead-code projection uses for `project_root`'s published -/// view, for tests that compare it with an independent build. -#[cfg(test)] -pub(crate) fn current_view_projection_store_for_test( - project_root: &Path, - config: &crate::config::Config, -) -> Option<(ReadonlyCallGraphStore, String)> { - current_view_projection_store(project_root, Path::new(""), config, &[]) -} - fn build_view_callgraph_snapshot( job: &InspectJob, projection_cache: Option<&InspectManager>, diff --git a/crates/aft/src/inspect/mod.rs b/crates/aft/src/inspect/mod.rs index 567cd3ef45..f8d036b44f 100644 --- a/crates/aft/src/inspect/mod.rs +++ b/crates/aft/src/inspect/mod.rs @@ -29,9 +29,7 @@ pub use job::{ JobOutcome, JobScope, JobStatus, WorkerCtx, }; #[cfg(test)] -pub(crate) use manager::{ - current_view_projection_store_for_test, filter_payload_for_scope_for_test, InspectBuilderState, -}; +pub(crate) use manager::{filter_payload_for_scope_for_test, InspectBuilderState}; pub use manager::{InspectManager, Tier2RunSubmission, Tier2RunSubmissionError}; pub use phase_log::{ format_wait_text, inspect_phase_log_for_request, InspectPhaseEntry, InspectPhaseId, diff --git a/crates/aft/src/lib.rs b/crates/aft/src/lib.rs index 8fafc25211..0dd7cac717 100644 --- a/crates/aft/src/lib.rs +++ b/crates/aft/src/lib.rs @@ -114,6 +114,7 @@ pub mod gh_shim_ticket; pub(crate) mod gh_shim_worker_broker; #[cfg(unix)] pub(crate) mod gh_shim_worker_endpoints; +pub mod git_index_reader; pub(crate) mod git_probe; pub mod github_read; pub mod grep_executor; diff --git a/crates/aft/src/main.rs b/crates/aft/src/main.rs index d3857d0d42..e127411d73 100644 --- a/crates/aft/src/main.rs +++ b/crates/aft/src/main.rs @@ -1523,6 +1523,7 @@ fn dispatch_command(req: RawRequest, ctx: &AppContext) -> Response { "remove_import" => aft::commands::remove_import::handle_remove_import(&req, ctx), "organize_imports" => aft::commands::organize_imports::handle_organize_imports(&req, ctx), "configure" => aft::commands::configure::handle_configure(&req, ctx), + "view_token" => aft::commands::view_token::handle_view_token(&req, ctx), "glob" => aft::commands::glob::handle_glob(&req, ctx), "grep" => aft::commands::grep::handle_grep(&req, ctx), "semantic_search" => { @@ -3117,6 +3118,32 @@ mod deferred_semantic_search_tests { } } + #[test] + fn view_token_management_dispatch_reaches_the_default_off_guard() { + let storage = tempfile::tempdir().unwrap(); + let context = AppContext::new( + Box::new(TreeSitterProvider::new()), + Config { + storage_dir: Some(storage.path().to_owned()), + ..Default::default() + }, + ); + let response = super::dispatch_command( + request( + "token", + "view_token", + serde_json::json!({ + "root": storage.path().join("future"), "expected_tree": "a".repeat(40), + }), + ), + &context, + ); + let wire = serde_json::to_value(response).unwrap(); + assert_eq!(wire["success"], false); + assert_eq!(wire["code"], "VIEW_TOKEN"); + assert!(!storage.path().join("blobs").exists()); + } + fn slow_search_request(id: &str) -> RawRequest { request( id, diff --git a/crates/aft/src/migration/mod.rs b/crates/aft/src/migration/mod.rs index badf2ceeb7..0e40b00d60 100644 --- a/crates/aft/src/migration/mod.rs +++ b/crates/aft/src/migration/mod.rs @@ -15,6 +15,9 @@ pub mod per_checkout; pub mod prune_legacy; +pub mod v3_import; +pub mod v3_ledger; +pub mod v3_symbols; use std::collections::BTreeMap; use std::fmt; diff --git a/crates/aft/src/migration/per_checkout.rs b/crates/aft/src/migration/per_checkout.rs index c6cd767d7e..5ab898240b 100644 --- a/crates/aft/src/migration/per_checkout.rs +++ b/crates/aft/src/migration/per_checkout.rs @@ -171,7 +171,7 @@ impl ImportState { } } - fn parse(value: &str) -> Option { + pub(super) fn parse(value: &str) -> Option { [ Self::Claimed, Self::Staged, @@ -200,7 +200,7 @@ pub struct ClaimOwner { } impl ClaimOwner { - fn new() -> Self { + pub(super) fn new() -> Self { static SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); let me = this_process(); let seq = SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed); @@ -237,10 +237,10 @@ fn active_claims() -> &'static Mutex> { } /// Marks a claim token live for as long as the import that owns it runs. -struct ActiveClaim(String); +pub(super) struct ActiveClaim(String); impl ActiveClaim { - fn register(owner: &ClaimOwner) -> Self { + pub(super) fn register(owner: &ClaimOwner) -> Self { lock(active_claims()).insert(owner.token.clone()); Self(owner.token.clone()) } @@ -258,7 +258,7 @@ fn lock(mutex: &Mutex) -> MutexGuard<'_, T> { .unwrap_or_else(std::sync::PoisonError::into_inner) } -fn now_ms() -> u64 { +pub(super) fn now_ms() -> u64 { SystemTime::now() .duration_since(UNIX_EPOCH) .map(|elapsed| elapsed.as_millis().min(u128::from(u64::MAX)) as u64) @@ -781,18 +781,18 @@ fn status_of(rows: Vec) -> ImportStatus { /// One converted file: the manifest facts and the payload stored under `key`. #[derive(Clone, Debug, Eq, PartialEq)] -struct BundleEntry { - rel_path: RelPath, - content: ContentHash, - size: u64, - key: [u8; 32], - payload: Vec, +pub(super) struct BundleEntry { + pub(super) rel_path: RelPath, + pub(super) content: ContentHash, + pub(super) size: u64, + pub(super) key: [u8; 32], + pub(super) payload: Vec, } /// The staged, canonical conversion of one legacy artifact. #[derive(Clone, Debug, Default, Eq, PartialEq)] -struct Bundle { - entries: Vec, +pub(super) struct Bundle { + pub(super) entries: Vec, } impl Bundle { @@ -868,7 +868,7 @@ fn staged_path(ledger: &ImportLedger, artifact: Artifact, staged_hash: &[u8; 32] /// Writes `bytes` to `path` through temp + fsync + rename, then syncs the /// directory, so `staged` is only recorded for bytes that reached the disk. -fn write_durable(path: &Path, bytes: &[u8]) -> io::Result<()> { +pub(super) fn write_durable(path: &Path, bytes: &[u8]) -> io::Result<()> { let parent = path.parent().unwrap_or_else(|| Path::new(".")); crate::private_storage::create_dir_all(parent)?; let temporary = path.with_extension(format!("tmp.{}.{}", std::process::id(), now_ms())); @@ -950,7 +950,7 @@ impl<'a> LeReader<'a> { /// failed checksum, a path that is not relative, or a malformed table. /// Files without a recorded content hash are left out; the first load's /// strict reconciliation adds them from the checkout. -fn convert_trigram(bytes: &[u8], policy: &TrigramPolicy) -> Result { +pub(super) fn convert_trigram(bytes: &[u8], policy: &TrigramPolicy) -> Result { let mut reader = LeReader::new(bytes); if reader.u32()? != LEGACY_CACHE_MAGIC { return Err("not a trigram cache".to_owned()); @@ -1251,21 +1251,12 @@ fn capped_legacy_summary(current: &ChunkShape, legacy: &ChunkShape, budget: usiz /// A legacy small-cap summary may instead be the exact uncapped input: keep /// that run under its old key so the owner can replace only the summary. /// Every other file is left for the plane to embed. -fn convert_semantic( +pub(super) fn convert_semantic( bytes: &[u8], root: &Path, producer: &SemanticProducer, ) -> Result<(Bundle, usize), String> { - let summary_budget = serde_json::from_str::( - &producer.model_fingerprint, - ) - .ok() - .filter(|identity| { - identity.embed_text_caps == producer.caps - && identity.embed_text_caps.total_chars - < crate::semantic_index::EmbedTextCaps::default().total_chars - }) - .map(|identity| identity.embed_text_caps.total_chars); + let summary_budget = small_summary_budget(producer); let parsed = match super::parse_legacy_snapshot(bytes, &producer.model_fingerprint) { Ok(parsed) => parsed, Err(error) @@ -1284,108 +1275,121 @@ fn convert_semantic( let mut entries = Vec::new(); let mut skipped = 0usize; for file in parsed.files { - let Some(rel_path) = super::legacy_path_to_rel_path(&file.path, &root) else { - skipped += 1; - continue; - }; - let Ok(relative) = segment_store::rel_path_to_os(&rel_path) else { - skipped += 1; - continue; - }; - let absolute = root.join(&relative); - let fits = fs::symlink_metadata(&absolute) - .is_ok_and(|meta| meta.is_file() && meta.len() <= MAX_SEMANTIC_SOURCE_BYTES); - let Some(source) = fits.then(|| fs::read(&absolute).ok()).flatten() else { - skipped += 1; - continue; - }; - if file.content_hash == [0; 32] || blake3::hash(&source).as_bytes() != &file.content_hash { - skipped += 1; - continue; + match convert_semantic_file(&root, producer, summary_budget, file) { + Some(entry) => entries.push(entry), + None => skipped += 1, } - let chunks = match crate::semantic_index::chunk_view_file( - &root, - &relative, - &source, - producer.caps, - ) { + } + entries.sort_by(|left, right| left.rel_path.cmp(&right.rel_path)); + Ok((Bundle { entries }, skipped)) +} + +/// The small text cap a producer's fingerprint records, when it is below the +/// default: a legacy file summary embedded under that cap may then be the +/// exact uncapped input of the current one (see [`capped_legacy_summary`]). +pub(super) fn small_summary_budget(producer: &SemanticProducer) -> Option { + serde_json::from_str::( + &producer.model_fingerprint, + ) + .ok() + .filter(|identity| { + identity.embed_text_caps == producer.caps + && identity.embed_text_caps.total_chars + < crate::semantic_index::EmbedTextCaps::default().total_chars + }) + .map(|identity| identity.embed_text_caps.total_chars) +} + +/// Converts one legacy file's rows under `producer`, or `None` when the file +/// cannot be imported and is left for the plane to embed: its path is not +/// under `root`, its bytes changed since the snapshot, or this release chunks +/// them into other rows than the snapshot holds. `root` is canonical. +pub(super) fn convert_semantic_file( + root: &Path, + producer: &SemanticProducer, + summary_budget: Option, + file: super::LegacyFile, +) -> Option { + let rel_path = super::legacy_path_to_rel_path(&file.path, root)?; + let relative = segment_store::rel_path_to_os(&rel_path).ok()?; + let absolute = root.join(&relative); + let fits = fs::symlink_metadata(&absolute) + .is_ok_and(|meta| meta.is_file() && meta.len() <= MAX_SEMANTIC_SOURCE_BYTES); + let source = fits.then(|| fs::read(&absolute).ok()).flatten()?; + if file.content_hash == [0; 32] || blake3::hash(&source).as_bytes() != &file.content_hash { + return None; + } + let chunks = + match crate::semantic_index::chunk_view_file(root, &relative, &source, producer.caps) { Ok(Some(chunks)) => chunks, - _ => { - skipped += 1; - continue; - } + _ => return None, }; - let mut stored: BTreeMap>> = BTreeMap::new(); - for entry in file.entries { - stored - .entry(ChunkShape { - name: entry.name, - qualified_name: entry.qualified_name.unwrap_or_default(), - kind: entry.kind, - start_line: entry.start_line, - end_line: entry.end_line, - exported: entry.exported, - snippet: entry.snippet, - embed_text: entry.embed_text, - }) - .or_default() - .push(entry.vector); - } - let chunk_count = chunks.len(); - let mut rows = Vec::with_capacity(chunk_count); - let mut summary_repair_pending = false; - for chunk in chunks { - let shape = ChunkShape { - name: chunk.name, - qualified_name: chunk.qualified_name.unwrap_or_default(), - kind: stored_kind(&chunk.kind), - start_line: chunk.start_line, - end_line: chunk.end_line, - exported: chunk.exported, - snippet: chunk.snippet, - embed_text: chunk.embed_text, - }; - if let Some(vector) = stored.get_mut(&shape).and_then(Vec::pop) { - rows.push((shape, vector)); - } else if let Some(legacy) = summary_budget - .and_then(|budget| { - stored - .keys() - .find(|legacy| capped_legacy_summary(&shape, legacy, budget)) - }) - .cloned() - { - let Some(vector) = stored.get_mut(&legacy).and_then(Vec::pop) else { - break; - }; - rows.push((legacy, vector)); - summary_repair_pending = true; - } else { + let mut stored: BTreeMap>> = BTreeMap::new(); + for entry in file.entries { + stored + .entry(ChunkShape { + name: entry.name, + qualified_name: entry.qualified_name.unwrap_or_default(), + kind: entry.kind, + start_line: entry.start_line, + end_line: entry.end_line, + exported: entry.exported, + snippet: entry.snippet, + embed_text: entry.embed_text, + }) + .or_default() + .push(entry.vector); + } + let chunk_count = chunks.len(); + let mut rows = Vec::with_capacity(chunk_count); + let mut summary_repair_pending = false; + for chunk in chunks { + let shape = ChunkShape { + name: chunk.name, + qualified_name: chunk.qualified_name.unwrap_or_default(), + kind: stored_kind(&chunk.kind), + start_line: chunk.start_line, + end_line: chunk.end_line, + exported: chunk.exported, + snippet: chunk.snippet, + embed_text: chunk.embed_text, + }; + if let Some(vector) = stored.get_mut(&shape).and_then(Vec::pop) { + rows.push((shape, vector)); + } else if let Some(legacy) = summary_budget + .and_then(|budget| { + stored + .keys() + .find(|legacy| capped_legacy_summary(&shape, legacy, budget)) + }) + .cloned() + { + let Some(vector) = stored.get_mut(&legacy).and_then(Vec::pop) else { break; - } - } - let all_matched = rows.len() == chunk_count && stored.values().all(Vec::is_empty); - if !all_matched { - skipped += 1; - continue; - } - let key = if summary_repair_pending { - producer.legacy_key(&source, &rel_path) + }; + rows.push((legacy, vector)); + summary_repair_pending = true; } else { - producer.key(&source, &rel_path) - }; - entries.push(BundleEntry { - content: ContentHash::from_bytes(file.content_hash), - size: source.len() as u64, - key: *key.as_bytes(), - payload: semantic_payload(producer, &rows), - rel_path, - }); + break; + } } - entries.sort_by(|left, right| left.rel_path.cmp(&right.rel_path)); - Ok((Bundle { entries }, skipped)) + let all_matched = rows.len() == chunk_count && stored.values().all(Vec::is_empty); + if !all_matched { + return None; + } + let key = if summary_repair_pending { + producer.legacy_key(&source, &rel_path) + } else { + producer.key(&source, &rel_path) + }; + Some(BundleEntry { + content: ContentHash::from_bytes(file.content_hash), + size: source.len() as u64, + key: *key.as_bytes(), + payload: semantic_payload(producer, &rows), + rel_path, + }) } - // --------------------------------------------------------------------------- // The import diff --git a/crates/aft/src/migration/prune_legacy.rs b/crates/aft/src/migration/prune_legacy.rs index d288ab4147..b97ad4a36d 100644 --- a/crates/aft/src/migration/prune_legacy.rs +++ b/crates/aft/src/migration/prune_legacy.rs @@ -1,12 +1,15 @@ //! `aft cache prune-legacy`: the only path that deletes a legacy (pre-view) -//! artifact set. +//! artifact set (`index`, `semantic`, `callgraph`, `artifact-owners` and +//! `symbols` directories of one key). //! //! Nothing deletes legacy sets automatically: not startup, not a sweep, not //! maintenance. The operator runs this command, which is a dry run unless //! `--yes` is given. It lists each legacy set as a whole with its bytes and -//! prunes a set only when the set's import into per-checkout views completed -//! and the retention window (14 days) has passed since, so an offline -//! rollback to the previous binary keeps its caches in between. +//! prunes a set only when the set's import into per-checkout views (v2) or +//! into the v3 stores completed and the retention window (14 days) has +//! passed since, so an offline rollback to the previous binary keeps its +//! caches in between. A set that a running v3 import or a v3 base build may +//! still read is kept whatever its ledgers say. //! //! The process census is a guard against a mistake, not process exclusion: //! @@ -49,14 +52,17 @@ pub enum LegacyKind { Semantic, Callgraph, ArtifactOwners, + /// `symbols/`: the parsed-symbol cache, which the v3 importer reads. + Symbols, } impl LegacyKind { - pub const ALL: [Self; 4] = [ + pub const ALL: [Self; 5] = [ Self::Index, Self::Semantic, Self::Callgraph, Self::ArtifactOwners, + Self::Symbols, ]; /// The legacy root under the storage directory. @@ -66,6 +72,7 @@ impl LegacyKind { Self::Semantic => "semantic", Self::Callgraph => "callgraph", Self::ArtifactOwners => "artifact-owners", + Self::Symbols => "symbols", } } @@ -100,6 +107,10 @@ pub enum Eligibility { LedgerUnreadable(String), /// The set could not be inventoried completely; it is never moved. InventoryIncomplete(String), + /// Something may still read the set as an import source, whatever its + /// ledgers say: a running v3 import holds a claim on it, or a v3 base of + /// the family of the same name is being built. + PinnedSource(String), } impl Eligibility { @@ -120,6 +131,9 @@ impl Eligibility { ), Self::LedgerUnreadable(reason) => format!("kept: import ledger unreadable: {reason}"), Self::InventoryIncomplete(reason) => format!("kept: inventory incomplete: {reason}"), + Self::PinnedSource(reason) => { + format!("kept: still pinned as an import source: {reason}") + } } } } @@ -156,7 +170,7 @@ pub struct Inventory { /// Size of a directory tree without following symlinks. The walk stops at /// `limit` entries; `complete` is false when it did. -fn tree_size(path: &Path, limit: usize) -> io::Result<(u64, u64, bool)> { +pub(crate) fn tree_size(path: &Path, limit: usize) -> io::Result<(u64, u64, bool)> { let mut bytes = 0_u64; let mut entries = 0_u64; let mut stack = vec![path.to_path_buf()]; @@ -220,14 +234,103 @@ fn civil_from_days(days: i64) -> (i64, u32, u32) { (year, month, day) } -/// Decides eligibility from the key's import ledger, read without writing. +/// Whether something may still read a legacy set as an import source. +enum SourcePin { + Free, + Pinned(String), + Uncertain(String), +} + +/// Consults the v3 import ledger and the v3 base-build claims (the +/// base-reference protocol of `views::base_refs`) of the family named like +/// the legacy key, reading both without writing. A finished import does not +/// free a set that a live import or a live base build may still read. +fn source_pin(storage: &Path, key: &str) -> SourcePin { + match super::v3_ledger::legacy_key_status(storage, key) { + Ok(Some(status)) if status.live_claim => { + return SourcePin::Pinned("a running v3 import holds a claim on it".to_owned()) + } + Ok(_) => {} + Err(error) => return SourcePin::Uncertain(error.to_string()), + } + if crate::blob_store::v2::validate_family(key).is_err() { + return SourcePin::Free; + } + let Ok(registry) = + crate::blob_store::v3::family_dir(storage, key).map(|family| family.join("members.sqlite")) + else { + return SourcePin::Free; + }; + if !registry.is_file() { + return SourcePin::Free; + } + // Read a private copy: even a read-only open of the live registry can + // leave WAL sidecars beside it, and this command's dry run writes nothing. + let building = super::v3_ledger::read_sqlite_copy(®istry, |connection| { + if !crate::views::base_refs::has_base_refs(connection)? { + return Ok(None); + } + let mut statement = connection.prepare( + "SELECT base_scope, owner_pid, owner_start FROM base_builds WHERE state='building'", + )?; + let claims = statement + .query_map([], |row| { + Ok(( + row.get::<_, String>(0)?, + crate::pins::PinOwner { + pid: row.get(1)?, + start_time: row.get(2)?, + }, + )) + })? + .collect::>>()?; + Ok(claims + .into_iter() + .find(|(_, owner)| crate::pins::owner_is_live(owner)) + .map(|(scope, _)| scope)) + }); + match building { + Ok(Some(scope)) => SourcePin::Pinned(format!("v3 base {scope} is being built")), + Ok(None) => SourcePin::Free, + Err(error) => SourcePin::Uncertain(format!("v3 registry unreadable: {error}")), + } +} + +/// Decides eligibility from the key's import ledgers (the v2 per-checkout +/// ledger and the v3 ledger), read without writing. Every ledger that has +/// rows for the key must be complete; the retention window runs from the +/// latest completion; and nothing may still pin the set as a source. fn eligibility(storage: &Path, key: &str, now: SystemTime) -> Eligibility { - let status = match ImportLedger::read_status(storage, key) { - Ok(Some(status)) => status, - Ok(None) => return Eligibility::NotImported, + match source_pin(storage, key) { + SourcePin::Free => {} + SourcePin::Pinned(reason) => return Eligibility::PinnedSource(reason), + SourcePin::Uncertain(reason) => return Eligibility::LedgerUnreadable(reason), + } + let v2 = match ImportLedger::read_status(storage, key) { + Ok(status) => status, Err(error) => return Eligibility::LedgerUnreadable(error.to_string()), }; - let (true, Some(completed_ms)) = (status.complete, status.completed_ms) else { + let v3 = match super::v3_ledger::legacy_key_status(storage, key) { + Ok(status) => status, + Err(error) => return Eligibility::LedgerUnreadable(error.to_string()), + }; + if v2.is_none() && v3.is_none() { + return Eligibility::NotImported; + } + let mut completions = Vec::new(); + if let Some(status) = v2 { + match (status.complete, status.completed_ms) { + (true, Some(completed_ms)) => completions.push(completed_ms), + _ => return Eligibility::ImportIncomplete, + } + } + if let Some(status) = v3 { + match (status.incomplete, status.completed_ms) { + (false, Some(completed_ms)) => completions.push(completed_ms), + _ => return Eligibility::ImportIncomplete, + } + } + let Some(completed_ms) = completions.into_iter().max() else { return Eligibility::ImportIncomplete; }; let retention_ms = LEGACY_RETENTION.as_millis() as u64; @@ -761,6 +864,7 @@ pub fn prune_legacy( writeln!(out)?; let inventory = inventory(storage, options.now)?; write_inventory(out, storage, &inventory)?; + write_retained(out, storage)?; if let Some(reasons) = census_refusal(census) { writeln!(out, "Refusing: the storage root may be in use.")?; @@ -936,6 +1040,54 @@ fn write_inventory(out: &mut dyn Write, storage: &Path, inventory: &Inventory) - Ok(()) } +/// Lists the legacy data this command never removes (v1 views and blob +/// families, v2 family stores), so the operator sees what stays for rollback +/// and which families v3 already holds. Symbol caches are part of the legacy +/// sets above and are pruned with them. +fn write_retained(out: &mut dyn Write, storage: &Path) -> io::Result<()> { + let items = match crate::storage_retention::retained_legacy_inventory(storage) { + Ok(items) => items + .into_iter() + .filter(|item| item.kind != crate::storage_retention::RetainedKind::SymbolCache) + .collect::>(), + Err(error) => { + writeln!(out, "Retained legacy data could not be listed: {error}")?; + writeln!(out)?; + return Ok(()); + } + }; + if items.is_empty() { + return Ok(()); + } + writeln!(out, "Retained legacy data (never removed by this command):")?; + for item in &items { + let mut notes = Vec::new(); + if item.copied_into_v3 { + notes.push("v3 family present".to_owned()); + } + if let Some(reason) = &item.pinned { + notes.push(format!("pinned: {reason}")); + } + writeln!( + out, + " {} {} {}{}", + item.kind.describe(), + item.path + .strip_prefix(storage) + .unwrap_or(&item.path) + .display(), + human_bytes(item.bytes), + if notes.is_empty() { + String::new() + } else { + format!(" ({})", notes.join("; ")) + } + )?; + } + writeln!(out)?; + Ok(()) +} + /// The retention window as a whole number of days, for help text. pub fn retention_days() -> u64 { LEGACY_RETENTION.as_secs() / Duration::from_secs(86_400).as_secs() diff --git a/crates/aft/src/migration/prune_legacy_tests.rs b/crates/aft/src/migration/prune_legacy_tests.rs index 8a941aaf12..f722304c78 100644 --- a/crates/aft/src/migration/prune_legacy_tests.rs +++ b/crates/aft/src/migration/prune_legacy_tests.rs @@ -116,3 +116,253 @@ fn a_tree_walk_stops_at_its_bound() { assert!(complete); assert_eq!(entries, 10); } + +// --------------------------------------------------------------------------- +// v3 import sources + +use std::collections::BTreeMap; + +use crate::blob_store::v3; +use crate::migration::v3_import::tests::{checkout, legacy_trigram, snapshot_tree, KEY}; +use crate::migration::v3_ledger::{hold_claim_for_test, run_v3_import, V3ImportRequest}; + +struct Census(Vec); + +impl ProcessCensus for Census { + fn take(&self) -> Result, String> { + Ok(self.0.clone()) + } +} + +const FIFTEEN_DAYS: Duration = Duration::from_secs(15 * 24 * 60 * 60); + +/// A storage root whose legacy set `KEY` was imported into v3 (and never +/// through v2), plus retained v1 and v2 data and a v3 family. +fn v3_imported_root(dir: &Path) -> PathBuf { + let root = checkout(dir); + let storage = dir.join("storage"); + legacy_trigram(&storage, &root); + let symbols = crate::symbol_cache_disk::cache_path(&storage, KEY); + fs::create_dir_all(symbols.parent().unwrap()).unwrap(); + fs::write(&symbols, b"not a symbol cache").unwrap(); + let mut request = V3ImportRequest::full_plane( + storage.clone(), + KEY.to_owned(), + KEY.to_owned(), + root, + crate::migration::v3_import::tests::POLICY, + None, + ); + request.symbols = true; + run_v3_import(&request, None).unwrap(); + for retained in [ + "views/0123456789abcdef/manifest.json", + "blobs/0123456789abcdef/semantic.sqlite", + "blobs/v2/family/semantic.sqlite", + ] { + let path = storage.join(retained); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(path, b"retained").unwrap(); + } + storage +} + +/// Every file's bytes except SQLite shared-memory files, which a reader +/// updates by design (read marks) without changing any stored data. +fn data_files(storage: &Path) -> BTreeMap> { + snapshot_tree(storage) + .into_iter() + .filter(|(path, _)| !path.to_string_lossy().ends_with("-shm")) + .collect() +} + +/// Fails naming every file whose presence or bytes differ. +fn assert_same( + before: &BTreeMap>, + after: &BTreeMap>, + what: &str, +) { + let changed = before + .keys() + .chain(after.keys()) + .filter(|path| before.get(*path) != after.get(*path)) + .map(|path| { + format!( + "{} ({:?} -> {:?} bytes)", + path.display(), + before.get(path).map(Vec::len), + after.get(path).map(Vec::len) + ) + }) + .collect::>(); + assert!(changed.is_empty(), "{what}: {changed:#?}"); +} + +fn eligibility_of(storage: &Path, now: SystemTime) -> Eligibility { + inventory(storage, now) + .unwrap() + .sets + .into_iter() + .find(|set| set.key == KEY) + .unwrap() + .eligibility +} + +fn run(storage: &Path, yes: bool, census: &Census) -> (PruneExit, String) { + let mut out = Vec::new(); + let exit = prune_legacy( + storage, + &PruneOptions { + yes, + now: SystemTime::now() + FIFTEEN_DAYS, + }, + census, + None, + &mut out, + ) + .unwrap(); + (exit, String::from_utf8(out).unwrap()) +} + +/// A v3 import's completion makes the set (including its symbol cache) +/// eligible after the retention window, but never while a running import +/// holds a claim on it, whatever its other rows say. +#[test] +fn a_set_a_live_v3_import_reads_is_never_prune_eligible() { + let _serial = v3::serial_for_test(); + let dir = tempfile::tempdir().unwrap(); + let storage = v3_imported_root(dir.path()); + let now = SystemTime::now(); + assert!(matches!( + eligibility_of(&storage, now), + Eligibility::Retained { .. } + )); + assert!(matches!( + eligibility_of(&storage, now + FIFTEEN_DAYS), + Eligibility::Eligible { .. } + )); + let set = inventory(&storage, now).unwrap().sets.remove(0); + assert!(set.dirs.iter().any(|dir| dir.kind == LegacyKind::Symbols)); + + let held = hold_claim_for_test(&storage, KEY, KEY); + let pinned = eligibility_of(&storage, now + FIFTEEN_DAYS); + assert!( + matches!(pinned, Eligibility::PinnedSource(ref reason) if reason.contains("v3 import")), + "{pinned:?}" + ); + let before = data_files(&storage); + let (exit, out) = run(&storage, true, &Census(Vec::new())); + assert_eq!(exit, PruneExit::Clean, "{out}"); + assert!( + out.contains("kept: still pinned as an import source"), + "{out}" + ); + assert_same(&before, &data_files(&storage), "a pinned set lost bytes"); + drop(held); +} + +/// The v3 registry's base-build claims are consulted: a live base build in +/// the family of the same name pins the legacy set; a dead builder does not. +#[test] +fn a_live_v3_base_build_pins_the_family_legacy_set() { + let _serial = v3::serial_for_test(); + let dir = tempfile::tempdir().unwrap(); + let storage = v3_imported_root(dir.path()); + let lock = v3::ReservationLock::acquire(&storage).unwrap(); + let mut registry = v3::open_base_refs_registry(&storage, KEY, &lock, None).unwrap(); + let me = crate::views::registry::current_owner(); + registry + .with_barrier(|tx| { + tx.execute( + "INSERT INTO base_builds (base_scope, owner_pid, owner_start, owner_token, attempt, state, heartbeat_at_ms) + VALUES ('base-under-test', ?1, ?2, 'token', 1, 'building', 0)", + rusqlite::params![me.pid, me.start_time as i64], + )?; + Ok(()) + }) + .unwrap(); + drop(registry); + drop(lock); + let later = SystemTime::now() + FIFTEEN_DAYS; + let pinned = eligibility_of(&storage, later); + assert!( + matches!(pinned, Eligibility::PinnedSource(ref reason) if reason.contains("base-under-test")), + "{pinned:?}" + ); + let lock = v3::ReservationLock::acquire(&storage).unwrap(); + let mut registry = v3::open_base_refs_registry(&storage, KEY, &lock, None).unwrap(); + registry + .with_barrier(|tx| { + // A pid no process holds: the builder is gone. + tx.execute("UPDATE base_builds SET owner_pid = 2147483646", [])?; + Ok(()) + }) + .unwrap(); + drop(registry); + drop(lock); + assert!(matches!( + eligibility_of(&storage, later), + Eligibility::Eligible { .. } + )); +} + +/// The safeguards stay: every run prints the stop-all warning, a dry run +/// changes nothing, a live AFT process refuses the run, and `--yes` renames +/// the eligible set aside before deleting it. Retained v1/v2 data is listed +/// and never touched. +#[cfg(unix)] +#[test] +fn prune_keeps_its_safeguards_and_never_touches_retained_legacy_data() { + let _serial = v3::serial_for_test(); + let dir = tempfile::tempdir().unwrap(); + let storage = v3_imported_root(dir.path()); + let retained = data_files(&storage) + .into_iter() + .filter(|(path, _)| { + let relative = path.strip_prefix(&storage).unwrap(); + relative.starts_with("views") + || relative.starts_with("blobs") + || relative.starts_with("migration") + }) + .collect::>(); + let before = data_files(&storage); + + let (exit, out) = run(&storage, false, &Census(Vec::new())); + assert_eq!(exit, PruneExit::Clean, "{out}"); + assert!(out.starts_with(OPERATOR_CONTRACT), "{out}"); + assert!(out.contains("Dry run: 1 legacy set(s)"), "{out}"); + assert!(out.contains("Retained legacy data"), "{out}"); + assert!(out.contains("v2 family store blobs/v2/family"), "{out}"); + assert!(out.contains("v1 view views/0123456789abcdef"), "{out}"); + assert!(out.contains("v3 family present"), "{out}"); + assert_same(&before, &data_files(&storage), "a dry run changed bytes"); + + let holder = CensusFinding::Aft { + pid: 4242, + daemon: true, + command: "aft --subc /tmp/c.json".to_owned(), + }; + let (exit, out) = run(&storage, true, &Census(vec![holder])); + assert_eq!(exit, PruneExit::Refused, "{out}"); + assert_same( + &before, + &data_files(&storage), + "a refused run changed bytes", + ); + + let (exit, out) = run(&storage, true, &Census(Vec::new())); + assert_eq!(exit, PruneExit::Clean, "{out}"); + assert!(out.contains(&format!("Moved {KEY} aside")), "{out}"); + for kind in ["index", "symbols"] { + assert!(!storage.join(kind).join(KEY).exists(), "{kind}: {out}"); + } + let after = data_files(&storage); + for (path, bytes) in &retained { + assert_eq!(after.get(path), Some(bytes), "{} changed", path.display()); + } + assert!(fs::read_dir(&storage).unwrap().all(|entry| !entry + .unwrap() + .file_name() + .to_string_lossy() + .starts_with(PRUNE_DIR_PREFIX))); +} diff --git a/crates/aft/src/migration/v3_import.rs b/crates/aft/src/migration/v3_import.rs new file mode 100644 index 0000000000..43a06d8313 --- /dev/null +++ b/crates/aft/src/migration/v3_import.rs @@ -0,0 +1,1451 @@ +//! Import of legacy (pre-view) index artifacts into the v3 family stores. +//! +//! The legacy artifacts of one key are `index//cache.bin` (trigram +//! postings), `semantic//semantic.bin` (vectors) and +//! `symbols//symbols.bin` (parsed symbols). This importer converts what +//! can be proven equal to a cold build into rows of the family's v3 planes +//! (`blobs/v3//{trigram,semantic,symbol}.sqlite`), so the base or +//! worker that later needs those files finds the rows by key instead of +//! re-tokenizing, re-embedding or re-parsing them. +//! +//! What it never does: +//! +//! - write a v1 or v2 row, manifest, pointer or schema (it opens no v1/v2 +//! store at all); +//! - move, change or delete a legacy file: only `aft cache prune-legacy` +//! removes legacy sets, and only after the retention window; +//! - fall back to a legacy reader when v3 admission refuses a reservation: +//! the plane reports a named storage gap and serves what v3 already holds; +//! - call an embedding model or the tree-sitter parser for an imported row +//! of the trigram or symbol plane. The semantic plane re-chunks the +//! checkout's bytes to prove the stored rows match, as the v2 import does, +//! but never calls a model. +//! +//! Inputs of any size are streamed: memory is bounded by the per-file tables +//! of an artifact plus a working-set budget, never by the input size. +//! +//! Progress is recorded in the import ledger `blobs/v3//imports.sqlite` +//! (format in docs/design/views-v3-format.md, *Import ledger and symbol +//! plane*), one row per legacy key, artifact, producer and destination plane: +//! +//! ```text +//! (absent) → claimed → staged → validated → registered → done +//! ↘ rejected{reason} (from any non-terminal state) +//! ``` +//! +//! Every transition is a compare-and-set on the row's state and owner token, +//! so a crash at any boundary leaves a row the next attempt resumes by +//! repeating at most one idempotent step. + +use std::collections::{BTreeMap, HashSet}; +use std::fmt; +use std::fs; +use std::io::{self, BufReader, BufWriter, Read, Seek, SeekFrom, Write}; +use std::path::{Path, PathBuf}; +use std::time::SystemTime; + +use super::per_checkout::{convert_semantic_file, now_ms, small_summary_budget, BundleEntry}; +use crate::blob_store::v2::{ + to_hex, ContentHash, FamilyKey, FamilyPlane, TrigramKey, TrigramPolicy, +}; +use crate::blob_store::v3; +use crate::views::segment_store::{TrigramFlag, TrigramPayload, TrigramRecord}; +use crate::views::semantic::SemanticProducer; +use crate::views::RelPath; + +/// Format and schema of the v3 import ledger this build writes and reads. +pub const LEDGER_FORMAT_VERSION: i64 = 1; +/// `PRAGMA application_id` of the ledger ("AFTI"). +pub const LEDGER_APPLICATION_ID: i64 = 0x4146_5449; +/// Working-set budget of the trigram inversion when the caller sets none. +pub const DEFAULT_WORKING_SET: usize = 64 << 20; +/// Payload bytes registered by one v3 publication. +const REGISTER_BATCH_BYTES: u64 = 32 << 20; +const STAGED_MAGIC: &[u8; 8] = b"AFTIMP03"; +/// Size of one sequential read of a legacy source. +const READ_CHUNK: usize = 64 * 1024; +/// Longest path field accepted from a legacy artifact or a staged bundle. +const MAX_PATH_BYTES: usize = 64 * 1024; +/// Longest single non-path field (a snippet, an embedded text, a vector or a +/// staged payload) the decoders allocate. +const MAX_FIELD_BYTES: usize = 256 << 20; +/// Upper bound on files listed by one legacy artifact. +const MAX_LEGACY_FILES: usize = 2_000_000; + +// Layout constants of the legacy trigram `cache.bin`, as written by the +// search index of the previous release (`search_index::write_cache_file_from_sources`). +const LEGACY_CACHE_MAGIC: u32 = 0x3144_4958; +const LEGACY_INDEX_MAGIC: &[u8; 8] = b"AFTIDX01"; +const LEGACY_LOOKUP_MAGIC: &[u8; 8] = b"AFTLKP01"; +const LEGACY_POSTING_BYTES: usize = 6; +const LEGACY_LOOKUP_ENTRY_BYTES: usize = 16; + +/// Refusals of a v3 reservation that are a lack of storage, reported as the +/// plane's named storage gap rather than as an import failure. +const STORAGE_GAP_REFUSALS: [&str; 3] = [ + "v3_namespace_budget", + "device_free_space_floor", + "reservation_ledger_unreadable", +]; + +// --------------------------------------------------------------------------- +// Errors + +#[derive(Debug)] +pub enum V3ImportError { + Io(io::Error), + Sqlite(rusqlite::Error), + Store(v3::Error), + /// The ledger was written by a newer AFT. + NewerLedger(i64), + /// The legacy key or family cannot name a directory. + InvalidKey(String), + /// Another owner took the row over between two steps of this attempt. + LostClaim(String), + Corrupt(String), +} + +impl fmt::Display for V3ImportError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Io(error) => write!(f, "v3 legacy import I/O error: {error}"), + Self::Sqlite(error) => write!(f, "v3 legacy import ledger error: {error}"), + Self::Store(error) => write!(f, "v3 legacy import store error: {error}"), + Self::NewerLedger(version) => write!( + f, + "v3 import ledger format {version} is newer than this build reads ({LEDGER_FORMAT_VERSION})" + ), + Self::InvalidKey(key) => write!(f, "invalid legacy key or family `{key}`"), + Self::LostClaim(row) => write!(f, "v3 import claim of {row} was taken over"), + Self::Corrupt(reason) => write!(f, "v3 import state is corrupt: {reason}"), + } + } +} + +impl std::error::Error for V3ImportError {} + +impl From for V3ImportError { + fn from(error: io::Error) -> Self { + Self::Io(error) + } +} + +impl From for V3ImportError { + fn from(error: rusqlite::Error) -> Self { + Self::Sqlite(error) + } +} + +impl From for V3ImportError { + fn from(error: v3::Error) -> Self { + Self::Store(error) + } +} + +pub type V3Result = Result; + +// --------------------------------------------------------------------------- +// Artifacts and paths + +/// One legacy artifact the v3 importer reads. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)] +pub enum LegacyArtifact { + Trigram, + Semantic, + Symbols, +} + +impl LegacyArtifact { + pub const ALL: [Self; 3] = [Self::Trigram, Self::Semantic, Self::Symbols]; + + pub const fn as_str(self) -> &'static str { + match self { + Self::Trigram => "trigram", + Self::Semantic => "semantic", + Self::Symbols => "symbols", + } + } + + pub(super) fn parse(value: &str) -> Option { + Self::ALL + .into_iter() + .find(|artifact| artifact.as_str() == value) + } + + /// The v3 plane this artifact's rows go to. + pub const fn plane(self) -> FamilyPlane { + match self { + Self::Trigram => FamilyPlane::Trigram, + Self::Semantic => FamilyPlane::Semantic, + Self::Symbols => FamilyPlane::Symbol, + } + } + + /// The legacy file this artifact is read from. + pub fn source_path(self, storage: &Path, legacy_key: &str) -> PathBuf { + match self { + Self::Trigram => super::per_checkout::legacy_trigram_path(storage, legacy_key), + Self::Semantic => super::per_checkout::legacy_semantic_path(storage, legacy_key), + Self::Symbols => crate::symbol_cache_disk::cache_path(storage, legacy_key), + } + } +} + +/// `/migration/v3//`: staging bundles. It lies +/// outside `blobs/v3` and outside every directory older binaries walk. +pub fn staging_dir(storage: &Path, family: &str, legacy_key: &str) -> PathBuf { + storage + .join("migration") + .join("v3") + .join(family) + .join(legacy_key) +} + +fn hash_prefix(hash: &[u8; 32]) -> String { + to_hex(hash)[..32].to_owned() +} + +// --------------------------------------------------------------------------- +// Bounded reads of a legacy source + +/// What a decode read and held, for the bounded-memory contract. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct ReadStats { + /// Bytes read from the legacy source, summed over every pass. + pub bytes_read: u64, + /// Largest single read from the legacy source. + pub largest_read: usize, + /// Passes over the trigram posting blob after the checksum pass. + pub posting_passes: u32, + /// Largest number of bytes of decoded postings held at once. + pub peak_working_set: u64, +} + +/// The file identity a decode started from. A legacy writer replaces its +/// file by rename, so an unchanged identity on the open handle means the +/// bytes did not change under the decode. +#[derive(Clone, Debug, Eq, PartialEq)] +struct SourceIdentity { + len: u64, + modified: Option, +} + +impl SourceIdentity { + fn of(metadata: &fs::Metadata) -> Self { + Self { + len: metadata.len(), + modified: metadata.modified().ok(), + } + } +} + +/// A legacy source opened once, read only through bounded reads. It is opened +/// read-only and never written, truncated or repaired. +pub(super) struct Source { + file: fs::File, + len: u64, + identity: SourceIdentity, + stats: ReadStats, +} + +impl Source { + fn open(path: &Path) -> Result { + let file = fs::File::open(path).map_err(|error| match error.kind() { + io::ErrorKind::NotFound => "no legacy artifact".to_owned(), + _ => format!("legacy artifact unreadable: {error}"), + })?; + let metadata = file + .metadata() + .map_err(|error| format!("legacy artifact unreadable: {error}"))?; + if !metadata.is_file() { + return Err("legacy artifact is not a file".to_owned()); + } + Ok(Self { + len: metadata.len(), + identity: SourceIdentity::of(&metadata), + file, + stats: ReadStats::default(), + }) + } + + fn read_at(&mut self, offset: u64, buf: &mut [u8]) -> Result<(), String> { + offset + .checked_add(buf.len() as u64) + .filter(|end| *end <= self.len) + .ok_or("unexpected end of legacy artifact")?; + self.file + .seek(SeekFrom::Start(offset)) + .and_then(|_| self.file.read_exact(buf)) + .map_err(|error| format!("legacy artifact read failed: {error}"))?; + self.stats.bytes_read += buf.len() as u64; + self.stats.largest_read = self.stats.largest_read.max(buf.len()); + Ok(()) + } + + /// A sequential reader over the whole source, through bounded reads. + pub(super) fn sequential(&mut self) -> SourceRead<'_> { + SourceRead { + source: self, + offset: 0, + } + } + + /// Whether the open file still has the identity it had when opened. + fn unchanged(&self) -> bool { + self.file + .metadata() + .is_ok_and(|metadata| SourceIdentity::of(&metadata) == self.identity) + } + + /// One sequential pass over the whole file: its BLAKE3 and the CRC32 of + /// each `[start, end)` range in `crc_ranges`. + pub(super) fn checksum_pass( + &mut self, + crc_ranges: &[(u64, u64)], + ) -> Result<([u8; 32], Vec), String> { + let mut hasher = blake3::Hasher::new(); + let mut crcs = vec![crc32fast::Hasher::new(); crc_ranges.len()]; + let mut buffer = vec![0_u8; READ_CHUNK]; + let mut offset = 0_u64; + while offset < self.len { + let len = (self.len - offset).min(READ_CHUNK as u64) as usize; + self.read_at(offset, &mut buffer[..len])?; + let chunk = &buffer[..len]; + hasher.update(chunk); + for ((start, end), crc) in crc_ranges.iter().zip(crcs.iter_mut()) { + let from = (*start).max(offset); + let to = (*end).min(offset + len as u64); + if from < to { + crc.update(&chunk[(from - offset) as usize..(to - offset) as usize]); + } + } + offset += len as u64; + } + Ok(( + *hasher.finalize().as_bytes(), + crcs.into_iter().map(crc32fast::Hasher::finalize).collect(), + )) + } +} + +/// `Read` over a [`Source`] from its start; every read is at most +/// [`READ_CHUNK`] bytes. +pub(super) struct SourceRead<'a> { + source: &'a mut Source, + offset: u64, +} + +impl Read for SourceRead<'_> { + fn read(&mut self, buf: &mut [u8]) -> io::Result { + let remaining = self.source.len - self.offset; + let len = (buf.len() as u64).min(remaining).min(READ_CHUNK as u64) as usize; + if len == 0 { + return Ok(0); + } + self.source + .read_at(self.offset, &mut buf[..len]) + .map_err(io::Error::other)?; + self.offset += len as u64; + Ok(len) + } +} + +/// A little-endian cursor over `[offset, end)` of a source, refilled one +/// bounded window at a time. +struct SeqReader<'a> { + source: &'a mut Source, + window: Vec, + window_start: u64, + offset: u64, + end: u64, +} + +impl<'a> SeqReader<'a> { + fn new(source: &'a mut Source, offset: u64, end: u64) -> Self { + Self { + source, + window: Vec::new(), + window_start: offset, + offset, + end, + } + } + + fn position(&self) -> u64 { + self.offset + } + + fn is_exhausted(&self) -> bool { + self.offset == self.end + } + + fn bytes(&mut self, out: &mut [u8]) -> Result<(), String> { + if self.offset + out.len() as u64 > self.end { + return Err("unexpected end of data".to_owned()); + } + let mut written = 0; + while written < out.len() { + let window_end = self.window_start + self.window.len() as u64; + if self.offset < self.window_start || self.offset >= window_end { + let len = (self.end - self.offset).min(READ_CHUNK as u64) as usize; + self.window.resize(len, 0); + self.source.read_at(self.offset, &mut self.window)?; + self.window_start = self.offset; + continue; + } + let at = (self.offset - self.window_start) as usize; + let take = (self.window.len() - at).min(out.len() - written); + out[written..written + take].copy_from_slice(&self.window[at..at + take]); + written += take; + self.offset += take as u64; + } + Ok(()) + } + + fn skip(&mut self, len: u64) -> Result<(), String> { + self.offset = self + .offset + .checked_add(len) + .filter(|end| *end <= self.end) + .ok_or("unexpected end of data")?; + Ok(()) + } + + fn vec(&mut self, len: usize, cap: usize) -> Result, String> { + if len > cap || len as u64 > self.end - self.offset { + return Err(format!("field of {len} bytes exceeds its bound")); + } + let mut out = vec![0_u8; len]; + self.bytes(&mut out)?; + Ok(out) + } + + fn u8(&mut self) -> Result { + let mut out = [0_u8; 1]; + self.bytes(&mut out)?; + Ok(out[0]) + } + + fn u32(&mut self) -> Result { + let mut out = [0_u8; 4]; + self.bytes(&mut out)?; + Ok(u32::from_le_bytes(out)) + } + + fn u64(&mut self) -> Result { + let mut out = [0_u8; 8]; + self.bytes(&mut out)?; + Ok(u64::from_le_bytes(out)) + } + + fn array32(&mut self) -> Result<[u8; 32], String> { + let mut out = [0_u8; 32]; + self.bytes(&mut out)?; + Ok(out) + } + + /// A `u32`-length-prefixed field. + fn field(&mut self, cap: usize) -> Result, String> { + let len = self.u32()? as usize; + self.vec(len, cap) + } + + fn string(&mut self, cap: usize) -> Result { + String::from_utf8(self.field(cap)?).map_err(|_| "legacy string is not UTF-8".to_owned()) + } +} + +// --------------------------------------------------------------------------- +// Streaming conversion: trigram + +struct TrigramFile { + rel_path: RelPath, + size: u64, + content: [u8; 32], + unindexed: bool, +} + +/// Where the sections of a checksummed legacy `cache.bin` are. +struct TrigramLayout { + files: Vec, + blob_start: u64, + blob_len: u64, + lookup_entries_start: u64, + entry_count: u64, +} + +/// Verifies both checksums of a legacy `cache.bin` in one sequential pass and +/// reads its file table. Returns the layout and the file's BLAKE3. +fn trigram_layout( + source: &mut Source, + policy: &TrigramPolicy, +) -> Result<(TrigramLayout, [u8; 32]), String> { + let source_len = source.len; + let mut header = SeqReader::new(source, 0, 16.min(source_len)); + if header.u32()? != LEGACY_CACHE_MAGIC { + return Err("not a trigram cache".to_owned()); + } + let version = header.u32()?; + if version != crate::search_index::INDEX_FORMAT_VERSION { + return Err(format!( + "trigram cache version {version} differs from this build's {}", + crate::search_index::INDEX_FORMAT_VERSION + )); + } + let postings_len_total = header.u64()?; + let postings_end = 16_u64 + .checked_add(postings_len_total) + .filter(|end| *end <= source.len && postings_len_total >= 4) + .ok_or("trigram cache postings section overruns the file")?; + let crc_at = postings_end - 4; + let lookup_crc_at = source + .len + .checked_sub(4) + .filter(|at| *at >= postings_end) + .ok_or("trigram cache lookup section is truncated")?; + let (source_hash, crcs) = + source.checksum_pass(&[(16, crc_at), (postings_end, lookup_crc_at)])?; + let mut stored = [0_u8; 4]; + source.read_at(crc_at, &mut stored)?; + if crcs[0] != u32::from_le_bytes(stored) { + return Err("trigram cache postings checksum mismatch".to_owned()); + } + source.read_at(lookup_crc_at, &mut stored)?; + if crcs[1] != u32::from_le_bytes(stored) { + return Err("trigram cache lookup checksum mismatch".to_owned()); + } + + let mut reader = SeqReader::new(source, 16, crc_at); + let mut magic = [0_u8; 8]; + reader.bytes(&mut magic)?; + if &magic != LEGACY_INDEX_MAGIC || reader.u32()? != version { + return Err("trigram cache postings header mismatch".to_owned()); + } + let head_len = reader.u32()?; + let root_len = reader.u32()?; + let ignore_len = reader.u32()?; + let max_file_size = reader.u64()?; + if max_file_size != policy.max_file_size { + return Err(format!( + "trigram cache max_file_size {max_file_size} differs from the family policy {}", + policy.max_file_size + )); + } + let file_count = reader.u32()? as usize; + if file_count > MAX_LEGACY_FILES { + return Err(format!("trigram cache lists {file_count} files")); + } + reader.skip(u64::from(head_len))?; + reader.skip(u64::from(root_len))?; + reader.skip(u64::from(ignore_len))?; + let mut files = Vec::with_capacity(file_count.min((crc_at / 57) as usize)); + let mut seen = HashSet::new(); + for _ in 0..file_count { + let unindexed = match reader.u8()? { + 0 => false, + 1 => true, + other => return Err(format!("trigram cache file flag {other}")), + }; + let path_len = reader.u32()? as usize; + let size = reader.u64()?; + let _secs = reader.u64()?; + let _nanos = reader.u32()?; + let content = reader.array32()?; + let path = reader.vec(path_len, MAX_PATH_BYTES)?; + let path = std::str::from_utf8(&path).map_err(|_| "trigram cache path is not UTF-8")?; + let path = Path::new(path); + if path.is_absolute() + || path + .components() + .any(|component| !matches!(component, std::path::Component::Normal(_))) + { + return Err(format!( + "trigram cache path {} is not relative", + path.display() + )); + } + let rel_path = RelPath::from_os_path(path).map_err(|error| error.to_string())?; + // A digest per path keeps the duplicate check smaller than the table. + if !seen.insert(*blake3::hash(rel_path.as_bytes()).as_bytes()) { + return Err("trigram cache lists a path twice".to_owned()); + } + files.push(TrigramFile { + rel_path, + size, + content, + unindexed, + }); + } + drop(seen); + let blob_len = reader.u64()?; + let blob_start = reader.position(); + reader.skip(blob_len)?; + if blob_len % LEGACY_POSTING_BYTES as u64 != 0 { + return Err("trigram cache posting blob has a partial record".to_owned()); + } + + let mut lookup = SeqReader::new(source, postings_end, lookup_crc_at); + lookup.bytes(&mut magic)?; + if &magic != LEGACY_LOOKUP_MAGIC || lookup.u32()? != version { + return Err("trigram cache lookup header mismatch".to_owned()); + } + let entry_count = u64::from(lookup.u32()?); + let lookup_entries_start = lookup.position(); + if entry_count + .checked_mul(LEGACY_LOOKUP_ENTRY_BYTES as u64) + .and_then(|len| lookup_entries_start.checked_add(len)) + != Some(lookup_crc_at) + { + return Err("trigram cache lookup table does not fill its section".to_owned()); + } + Ok(( + TrigramLayout { + files, + blob_start, + blob_len, + lookup_entries_start, + entry_count, + }, + source_hash, + )) +} + +/// Calls `visit(trigram, postings)` for every posting list, in lookup order, +/// with each list delivered in bounded chunks of whole postings. Checks that +/// the lookup is strictly sorted and every list lies inside the blob. +fn for_each_posting_chunk( + source: &mut Source, + layout: &TrigramLayout, + visit: &mut dyn FnMut(u32, &[u8]) -> Result<(), String>, +) -> Result<(), String> { + const ENTRIES_PER_READ: usize = READ_CHUNK / LEGACY_LOOKUP_ENTRY_BYTES; + const POSTINGS_PER_READ: usize = READ_CHUNK / LEGACY_POSTING_BYTES; + let mut entries = vec![0_u8; ENTRIES_PER_READ * LEGACY_LOOKUP_ENTRY_BYTES]; + let mut postings = vec![0_u8; POSTINGS_PER_READ * LEGACY_POSTING_BYTES]; + let mut previous = None; + let mut index = 0_u64; + while index < layout.entry_count { + let batch = (layout.entry_count - index).min(ENTRIES_PER_READ as u64) as usize; + let bytes = &mut entries[..batch * LEGACY_LOOKUP_ENTRY_BYTES]; + source.read_at( + layout.lookup_entries_start + index * LEGACY_LOOKUP_ENTRY_BYTES as u64, + bytes, + )?; + for entry in bytes.chunks_exact(LEGACY_LOOKUP_ENTRY_BYTES) { + let trigram = u32::from_le_bytes(entry[..4].try_into().expect("4")); + let offset = u64::from_le_bytes(entry[4..12].try_into().expect("8")); + let count = u64::from(u32::from_le_bytes(entry[12..16].try_into().expect("4"))); + if previous.is_some_and(|previous| previous >= trigram) { + return Err("trigram cache lookup is not strictly sorted".to_owned()); + } + previous = Some(trigram); + count + .checked_mul(LEGACY_POSTING_BYTES as u64) + .and_then(|len| offset.checked_add(len)) + .filter(|end| *end <= layout.blob_len) + .ok_or("trigram cache posting list overruns the blob")?; + let mut done = 0_u64; + while done < count { + let take = (count - done).min(POSTINGS_PER_READ as u64) as usize; + let chunk = &mut postings[..take * LEGACY_POSTING_BYTES]; + source.read_at( + layout.blob_start + offset + done * LEGACY_POSTING_BYTES as u64, + chunk, + )?; + visit(trigram, chunk)?; + done += take as u64; + } + } + index += batch as u64; + } + Ok(()) +} + +/// Converts a legacy `cache.bin` into canonical per-file trigram entries, +/// handing each to `emit`, with memory bounded by the file table plus +/// `working_set` bytes of decoded postings (or one file's postings, if a +/// single file needs more). +/// +/// The legacy postings are the search index's own posting fold of each file, +/// so inverting them gives exactly the payload `TrigramPayload::extract` +/// computes from the same bytes; no source file is read. Anything that cannot +/// be proven equal is a rejection: another index or tokenizer version, another +/// size limit, a failed checksum, a path that is not relative, or a malformed +/// table. Files without a recorded content hash are left out. +/// +/// The inversion runs in file-id ranges: one counting pass over the posting +/// blob sizes each file's postings, then one pass per range collects the +/// postings of that range's files only. +fn convert_trigram_streaming( + source: &mut Source, + policy: &TrigramPolicy, + working_set: usize, + emit: &mut dyn FnMut(BundleEntry) -> Result<(), String>, +) -> Result<[u8; 32], String> { + let (layout, source_hash) = trigram_layout(source, policy)?; + let file_count = layout.files.len(); + let mut counts = vec![0_u32; file_count]; + let mut last: Vec> = vec![None; file_count]; + for_each_posting_chunk(source, &layout, &mut |trigram, chunk| { + for posting in chunk.chunks_exact(LEGACY_POSTING_BYTES) { + let file_id = u32::from_le_bytes(posting[..4].try_into().expect("4")) as usize; + let slot = last + .get_mut(file_id) + .ok_or("trigram cache posting names an unknown file")?; + if *slot == Some(trigram) { + return Err("trigram cache lists one file twice under a trigram".to_owned()); + } + *slot = Some(trigram); + counts[file_id] += 1; + } + Ok(()) + })?; + drop(last); + source.stats.posting_passes += 1; + + let record_bytes = std::mem::size_of::() as u64; + let budget = working_set.max(1) as u64; + let mut start = 0; + while start < file_count { + let mut end = start; + let mut held = 0_u64; + while end < file_count { + let next = u64::from(counts[end]) * record_bytes; + if end > start && held + next > budget { + break; + } + held += next; + end += 1; + } + source.stats.peak_working_set = source.stats.peak_working_set.max(held); + let mut records: Vec> = counts[start..end] + .iter() + .map(|count| Vec::with_capacity(*count as usize)) + .collect(); + for_each_posting_chunk(source, &layout, &mut |trigram, chunk| { + for posting in chunk.chunks_exact(LEGACY_POSTING_BYTES) { + let file_id = u32::from_le_bytes(posting[..4].try_into().expect("4")) as usize; + if (start..end).contains(&file_id) { + records[file_id - start].push(TrigramRecord { + trigram, + next_mask: posting[4], + loc_mask: posting[5], + }); + } + } + Ok(()) + })?; + source.stats.posting_passes += 1; + for (file, records) in layout.files[start..end].iter().zip(records) { + if file.content == [0; 32] { + continue; + } + let flag = if !file.unindexed { + TrigramFlag::Indexed + } else if file.size > policy.max_file_size { + TrigramFlag::UnindexedOversize + } else { + TrigramFlag::UnindexedBinary + }; + if flag != TrigramFlag::Indexed && !records.is_empty() { + return Err("trigram cache holds postings for an unindexed file".to_owned()); + } + let content = ContentHash::from_bytes(file.content); + emit(BundleEntry { + rel_path: file.rel_path.clone(), + content, + size: file.size, + key: *TrigramKey { + content, + policy: *policy, + } + .family_key() + .as_bytes(), + payload: TrigramPayload { flag, records }.encode(), + })?; + } + start = end; + } + Ok(source_hash) +} + +// --------------------------------------------------------------------------- +// Streaming conversion: semantic + +struct SemanticHeader { + version: u8, + dimension: usize, + entry_count: u64, +} + +fn semantic_header( + reader: &mut SeqReader<'_>, + producer: &SemanticProducer, +) -> Result { + let version = reader.u8()?; + if version != super::LEGACY_SEMANTIC_V6 && version != super::LEGACY_SEMANTIC_V7 { + return Err(format!( + "semantic.bin version {version} has no established producer stamps" + )); + } + let dimension = reader.u32()? as usize; + if dimension == 0 || dimension > super::MAX_LEGACY_DIMENSION { + return Err(format!("invalid semantic dimension {dimension}")); + } + let entry_count = reader.u32()? as usize; + if entry_count > super::MAX_LEGACY_ROWS { + return Err(format!("too many semantic entries {entry_count}")); + } + let fingerprint = reader.string(MAX_FIELD_BYTES)?; + if !super::fingerprint_is_compatible(&fingerprint, &producer.model_fingerprint) { + return Err("model fingerprint or chunker version is incompatible".to_owned()); + } + Ok(SemanticHeader { + version, + dimension, + entry_count: entry_count as u64, + }) +} + +/// Reads the metadata table: each path's recorded content hash. +fn semantic_metadata(reader: &mut SeqReader<'_>) -> Result, [u8; 32]>, String> { + let count = reader.u32()? as usize; + if count > super::MAX_LEGACY_ROWS { + return Err(format!("too many semantic metadata rows {count}")); + } + let mut metadata = BTreeMap::new(); + for _ in 0..count { + let path = reader.field(MAX_PATH_BYTES)?; + let _seconds = reader.u64()?; + let nanos = reader.u32()?; + if nanos >= 1_000_000_000 { + return Err(format!("invalid semantic mtime nanos {nanos}")); + } + let _size = reader.u64()?; + let content_hash = reader.array32()?; + if metadata.insert(path, content_hash).is_some() { + return Err("duplicate semantic metadata path".to_owned()); + } + } + Ok(metadata) +} + +fn semantic_entry( + reader: &mut SeqReader<'_>, + header: &SemanticHeader, +) -> Result<(Vec, super::LegacyEntry), String> { + let path = reader.field(MAX_PATH_BYTES)?; + let name = reader.string(MAX_FIELD_BYTES)?; + let qualified_name = if header.version == super::LEGACY_SEMANTIC_V7 { + let qualified = reader.string(MAX_FIELD_BYTES)?; + (!qualified.is_empty()).then_some(qualified) + } else { + None + }; + let kind = reader.u8()?; + let start_line = reader.u32()?; + let end_line = reader.u32()?; + let exported = reader.u8()? != 0; + let snippet = reader.string(MAX_FIELD_BYTES)?; + let embed_text = reader.string(MAX_FIELD_BYTES)?; + let vector_len = header + .dimension + .checked_mul(std::mem::size_of::()) + .ok_or("semantic vector length overflow")?; + let vector = reader.vec(vector_len, MAX_FIELD_BYTES)?; + Ok(( + path, + super::LegacyEntry { + name, + qualified_name, + kind, + start_line, + end_line, + exported, + snippet, + embed_text, + vector, + }, + )) +} + +fn path_digest(path: &[u8]) -> [u8; 32] { + *blake3::hash(path).as_bytes() +} + +/// Converts a legacy `semantic.bin` into view payloads under `producer`, one +/// file's rows at a time, handing each imported file to `emit`. Returns the +/// file's BLAKE3 and the number of files left for the plane to embed. +/// +/// The decoder is read-only and strict: a version without producer stamps, +/// another model or chunker, or trailing bytes after the snapshot (appended +/// segments or a torn tail) reject the whole artifact. Nothing is repaired or +/// folded. Each file is then checked as the v2 import does +/// (`convert_semantic_file`): its current bytes must still hash to the +/// snapshot's hash and chunk into exactly the snapshot's rows. A path whose +/// rows are not contiguous in the snapshot is left for the plane. +fn convert_semantic_streaming( + source: &mut Source, + root: &Path, + producer: &SemanticProducer, + emit: &mut dyn FnMut(BundleEntry) -> Result<(), String>, +) -> Result<([u8; 32], u64), String> { + let (source_hash, _) = source.checksum_pass(&[])?; + let len = source.len; + // Pass 1: validate the whole snapshot and find paths whose rows are split. + let mut seen = HashSet::new(); + let mut split = HashSet::new(); + { + let mut reader = SeqReader::new(source, 0, len); + let header = semantic_header(&mut reader, producer)?; + semantic_metadata(&mut reader)?; + let mut current: Option<[u8; 32]> = None; + for _ in 0..header.entry_count { + let (path, _) = semantic_entry(&mut reader, &header)?; + let digest = path_digest(&path); + if current != Some(digest) { + if !seen.insert(digest) { + split.insert(digest); + } + current = Some(digest); + } + } + if !reader.is_exhausted() { + return Err( + "trailing bytes after semantic snapshot (segments or a torn tail are not repaired)" + .to_owned(), + ); + } + } + // Pass 2: convert one contiguous group of rows at a time. + let root = fs::canonicalize(root).map_err(|error| error.to_string())?; + let summary_budget = small_summary_budget(producer); + let mut reader = SeqReader::new(source, 0, len); + let header = semantic_header(&mut reader, producer)?; + let metadata = semantic_metadata(&mut reader)?; + let mut skipped = 0_u64; + let mut convert = |path: Vec, entries: Vec| -> Result<(), String> { + let digest = path_digest(&path); + let Some(content_hash) = metadata.get(&path).copied() else { + // Rows without metadata cannot prove they belong to this root. + skipped += 1; + return Ok(()); + }; + if split.contains(&digest) { + skipped += 1; + return Ok(()); + } + let file = super::LegacyFile { + path, + content_hash, + entries, + }; + match convert_semantic_file(&root, producer, summary_budget, file) { + Some(entry) => emit(entry), + None => { + skipped += 1; + Ok(()) + } + } + }; + let mut group: Option<(Vec, Vec)> = None; + for _ in 0..header.entry_count { + let (path, entry) = semantic_entry(&mut reader, &header)?; + match &mut group { + Some((current, rows)) if *current == path => rows.push(entry), + _ => { + if let Some((path, rows)) = group.take() { + convert(path, rows)?; + } + group = Some((path, vec![entry])); + } + } + } + if let Some((path, rows)) = group.take() { + convert(path, rows)?; + } + // A file the snapshot lists without rows is imported when this release + // also chunks it into no rows, as the v2 import does. + for path in metadata.keys() { + if !seen.contains(&path_digest(path)) { + convert(path.clone(), Vec::new())?; + } + } + Ok((source_hash, skipped)) +} + +// --------------------------------------------------------------------------- +// Staged bundles + +/// Streams converted entries to a staging file. The final name is derived +/// from the hash of the whole file, so a resumed attempt finds exactly the +/// bytes its ledger row recorded. +struct StagedWriter { + file: BufWriter, + temporary: PathBuf, + body: blake3::Hasher, + whole: blake3::Hasher, + count: u64, +} + +impl StagedWriter { + fn create(dir: &Path, artifact: LegacyArtifact) -> io::Result { + static SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + crate::private_storage::create_dir_all(dir)?; + let temporary = dir.join(format!( + "{}.tmp.{}.{}.{}", + artifact.as_str(), + std::process::id(), + now_ms(), + SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + )); + let file = crate::private_storage::options() + .create_new(true) + .write(true) + .open(&temporary)?; + let mut writer = Self { + file: BufWriter::new(file), + temporary, + body: blake3::Hasher::new(), + whole: blake3::Hasher::new(), + count: 0, + }; + writer.write(STAGED_MAGIC)?; + writer.write(&[artifact as u8])?; + Ok(writer) + } + + fn write(&mut self, bytes: &[u8]) -> io::Result<()> { + self.body.update(bytes); + self.whole.update(bytes); + self.file.write_all(bytes) + } + + fn entry(&mut self, entry: &BundleEntry) -> io::Result<()> { + let path = entry.rel_path.as_bytes(); + let payload_len = u32::try_from(entry.payload.len()) + .ok() + .filter(|len| *len as usize <= MAX_FIELD_BYTES) + .ok_or_else(|| io::Error::other("staged payload too large"))?; + self.write(&[1])?; + self.write(&(path.len() as u32).to_le_bytes())?; + self.write(path)?; + self.write(entry.content.as_bytes())?; + self.write(&entry.size.to_le_bytes())?; + self.write(&entry.key)?; + self.write(&payload_len.to_le_bytes())?; + self.write(&entry.payload)?; + self.count += 1; + Ok(()) + } + + /// Writes the trailer, makes the file durable under its final name and + /// returns that name and the file's hash. + fn finish(mut self, dir: &Path, artifact: LegacyArtifact) -> io::Result<(PathBuf, [u8; 32])> { + let digest = *self.body.finalize().as_bytes(); + self.write(&[0])?; + let count = self.count.to_le_bytes(); + self.write(&count)?; + self.write(&digest)?; + let hash = *self.whole.finalize().as_bytes(); + let file = self.file.into_inner().map_err(|error| error.into_error())?; + file.sync_all()?; + drop(file); + let path = staged_path(dir, artifact, &hash); + crate::fs_lock::rename_over(&self.temporary, &path)?; + crate::fs_lock::sync_parent(&path); + Ok((path, hash)) + } + + fn abandon(self) { + let temporary = self.temporary.clone(); + drop(self); + let _ = fs::remove_file(temporary); + } +} + +pub(super) fn staged_path(dir: &Path, artifact: LegacyArtifact, hash: &[u8; 32]) -> PathBuf { + dir.join(format!( + "{}-{}-{}.bundle", + artifact.as_str(), + artifact.plane().as_str(), + hash_prefix(hash) + )) +} + +/// Streams the entries of a staged bundle to `visit`, then checks its +/// trailer, entry count and whole-file hash against `expected`. `visit` may +/// see entries of a bundle that later fails these checks, so a caller that +/// must not act on such entries runs a checking pass first. +fn read_staged( + path: &Path, + artifact: LegacyArtifact, + expected: &[u8; 32], + visit: &mut dyn FnMut(BundleEntry) -> Result<(), String>, +) -> Result { + struct Hashing { + inner: R, + body: blake3::Hasher, + whole: blake3::Hasher, + in_body: bool, + } + impl Hashing { + fn take(&mut self, out: &mut [u8]) -> Result<(), String> { + self.inner + .read_exact(out) + .map_err(|error| format!("staged bundle unreadable: {error}"))?; + if self.in_body { + self.body.update(out); + } + self.whole.update(out); + Ok(()) + } + fn u32(&mut self) -> Result { + let mut out = [0_u8; 4]; + self.take(&mut out)?; + Ok(u32::from_le_bytes(out)) + } + fn u64(&mut self) -> Result { + let mut out = [0_u8; 8]; + self.take(&mut out)?; + Ok(u64::from_le_bytes(out)) + } + fn array32(&mut self) -> Result<[u8; 32], String> { + let mut out = [0_u8; 32]; + self.take(&mut out)?; + Ok(out) + } + fn vec(&mut self, len: usize, cap: usize) -> Result, String> { + if len > cap { + return Err("staged bundle field exceeds its bound".to_owned()); + } + let mut out = vec![0_u8; len]; + self.take(&mut out)?; + Ok(out) + } + } + let file = + fs::File::open(path).map_err(|error| format!("staged bundle unreadable: {error}"))?; + let mut reader = Hashing { + inner: BufReader::with_capacity(READ_CHUNK, file), + body: blake3::Hasher::new(), + whole: blake3::Hasher::new(), + in_body: true, + }; + let mut magic = [0_u8; 9]; + reader.take(&mut magic)?; + if &magic[..8] != STAGED_MAGIC || magic[8] != artifact as u8 { + return Err("staged bundle has another format or artifact".to_owned()); + } + let mut count = 0_u64; + loop { + let mut marker = [0_u8; 1]; + // The end marker is the first trailer byte: it is not part of the + // body digest the writer recorded. + reader.in_body = false; + reader.take(&mut marker)?; + match marker[0] { + 0 => break, + 1 => { + reader.body.update(&marker); + reader.in_body = true; + } + other => return Err(format!("staged bundle marker {other}")), + } + let path_len = reader.u32()? as usize; + let rel_path = RelPath::new(reader.vec(path_len, MAX_PATH_BYTES)?) + .map_err(|error| error.to_string())?; + let content = ContentHash::from_bytes(reader.array32()?); + let size = reader.u64()?; + let key = reader.array32()?; + let payload_len = reader.u32()? as usize; + let payload = reader.vec(payload_len, MAX_FIELD_BYTES)?; + count += 1; + visit(BundleEntry { + rel_path, + content, + size, + key, + payload, + })?; + } + let body_digest = *reader.body.finalize().as_bytes(); + let recorded_count = reader.u64()?; + let recorded_digest = reader.array32()?; + if recorded_count != count || recorded_digest != body_digest { + return Err("staged bundle trailer does not match its entries".to_owned()); + } + let mut extra = [0_u8; 1]; + if reader + .inner + .read(&mut extra) + .map_err(|error| error.to_string())? + != 0 + { + return Err("staged bundle has trailing bytes".to_owned()); + } + if reader.whole.finalize().as_bytes() != expected { + return Err("staged bundle does not match its recorded hash".to_owned()); + } + Ok(count) +} + +/// The result of converting one legacy artifact to a staged bundle. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Staged { + pub path: PathBuf, + pub hash: [u8; 32], + pub source_hash: [u8; 32], + pub source_len: u64, + pub entries: u64, + /// Files the artifact lists that were left for the plane to build. + pub skipped: u64, + pub stats: ReadStats, +} + +/// What a staging attempt needs besides the artifact. +pub(super) struct StageInput<'a> { + pub storage: &'a Path, + pub legacy_key: &'a str, + pub root: &'a Path, + pub dir: &'a Path, + pub trigram: &'a TrigramPolicy, + pub semantic: Option<&'a SemanticProducer>, + pub working_set: usize, +} + +/// Converts `artifact` into a durable staged bundle. `Ok(Err(reason))` is a +/// rejection of the artifact: absent, incompatible, malformed, or changed +/// while it was read. `Err` is a failure to write the staging file. +pub(super) fn stage( + artifact: LegacyArtifact, + input: &StageInput<'_>, +) -> V3Result> { + let mut source = match Source::open(&artifact.source_path(input.storage, input.legacy_key)) { + Ok(source) => source, + Err(reason) => return Ok(Err(reason)), + }; + let mut writer = StagedWriter::create(input.dir, artifact)?; + let mut write_error = None; + let mut emit = |entry: BundleEntry| -> Result<(), String> { + writer.entry(&entry).map_err(|error| { + let message = error.to_string(); + write_error = Some(error); + message + }) + }; + let converted = match artifact { + LegacyArtifact::Trigram => { + convert_trigram_streaming(&mut source, input.trigram, input.working_set, &mut emit) + .map(|hash| (hash, 0)) + } + LegacyArtifact::Semantic => match input.semantic { + Some(producer) => { + convert_semantic_streaming(&mut source, input.root, producer, &mut emit) + } + None => Err("the request carries no semantic producer".to_owned()), + }, + LegacyArtifact::Symbols => { + super::v3_symbols::convert_symbols_streaming(&mut source, input.root, &mut emit) + } + }; + if let Some(error) = write_error { + writer.abandon(); + return Err(error.into()); + } + let (source_hash, skipped) = match converted { + Ok(converted) => converted, + Err(reason) => { + writer.abandon(); + return Ok(Err(reason)); + } + }; + if !source.unchanged() { + writer.abandon(); + return Ok(Err( + "the legacy artifact changed during the import".to_owned() + )); + } + let entries = writer.count; + let (path, hash) = writer.finish(input.dir, artifact)?; + Ok(Ok(Staged { + path, + hash, + source_hash, + source_len: source.len, + entries, + skipped, + stats: source.stats, + })) +} + +/// Re-reads a staged bundle and re-derives what can be derived from each +/// entry: a trigram entry's key from its content and the policy, and that +/// every payload decodes for its plane and producer. +pub(super) fn check_staged( + artifact: LegacyArtifact, + path: &Path, + hash: &[u8; 32], + input: &StageInput<'_>, +) -> Result { + read_staged(path, artifact, hash, &mut |entry| match artifact { + LegacyArtifact::Trigram => { + TrigramPayload::decode(&entry.payload).map_err(|error| error.to_string())?; + let key = TrigramKey { + content: entry.content, + policy: *input.trigram, + } + .family_key(); + (key.as_bytes() == &entry.key) + .then_some(()) + .ok_or_else(|| "staged trigram key differs from its content".to_owned()) + } + LegacyArtifact::Semantic => { + let producer = input + .semantic + .ok_or("the request carries no semantic producer")?; + let relative = crate::views::segment_store::rel_path_to_os(&entry.rel_path) + .map_err(|error| error.to_string())?; + crate::semantic_index::SemanticVectors::decode_view_payload( + &entry.payload, + &relative, + &crate::semantic_index::ViewPayloadProducer { + chunker_version: &producer.chunker_version, + template_version: &producer.template_version, + model_fingerprint: &producer.model_fingerprint, + }, + ) + .map(|_| ()) + } + LegacyArtifact::Symbols => super::v3_symbols::check_staged_entry(&entry), + }) +} + +/// Re-hashes a legacy source in one bounded sequential pass. +pub(super) fn source_identity( + artifact: LegacyArtifact, + storage: &Path, + legacy_key: &str, +) -> Result<([u8; 32], u64), String> { + let mut source = Source::open(&artifact.source_path(storage, legacy_key))?; + let (hash, _) = source.checksum_pass(&[])?; + Ok((hash, source.len)) +} + +// --------------------------------------------------------------------------- +// Registration into v3 + +/// What putting a staged bundle into its plane did. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct Registered { + pub inserted: u64, + /// Rows already present with an equal payload; only touched. + pub reused: u64, + /// Rows already present with another payload for the same key. The stored + /// row is kept: semantic keys address the input and producer, not the + /// model's output floats, and a stored trigram row came from the same bytes. + pub kept_existing: u64, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum RegisterOutcome { + Registered(Registered), + /// A reservation was refused for lack of storage. The plane serves what + /// v3 already holds; nothing falls back to the legacy artifact. + StorageGap(String), +} + +fn storage_gap(error: &v3::Error) -> Option { + match error { + v3::Error::Refused(reason) if STORAGE_GAP_REFUSALS.contains(reason) => { + Some(format!("storage_gap:{reason}")) + } + _ => None, + } +} + +/// Puts every entry of a checked staged bundle into the destination plane, +/// in batches of at most [`REGISTER_BATCH_BYTES`] payload bytes, each one +/// v3 publication under its own reservation. Every put is idempotent, so a +/// resumed attempt repeats the batches a crash interrupted. +pub(super) fn register_staged( + storage: &Path, + family: &str, + artifact: LegacyArtifact, + path: &Path, + hash: &[u8; 32], + after_batch: &mut dyn FnMut(u64), +) -> V3Result> { + let plane = artifact.plane(); + let schema = plane.payload_schema(); + let mut totals = Registered::default(); + let mut batch: Vec<(FamilyKey, Vec)> = Vec::new(); + let mut batch_bytes = 0_u64; + let mut batches = 0_u64; + let mut failure: Option = None; + let mut gap: Option = None; + let mut flush = |batch: &mut Vec<(FamilyKey, Vec)>, + batch_bytes: &mut u64, + totals: &mut Registered| + -> Result<(), String> { + if batch.is_empty() { + return Ok(()); + } + let request = batch_bytes.saturating_mul(2).saturating_add(1 << 20); + let result = v3::publish(storage, family, request, None, |publication| { + let mut done = Registered::default(); + for (key, payload) in batch.iter() { + match publication.put_or_touch(key, payload, schema) { + Ok(true) => done.inserted += 1, + Ok(false) => done.reused += 1, + Err(v3::Error::Refused("payload_mismatch")) => { + publication.touch(&[*key])?; + done.kept_existing += 1; + } + Err(error) => return Err(error), + } + } + Ok(done) + }); + match result { + Ok(done) => { + totals.inserted += done.inserted; + totals.reused += done.reused; + totals.kept_existing += done.kept_existing; + batches += 1; + after_batch(batches); + batch.clear(); + *batch_bytes = 0; + Ok(()) + } + Err(error) => { + let message = error.to_string(); + match storage_gap(&error) { + Some(name) => gap = Some(name), + None => failure = Some(error.into()), + } + Err(message) + } + } + }; + let read = read_staged(path, artifact, hash, &mut |entry| { + batch_bytes += entry.payload.len() as u64; + batch.push((FamilyKey::new(plane, entry.key), entry.payload)); + if batch_bytes >= REGISTER_BATCH_BYTES { + flush(&mut batch, &mut batch_bytes, &mut totals)?; + } + Ok(()) + }); + let read = read.and_then(|_| flush(&mut batch, &mut batch_bytes, &mut totals)); + if let Some(name) = gap { + return Ok(Ok(RegisterOutcome::StorageGap(name))); + } + if let Some(error) = failure { + return Err(error); + } + Ok(read.map(|_| RegisterOutcome::Registered(totals))) +} + +#[cfg(test)] +#[path = "v3_import_tests.rs"] +pub(crate) mod tests; diff --git a/crates/aft/src/migration/v3_import_tests.rs b/crates/aft/src/migration/v3_import_tests.rs new file mode 100644 index 0000000000..3f96c8219a --- /dev/null +++ b/crates/aft/src/migration/v3_import_tests.rs @@ -0,0 +1,464 @@ +use std::path::{Path, PathBuf}; + +use super::*; +use crate::semantic_index::{EmbedTextCaps, SemanticIndex, SemanticIndexFingerprint}; + +pub(crate) const POLICY: TrigramPolicy = TrigramPolicy { + max_file_size: 1 << 20, +}; +pub(crate) const FAMILY: &str = "family"; +pub(crate) const KEY: &str = "family"; + +pub(crate) fn write_tree(root: &Path, files: &[(&str, &[u8])]) { + for (path, bytes) in files { + let path = root.join(path); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(path, bytes).unwrap(); + } +} + +/// A checkout with a few source files, prose and a binary file. +pub(crate) fn checkout(dir: &Path) -> PathBuf { + let root = dir.join("checkout"); + fs::create_dir_all(&root).unwrap(); + write_tree( + &root, + &[ + ( + "src/alpha.rs", + b"pub fn alpha_total(values: &[u32]) -> u32 {\n values.iter().sum()\n}\n", + ), + ( + "src/beta.rs", + b"pub struct BetaCache {\n entries: Vec,\n}\n", + ), + ( + "notes/readme.md", + b"# Notes\n\nSome prose about the alpha total.\n", + ), + ("assets/blob.bin", &[0, 159, 146, 150, 0, 1, 2, 3, 0, 0]), + ], + ); + fs::canonicalize(&root).unwrap() +} + +/// Adds `count` generated source files, so an artifact is many times larger +/// than a small working set. +pub(crate) fn add_generated(root: &Path, count: usize) { + for index in 0..count { + let body = (0..20) + .map(|line| { + format!("pub fn generated_{index}_{line}(value: u64) -> u64 {{ value ^ {line} }}\n") + }) + .collect::(); + write_tree(root, &[(&format!("gen/file_{index}.rs"), body.as_bytes())]); + } +} + +/// Writes the previous release's trigram cache for `root` where the importer +/// reads it, and returns its bytes. +pub(crate) fn legacy_trigram(storage: &Path, root: &Path) -> Vec { + let dir = storage.join("index").join(KEY); + let mut index = crate::search_index::SearchIndex::build_with_limit(root, POLICY.max_file_size); + assert!(index.write_to_disk(&dir, None), "legacy cache written"); + fs::read(dir.join("cache.bin")).unwrap() +} + +pub(crate) fn deterministic(text: &str) -> Vec { + blake3::hash(text.as_bytes()).as_bytes()[..16] + .iter() + .map(|byte| (f32::from(*byte) - 127.5) / 127.5) + .collect() +} + +pub(crate) fn fingerprint() -> SemanticIndexFingerprint { + SemanticIndexFingerprint { + backend: "test".to_owned(), + model: "deterministic".to_owned(), + base_url: "none".to_owned(), + dimension: 16, + chunking_version: 2, + embed_text_caps: EmbedTextCaps::default(), + ..SemanticIndexFingerprint::default() + } +} + +pub(crate) fn producer() -> SemanticProducer { + let fingerprint = fingerprint(); + SemanticProducer::current(fingerprint.as_string(), fingerprint.embed_text_caps) +} + +/// Writes the previous release's `semantic.bin` for `root` where the +/// importer reads it, and returns its bytes. +pub(crate) fn legacy_semantic(storage: &Path, root: &Path) -> Vec { + let files = ["src/alpha.rs", "src/beta.rs", "notes/readme.md"] + .iter() + .map(|path| root.join(path)) + .collect::>(); + let mut index = SemanticIndex::build( + root, + &files, + &mut |texts: Vec| Ok(texts.iter().map(|text| deterministic(text)).collect()), + 64, + ) + .unwrap(); + index.set_fingerprint(fingerprint()); + let bytes = index.to_bytes(); + let path = super::super::per_checkout::legacy_semantic_path(storage, KEY); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(&path, &bytes).unwrap(); + bytes +} + +fn sorted(mut entries: Vec) -> Vec { + entries.sort_by(|left, right| left.rel_path.cmp(&right.rel_path)); + entries +} + +fn stream_trigram( + path: &Path, + working_set: usize, +) -> Result<(Vec, ReadStats), String> { + let mut source = Source::open(path)?; + let mut entries = Vec::new(); + convert_trigram_streaming(&mut source, &POLICY, working_set, &mut |entry| { + entries.push(entry); + Ok(()) + })?; + Ok((entries, source.stats)) +} + +pub(super) fn input<'a>( + storage: &'a Path, + root: &'a Path, + dir: &'a Path, + semantic: Option<&'a SemanticProducer>, +) -> StageInput<'a> { + StageInput { + storage, + legacy_key: KEY, + root, + dir, + trigram: &POLICY, + semantic, + working_set: DEFAULT_WORKING_SET, + } +} + +/// Every file below `dir`, with its bytes, for byte-identity checks. +pub(crate) fn snapshot_tree(dir: &Path) -> BTreeMap> { + let mut files = BTreeMap::new(); + let mut stack = vec![dir.to_path_buf()]; + while let Some(dir) = stack.pop() { + let Ok(entries) = fs::read_dir(&dir) else { + continue; + }; + for entry in entries { + let path = entry.unwrap().path(); + if path.is_dir() { + stack.push(path); + } else { + files.insert(path.clone(), fs::read(&path).unwrap()); + } + } + } + files +} + +/// The streaming inversion equals the whole-file v2 conversion even when its +/// working set is far smaller than the artifact, and it never holds more +/// than the budget (or one file's postings) of decoded postings at once. +#[test] +fn streaming_trigram_conversion_is_bounded_and_equals_the_whole_file_conversion() { + let dir = tempfile::tempdir().unwrap(); + let root = checkout(dir.path()); + add_generated(&root, 150); + let storage = dir.path().join("storage"); + let bytes = legacy_trigram(&storage, &root); + let whole = super::super::per_checkout::convert_trigram(&bytes, &POLICY).unwrap(); + let path = storage.join("index").join(KEY).join("cache.bin"); + + const BUDGET: usize = 2 * 1024; + let (entries, stats) = stream_trigram(&path, BUDGET).unwrap(); + assert_eq!(sorted(entries), whole.entries); + let largest_file = whole + .entries + .iter() + .map(|entry| { + TrigramPayload::decode(&entry.payload) + .unwrap() + .records + .len() as u64 + * std::mem::size_of::() as u64 + }) + .max() + .unwrap(); + assert!( + bytes.len() > 20 * BUDGET, + "the artifact ({} bytes) must dwarf the working set", + bytes.len() + ); + assert!( + stats.peak_working_set <= (BUDGET as u64).max(largest_file), + "held {} bytes of postings with a {BUDGET}-byte budget", + stats.peak_working_set + ); + assert!(stats.posting_passes > 3, "{stats:?}"); + assert!(stats.largest_read <= READ_CHUNK, "{stats:?}"); + + // With the default budget the same artifact needs one counting pass and + // one collecting pass. + let (entries, stats) = stream_trigram(&path, DEFAULT_WORKING_SET).unwrap(); + assert_eq!(sorted(entries), whole.entries); + assert_eq!(stats.posting_passes, 2); +} + +/// Anything not provably equal to a cold build is rejected, never relabelled. +#[test] +fn streaming_trigram_conversion_rejects_incompatible_and_corrupt_caches() { + let dir = tempfile::tempdir().unwrap(); + let root = checkout(dir.path()); + let storage = dir.path().join("storage"); + let bytes = legacy_trigram(&storage, &root); + let path = dir.path().join("variant.bin"); + let run = |bytes: &[u8], policy: &TrigramPolicy| { + fs::write(&path, bytes).unwrap(); + let mut source = Source::open(&path).unwrap(); + convert_trigram_streaming(&mut source, policy, DEFAULT_WORKING_SET, &mut |_| Ok(())) + .map(|_| ()) + }; + assert!(run( + &bytes, + &TrigramPolicy { + max_file_size: 4096 + } + ) + .unwrap_err() + .contains("max_file_size")); + let mut version = bytes.clone(); + version[4] ^= 0xff; + assert!(run(&version, &POLICY).unwrap_err().contains("version")); + let mut flipped = bytes.clone(); + let middle = 40 + (bytes.len() - 40) / 3; + flipped[middle] ^= 0x01; + assert!(run(&flipped, &POLICY).unwrap_err().contains("checksum")); + assert!(run(&bytes[..bytes.len() - 7], &POLICY).is_err()); + assert!(run(&bytes, &POLICY).is_ok()); +} + +#[test] +fn streaming_semantic_conversion_equals_the_v2_conversion() { + let dir = tempfile::tempdir().unwrap(); + let root = checkout(dir.path()); + let storage = dir.path().join("storage"); + let snapshot = legacy_semantic(&storage, &root); + fs::write(root.join("src/beta.rs"), b"pub struct BetaCache;\n").unwrap(); + let producer = producer(); + let (v2, v2_skipped) = + super::super::per_checkout::convert_semantic(&snapshot, &root, &producer).unwrap(); + let mut source = Source::open(&super::super::per_checkout::legacy_semantic_path( + &storage, KEY, + )) + .unwrap(); + let mut entries = Vec::new(); + let (hash, skipped) = convert_semantic_streaming(&mut source, &root, &producer, &mut |entry| { + entries.push(entry); + Ok(()) + }) + .unwrap(); + assert!(!v2.entries.is_empty()); + assert_eq!(sorted(entries), v2.entries); + assert_eq!(skipped, v2_skipped as u64); + assert_eq!(hash, *blake3::hash(&snapshot).as_bytes()); + assert!(source.stats.largest_read <= READ_CHUNK); +} + +/// The semantic decoder never repairs: appended bytes (a segment or a torn +/// tail) reject the artifact, whose bytes stay exactly as they were. +#[test] +fn semantic_trailing_bytes_are_refused_without_repair() { + let dir = tempfile::tempdir().unwrap(); + let root = checkout(dir.path()); + let storage = dir.path().join("storage"); + let mut snapshot = legacy_semantic(&storage, &root); + snapshot.extend_from_slice(b"torn tail"); + let path = super::super::per_checkout::legacy_semantic_path(&storage, KEY); + fs::write(&path, &snapshot).unwrap(); + let staging = dir.path().join("staging"); + let producer = producer(); + let rejected = stage( + LegacyArtifact::Semantic, + &input(&storage, &root, &staging, Some(&producer)), + ) + .unwrap() + .unwrap_err(); + assert!(rejected.contains("trailing bytes"), "{rejected}"); + assert_eq!(fs::read(&path).unwrap(), snapshot); + // Nothing is left in staging after a rejection. + assert!(snapshot_tree(&staging).is_empty()); +} + +/// Imported postings are the rows a cold v3 build of the same bytes would +/// store, under the same keys, and producing them reads no checkout file: +/// the import retokenizes nothing. It writes only under `blobs/v3` (and its +/// staging directory) and leaves the legacy cache byte-identical. +#[test] +fn imported_trigram_rows_equal_a_cold_build_and_read_no_checkout_file() { + let _serial = crate::blob_store::v3::serial_for_test(); + let dir = tempfile::tempdir().unwrap(); + let root = checkout(dir.path()); + let storage = dir.path().join("storage"); + let bytes = legacy_trigram(&storage, &root); + let sources = snapshot_tree(&root) + .into_iter() + .map(|(path, bytes)| (path.strip_prefix(&root).unwrap().to_path_buf(), bytes)) + .collect::>(); + // The checkout is gone: every row must come from the legacy postings. + fs::remove_dir_all(&root).unwrap(); + let before = snapshot_tree(&storage); + let staging = staging_dir(&storage, FAMILY, KEY); + let input = input(&storage, &root, &staging, None); + let staged = stage(LegacyArtifact::Trigram, &input).unwrap().unwrap(); + assert_eq!(staged.source_hash, *blake3::hash(&bytes).as_bytes()); + check_staged(LegacyArtifact::Trigram, &staged.path, &staged.hash, &input).unwrap(); + let mut entries = Vec::new(); + read_staged( + &staged.path, + LegacyArtifact::Trigram, + &staged.hash, + &mut |entry| { + entries.push(entry); + Ok(()) + }, + ) + .unwrap(); + // Exactly the files a cold build indexes are imported: every checkout + // file whose trigram extraction is indexed (the binary file is not), and + // no other path. + let imported = entries + .iter() + .map(|entry| crate::views::segment_store::rel_path_to_os(&entry.rel_path).unwrap()) + .collect::>(); + let eligible = sources + .iter() + .filter(|(_, bytes)| { + TrigramPayload::extract(bytes, &POLICY).flag + == crate::views::segment_store::TrigramFlag::Indexed + }) + .map(|(path, _)| path.clone()) + .collect::>(); + assert_eq!(imported, eligible); + assert_eq!( + eligible, + ["notes/readme.md", "src/alpha.rs", "src/beta.rs"] + .into_iter() + .map(PathBuf::from) + .collect() + ); + assert_eq!(entries.len(), imported.len(), "a path was imported twice"); + let outcome = register_staged( + &storage, + FAMILY, + LegacyArtifact::Trigram, + &staged.path, + &staged.hash, + &mut |_| {}, + ) + .unwrap() + .unwrap(); + assert_eq!( + outcome, + RegisterOutcome::Registered(Registered { + inserted: entries.len() as u64, + ..Registered::default() + }) + ); + let reader = v3::PlaneReader::open_existing(&storage, FAMILY, FamilyPlane::Trigram).unwrap(); + for entry in &entries { + let relative = crate::views::segment_store::rel_path_to_os(&entry.rel_path).unwrap(); + let source = &sources[&relative]; + let key = TrigramKey { + content: ContentHash::of(source), + policy: POLICY, + } + .family_key(); + assert_eq!(key.as_bytes(), &entry.key, "key of {}", relative.display()); + assert_eq!( + reader.get(&key).unwrap(), + Some(TrigramPayload::extract(source, &POLICY).encode()), + "row of {}", + relative.display() + ); + } + // Only v3 rows and staging were added; legacy bytes are untouched and no + // v1/v2 store, view or ledger was created. + let after = snapshot_tree(&storage); + for (path, bytes) in &before { + assert_eq!(after.get(path), Some(bytes), "{} changed", path.display()); + } + for path in after.keys().filter(|path| !before.contains_key(*path)) { + let relative = path.strip_prefix(&storage).unwrap(); + // The floor file is the v3 publication boundary's reader floor. + assert!( + relative.starts_with("blobs/v3") + || relative.starts_with("migration/v3") + || relative == Path::new("reader-floor.json"), + "import wrote {}", + relative.display() + ); + } + assert!(after + .keys() + .all(|path| !path.starts_with(storage.join("blobs/v2")))); + + // Registering again is idempotent: every row is reused. + let again = register_staged( + &storage, + FAMILY, + LegacyArtifact::Trigram, + &staged.path, + &staged.hash, + &mut |_| {}, + ) + .unwrap() + .unwrap(); + assert_eq!( + again, + RegisterOutcome::Registered(Registered { + reused: entries.len() as u64, + ..Registered::default() + }) + ); +} + +/// A reservation refused for lack of space is reported as the plane's named +/// storage gap: no row is written and nothing reads the legacy artifact in +/// its place. +#[test] +fn a_refused_reservation_is_a_named_storage_gap() { + let _serial = crate::blob_store::v3::serial_for_test(); + let dir = tempfile::tempdir().unwrap(); + let root = checkout(dir.path()); + let storage = dir.path().join("storage"); + legacy_trigram(&storage, &root); + let staging = staging_dir(&storage, FAMILY, KEY); + let input = input(&storage, &root, &staging, None); + let staged = stage(LegacyArtifact::Trigram, &input).unwrap().unwrap(); + v3::set_free_space_for_test(1 << 20, 1 << 40); + let outcome = register_staged( + &storage, + FAMILY, + LegacyArtifact::Trigram, + &staged.path, + &staged.hash, + &mut |_| {}, + ); + v3::set_free_space_for_test(u64::MAX, u64::MAX / 2); + assert_eq!( + outcome.unwrap().unwrap(), + RegisterOutcome::StorageGap("storage_gap:device_free_space_floor".to_owned()) + ); + assert!(!v3::plane_path(&storage, FAMILY, FamilyPlane::Trigram) + .unwrap() + .exists()); +} diff --git a/crates/aft/src/migration/v3_ledger.rs b/crates/aft/src/migration/v3_ledger.rs new file mode 100644 index 0000000000..f358cedc52 --- /dev/null +++ b/crates/aft/src/migration/v3_ledger.rs @@ -0,0 +1,1030 @@ +//! The v3 import ledger (`blobs/v3//imports.sqlite`) and the driver +//! that runs one legacy key's import through it. +//! +//! One row per legacy key, artifact, producer and destination plane, so an +//! earlier completion of one artifact or producer never stands for another: +//! a semantic-only completion leaves the trigram and symbol rows to run, and a +//! changed producer starts a new row. Claims follow the v2 import ledger's +//! owner-token protocol (`per_checkout::ClaimOwner`): a live owner makes other +//! callers back off, a dead or released owner's row is taken over with +//! `attempt + 1` and resumes from its durable state. +//! +//! The file is created, and every row transition written, inside a +//! `v3::publish` call, so it exists only after the v3 reader floor is raised +//! and each write holds the storage root's reservation lock. Readers that +//! must not write under the storage root copy it first (see [`read_rows`]). + +use std::collections::{BTreeMap, HashMap}; +use std::fs; +use std::io; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex, MutexGuard, OnceLock, Weak}; + +use rusqlite::{params, OptionalExtension, TransactionBehavior}; + +use super::per_checkout::{now_ms, validate_legacy_key, ActiveClaim, ClaimOwner, ImportState}; +use super::v3_import::{ + check_staged, register_staged, source_identity, stage, staged_path, staging_dir, + LegacyArtifact, ReadStats, RegisterOutcome, Registered, StageInput, V3ImportError, V3Result, + DEFAULT_WORKING_SET, LEDGER_APPLICATION_ID, LEDGER_FORMAT_VERSION, +}; +use crate::blob_store::v2::{FamilyPlane, TrigramPolicy}; +use crate::blob_store::v3; +use crate::db::lifecycle::{SqliteStore, TrackedConnection}; +use crate::views::semantic::SemanticProducer; + +const LEDGER_FILE: &str = "imports.sqlite"; +const LEDGER_SCHEMA_VERSION: i64 = 1; +/// Each ledger write is one v3 publication; this covers its WAL frames and +/// the registry transaction, independent of the import's size. +const LEDGER_RESERVATION: u64 = 64 * 1024; +const STORE: SqliteStore = SqliteStore::Unmapped("v3_import_ledger"); + +const SCHEMA: &str = "CREATE TABLE format_meta (singleton INTEGER PRIMARY KEY CHECK(singleton=1), + format_version INTEGER NOT NULL, schema_version INTEGER NOT NULL, schema_text TEXT NOT NULL); +CREATE TABLE imports (legacy_key TEXT NOT NULL, artifact TEXT NOT NULL, + producer TEXT NOT NULL, plane TEXT NOT NULL, + state TEXT NOT NULL CHECK(state IN ('claimed','staged','validated','registered','done','rejected')), + attempt INTEGER NOT NULL CHECK(attempt>=1), owner_pid INTEGER, owner_start INTEGER, owner_token TEXT, + source_hash BLOB, source_len INTEGER, staged_hash BLOB, + imported INTEGER NOT NULL DEFAULT 0, skipped INTEGER NOT NULL DEFAULT 0, + reason TEXT, updated_ms INTEGER NOT NULL, completed_ms INTEGER, + PRIMARY KEY(legacy_key, artifact, producer, plane)); +"; + +/// Identifies one ledger row. +#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)] +pub struct RowKey { + pub legacy_key: String, + pub artifact: LegacyArtifact, + /// The trigram policy fingerprint, semantic producer id or symbol + /// producer id the row's payloads were converted for. + pub producer: String, + pub plane: FamilyPlane, +} + +impl RowKey { + fn describe(&self) -> String { + format!( + "{}/{}/{}", + self.legacy_key, + self.artifact.as_str(), + self.plane.as_str() + ) + } +} + +/// One row of the v3 import ledger. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct V3ImportRow { + pub key: RowKey, + pub state: ImportState, + pub attempt: u64, + pub owner: Option, + pub source_hash: Option<[u8; 32]>, + pub source_len: Option, + pub staged_hash: Option<[u8; 32]>, + /// Rows put into (or found in) the plane. + pub imported: u64, + /// Files of the artifact left for the plane to build. + pub skipped: u64, + /// Why the row was rejected, or the storage gap that stopped it. + pub reason: Option, + pub updated_ms: u64, + pub completed_ms: Option, +} + +fn lock(mutex: &Mutex) -> MutexGuard<'_, T> { + mutex + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) +} + +type SharedConnection = Arc>; + +/// Every handle of a process shares one connection per ledger: a second +/// descriptor on the same database would drop the process's POSIX locks on +/// it when closed. +fn open_connections() -> &'static Mutex>>> { + static OPEN: OnceLock>>>> = + OnceLock::new(); + OPEN.get_or_init(|| Mutex::new(HashMap::new())) +} + +fn check_format(connection: &rusqlite::Connection) -> V3Result<()> { + let application_id: i64 = + connection.query_row("PRAGMA application_id", [], |row| row.get(0))?; + if application_id != LEDGER_APPLICATION_ID { + return Err(V3ImportError::Corrupt(format!( + "import ledger application_id {application_id:#x}" + ))); + } + let meta: Option<(i64, i64, String)> = connection + .query_row( + "SELECT format_version, schema_version, schema_text FROM format_meta WHERE singleton=1", + [], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), + ) + .optional()?; + match meta { + Some((format, _, _)) if format > LEDGER_FORMAT_VERSION => { + Err(V3ImportError::NewerLedger(format)) + } + Some((_, schema, text)) if schema == LEDGER_SCHEMA_VERSION && text == SCHEMA => Ok(()), + Some(_) => Err(V3ImportError::Corrupt( + "import ledger schema differs from this build's".to_owned(), + )), + None => Err(V3ImportError::Corrupt( + "import ledger has no format row".to_owned(), + )), + } +} + +/// The ledger of one v3 family. +#[derive(Clone, Debug)] +pub struct V3Ledger { + storage: PathBuf, + family: String, + path: PathBuf, +} + +enum Claim { + Owned(V3ImportRow), + Terminal(V3ImportRow), + Busy(ClaimOwner), +} + +#[derive(Default)] +struct Advance { + source_hash: Option<[u8; 32]>, + source_len: Option, + staged_hash: Option<[u8; 32]>, + imported: Option, + skipped: Option, + reason: Option, +} + +const ROW_COLUMNS: &str = "legacy_key, artifact, producer, plane, state, attempt, owner_pid, + owner_start, owner_token, source_hash, source_len, staged_hash, imported, skipped, reason, + updated_ms, completed_ms"; + +fn decode_row(row: &rusqlite::Row<'_>) -> rusqlite::Result> { + let artifact: String = row.get(1)?; + let plane: String = row.get(3)?; + let state: String = row.get(4)?; + let hash = |index: usize| -> rusqlite::Result> { + let bytes: Option> = row.get(index)?; + Ok(bytes.and_then(|bytes| bytes.try_into().ok())) + }; + let owner = match ( + row.get::<_, Option>(6)?, + row.get::<_, Option>(7)?, + row.get::<_, Option>(8)?, + ) { + (Some(pid), Some(start), Some(token)) => Some(ClaimOwner { + pid: pid as u32, + start_time: start as u64, + token, + }), + _ => None, + }; + let (Some(artifact), Some(plane), Some(state)) = ( + LegacyArtifact::parse(&artifact), + FamilyPlane::V3_STORES + .into_iter() + .find(|candidate| candidate.as_str() == plane), + ImportState::parse(&state), + ) else { + return Ok(Err(V3ImportError::Corrupt(format!( + "unknown import row {artifact}/{plane}/{state}" + )))); + }; + Ok(Ok(V3ImportRow { + key: RowKey { + legacy_key: row.get(0)?, + artifact, + producer: row.get(2)?, + plane, + }, + state, + attempt: row.get::<_, i64>(5)? as u64, + owner, + source_hash: hash(9)?, + source_len: row.get::<_, Option>(10)?.map(|len| len as u64), + staged_hash: hash(11)?, + imported: row.get::<_, i64>(12)? as u64, + skipped: row.get::<_, i64>(13)? as u64, + reason: row.get(14)?, + updated_ms: row.get::<_, i64>(15)? as u64, + completed_ms: row.get::<_, Option>(16)?.map(|ms| ms as u64), + })) +} + +fn read_row(connection: &rusqlite::Connection, key: &RowKey) -> V3Result> { + connection + .query_row( + &format!( + "SELECT {ROW_COLUMNS} FROM imports + WHERE legacy_key=?1 AND artifact=?2 AND producer=?3 AND plane=?4" + ), + params![ + key.legacy_key, + key.artifact.as_str(), + key.producer, + key.plane.as_str() + ], + decode_row, + ) + .optional()? + .transpose() +} + +fn read_all(connection: &rusqlite::Connection) -> V3Result> { + let mut statement = connection.prepare(&format!( + "SELECT {ROW_COLUMNS} FROM imports ORDER BY legacy_key, artifact, producer, plane" + ))?; + let rows = statement + .query_map([], decode_row)? + .collect::, _>>()?; + rows.into_iter().collect() +} + +impl V3Ledger { + pub fn new(storage: &Path, family: &str) -> V3Result { + let dir = v3::family_dir(storage, family) + .map_err(|_| V3ImportError::InvalidKey(family.to_owned()))?; + Ok(Self { + storage: storage.to_path_buf(), + family: family.to_owned(), + path: dir.join(LEDGER_FILE), + }) + } + + pub fn path(&self) -> &Path { + &self.path + } + + /// The process's connection to the ledger. Without `create`, a missing + /// ledger is `None`; with it, the caller must be inside a v3 publication. + /// + /// The `open_connections` lock is held from before the open until the + /// connection is registered, the same lock [`read_rows`] holds across a + /// copy of the ledger's files, so an open never overlaps such a copy. + fn connection(&self, create: bool) -> V3Result> { + let mut open = lock(open_connections()); + open.retain(|_, connection| connection.strong_count() > 0); + if let Some(shared) = open.get(&self.path).and_then(Weak::upgrade) { + return Ok(Some(shared)); + } + if !create && !self.path.is_file() { + return Ok(None); + } + let exists = self.path.is_file(); + let mut connection = TrackedConnection::open(&self.path, STORE)?; + connection.busy_timeout(std::time::Duration::from_millis( + crate::blob_store::BUSY_TIMEOUT_MS, + ))?; + if !exists { + // A fresh file: select WAL, then create the schema and identity + // in one transaction, so a crash leaves either nothing usable to + // misread or the whole schema. + connection.pragma_update(None, "journal_mode", "WAL")?; + let tx = connection.transaction_with_behavior(TransactionBehavior::Immediate)?; + let empty: i64 = + tx.query_row("SELECT count(*) FROM sqlite_schema", [], |row| row.get(0))?; + if empty == 0 { + tx.execute_batch(SCHEMA)?; + tx.pragma_update(None, "application_id", LEDGER_APPLICATION_ID)?; + tx.execute( + "INSERT INTO format_meta (singleton, format_version, schema_version, schema_text) + VALUES (1, ?1, ?2, ?3)", + params![LEDGER_FORMAT_VERSION, LEDGER_SCHEMA_VERSION, SCHEMA], + )?; + } + tx.commit()?; + } + connection.pragma_update(None, "synchronous", "FULL")?; + check_format(&connection)?; + let shared = Arc::new(Mutex::new(connection)); + open.insert(self.path.clone(), Arc::downgrade(&shared)); + Ok(Some(shared)) + } + + /// Runs `body` in one IMMEDIATE ledger transaction inside a v3 + /// publication: the floor is raised before the ledger can be created and + /// the write holds the reservation lock. + fn write( + &self, + body: impl FnOnce(&rusqlite::Transaction<'_>) -> V3Result, + ) -> V3Result { + let mut outcome = None; + v3::publish( + &self.storage, + &self.family, + LEDGER_RESERVATION, + None, + |_publication| { + outcome = Some((|| { + let shared = self.connection(true)?.expect("created ledger"); + let mut connection = lock(&shared); + let tx = + connection.transaction_with_behavior(TransactionBehavior::Immediate)?; + let result = body(&tx)?; + tx.commit()?; + Ok(result) + })()); + Ok(()) + }, + )?; + outcome.expect("publication ran its body") + } + + /// Every row, read through this process's connection when it has one, + /// otherwise from a private copy (see [`read_rows`]). + pub fn rows(&self) -> V3Result> { + Ok(read_rows(&self.storage, &self.family)?.unwrap_or_default()) + } + + pub fn row(&self, key: &RowKey) -> V3Result> { + Ok(self.rows()?.into_iter().find(|row| &row.key == key)) + } + + fn claim(&self, key: &RowKey, owner: &ClaimOwner) -> V3Result { + self.write(|tx| { + let now = now_ms() as i64; + Ok(match read_row(tx, key)? { + None => { + tx.execute( + "INSERT INTO imports (legacy_key, artifact, producer, plane, state, attempt, + owner_pid, owner_start, owner_token, updated_ms) + VALUES (?1, ?2, ?3, ?4, 'claimed', 1, ?5, ?6, ?7, ?8)", + params![ + key.legacy_key, + key.artifact.as_str(), + key.producer, + key.plane.as_str(), + owner.pid, + owner.start_time as i64, + owner.token, + now + ], + )?; + Claim::Owned(read_row(tx, key)?.expect("inserted row")) + } + Some(row) if row.state.is_terminal() => Claim::Terminal(row), + Some(V3ImportRow { + owner: Some(holder), + .. + }) if holder.is_live() => Claim::Busy(holder), + Some(_) => { + // The previous owner is gone or released the row: resume + // where it stopped. Its state is durable, so resuming + // repeats at most the one step that was in flight. + tx.execute( + "UPDATE imports SET attempt = attempt + 1, owner_pid = ?5, + owner_start = ?6, owner_token = ?7, updated_ms = ?8 + WHERE legacy_key=?1 AND artifact=?2 AND producer=?3 AND plane=?4", + params![ + key.legacy_key, + key.artifact.as_str(), + key.producer, + key.plane.as_str(), + owner.pid, + owner.start_time as i64, + owner.token, + now + ], + )?; + Claim::Owned(read_row(tx, key)?.expect("claimed row")) + } + }) + }) + } + + /// Moves `key` from `from` to `to` if `owner` still holds it there. A + /// terminal state releases the owner and stamps completion. + fn advance( + &self, + key: &RowKey, + owner: &ClaimOwner, + from: ImportState, + to: ImportState, + update: Advance, + ) -> V3Result { + self.write(|tx| { + let now = now_ms() as i64; + let changed = tx.execute( + "UPDATE imports SET state = ?7, updated_ms = ?8, completed_ms = ?9, + source_hash = COALESCE(?10, source_hash), + source_len = COALESCE(?11, source_len), + staged_hash = COALESCE(?12, staged_hash), + imported = COALESCE(?13, imported), + skipped = COALESCE(?14, skipped), + reason = COALESCE(?15, reason), + owner_pid = CASE WHEN ?16 THEN NULL ELSE owner_pid END, + owner_start = CASE WHEN ?16 THEN NULL ELSE owner_start END, + owner_token = CASE WHEN ?16 THEN NULL ELSE owner_token END + WHERE legacy_key=?1 AND artifact=?2 AND producer=?3 AND plane=?4 + AND state = ?5 AND owner_token = ?6", + params![ + key.legacy_key, + key.artifact.as_str(), + key.producer, + key.plane.as_str(), + from.as_str(), + owner.token, + to.as_str(), + now, + to.is_terminal().then_some(now), + update.source_hash.as_ref().map(|hash| hash.to_vec()), + update.source_len.map(|len| len as i64), + update.staged_hash.as_ref().map(|hash| hash.to_vec()), + update.imported.map(|count| count as i64), + update.skipped.map(|count| count as i64), + update.reason, + to.is_terminal(), + ], + )?; + if changed != 1 { + return Err(V3ImportError::LostClaim(key.describe())); + } + Ok(read_row(tx, key)?.expect("advanced row")) + }) + } + + /// Releases `owner`'s claim without changing the row's state, recording + /// why it stopped. The next run takes the row over and resumes it. + fn release(&self, key: &RowKey, owner: &ClaimOwner, reason: &str) -> V3Result { + self.write(|tx| { + let changed = tx.execute( + "UPDATE imports SET owner_pid = NULL, owner_start = NULL, owner_token = NULL, + reason = ?6, updated_ms = ?7 + WHERE legacy_key=?1 AND artifact=?2 AND producer=?3 AND plane=?4 + AND owner_token = ?5", + params![ + key.legacy_key, + key.artifact.as_str(), + key.producer, + key.plane.as_str(), + owner.token, + reason, + now_ms() as i64 + ], + )?; + if changed != 1 { + return Err(V3ImportError::LostClaim(key.describe())); + } + Ok(read_row(tx, key)?.expect("released row")) + }) + } +} + +/// Reads every row of a family's ledger without writing a byte under the +/// storage root. A connection this process already holds is used as is. +/// Otherwise the database and its WAL are copied to a private temporary +/// directory and read there: an ordinary open would recover and checkpoint +/// a WAL a killed importer left behind. A copy taken while another process +/// writes can only be older or unreadable, and rows only move towards +/// completion, so neither makes an import look more complete than it is. +/// +/// The copy opens and closes descriptors on the ledger's files, and closing +/// any descriptor on a SQLite file drops every POSIX lock this process holds +/// on it. So the lookup and the whole copy run under the `open_connections` +/// lock, which every ledger open in this process takes before it opens and +/// keeps until it has registered its connection: an open and a copy can +/// never overlap, and a copy only runs while this process has no connection +/// to the ledger. +pub fn read_rows(storage: &Path, family: &str) -> V3Result>> { + let ledger = V3Ledger::new(storage, family)?; + let mut open = lock(open_connections()); + open.retain(|_, connection| connection.strong_count() > 0); + if let Some(shared) = open.get(&ledger.path).and_then(Weak::upgrade) { + drop(open); + return read_all(&lock(&shared)).map(Some); + } + if !ledger.path.is_file() { + return Ok(None); + } + let rows = read_sqlite_copy(&ledger.path, |connection| { + check_format(connection)?; + read_all(connection).map(Some) + }); + drop(open); + rows +} + +#[cfg(test)] +thread_local! { + /// Test hook: called on the copying thread at named points of + /// [`read_sqlite_copy`], so a test can hold a copy part-way through. + static COPY_HOOK: std::cell::RefCell>> = + const { std::cell::RefCell::new(None) }; +} + +#[cfg(test)] +fn copy_hook(stage: &'static str) { + COPY_HOOK.with(|hook| { + if let Some(hook) = hook.borrow_mut().as_mut() { + hook(stage); + } + }); +} + +/// Runs `body` on a private copy of the SQLite database at `path` (and its +/// WAL, if any), taken in a temporary directory outside the storage root. +/// Even a read-only open of a WAL database can create or recover sidecar +/// files beside it; reading a copy writes nothing under the storage root. +/// Copying opens and closes a descriptor on the file, which drops POSIX locks +/// this process holds on it, so callers use it only for databases this +/// process has no SQLite connection to and cannot open one during the copy +/// (as [`read_rows`] ensures for the ledger by holding `open_connections`, +/// and as holds for the `aft cache prune-legacy` command, which opens no v3 +/// registry connection). +pub(crate) fn read_sqlite_copy( + path: &Path, + body: impl FnOnce(&rusqlite::Connection) -> V3Result, +) -> V3Result { + static SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + let name = path + .file_name() + .ok_or_else(|| V3ImportError::Corrupt(format!("{} names no file", path.display())))?; + let copy_dir = std::env::temp_dir().join(format!( + "aft-v3-sqlite-read-{}-{}", + std::process::id(), + SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + )); + let result = (|| { + crate::private_storage::create_dir_all(©_dir)?; + let copy = copy_dir.join(name); + crate::private_storage::copy(path, ©)?; + #[cfg(test)] + copy_hook("database-copied"); + let mut wal_name = name.to_os_string(); + wal_name.push("-wal"); + match crate::private_storage::copy(path.with_file_name(&wal_name), copy_dir.join(&wal_name)) + { + Ok(_) => {} + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(error) => return Err(error.into()), + } + let connection = TrackedConnection::open(©, STORE)?; + let result = body(&connection); + drop(connection); + result + })(); + let _ = fs::remove_dir_all(©_dir); + #[cfg(test)] + copy_hook("finished"); + result +} + +// --------------------------------------------------------------------------- +// Legacy-key status for retention and pruning + +/// How far a legacy key's v3 import got, read without writing. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct LegacyImportStatus { + pub rows: Vec, + /// Some row has a claim whose owner is running: that process may be + /// reading the legacy artifact right now. + pub live_claim: bool, + /// Some row is not terminal yet. + pub incomplete: bool, + /// When the last row became terminal, once every row is. + pub completed_ms: Option, +} + +/// The v3 import status of `legacy_key`. The previous release named a legacy +/// set with the same key as its family, so the family ledger of that name is +/// the one that can hold the key's rows. `None` when no v3 import was ever +/// recorded for it. +pub fn legacy_key_status(storage: &Path, legacy_key: &str) -> V3Result> { + if validate_legacy_key(legacy_key).is_err() + || crate::blob_store::v2::validate_family(legacy_key).is_err() + { + return Ok(None); + } + let Some(rows) = read_rows(storage, legacy_key)? else { + return Ok(None); + }; + let rows = rows + .into_iter() + .filter(|row| row.key.legacy_key == legacy_key) + .collect::>(); + if rows.is_empty() { + return Ok(None); + } + let live_claim = rows + .iter() + .any(|row| row.owner.as_ref().is_some_and(ClaimOwner::is_live)); + let incomplete = rows.iter().any(|row| !row.state.is_terminal()); + let completed_ms = (!incomplete) + .then(|| rows.iter().filter_map(|row| row.completed_ms).max()) + .flatten(); + Ok(Some(LegacyImportStatus { + rows, + live_claim, + incomplete, + completed_ms, + })) +} + +// --------------------------------------------------------------------------- +// The driver + +/// What one caller asks the v3 import to produce. The trigram policy is not +/// optional: every request imports the search postings, so no request can +/// complete only the semantic plane and leave the trigram plane unasked. +#[derive(Clone, Debug)] +pub struct V3ImportRequest { + /// The storage root holding both the legacy artifacts and `blobs/v3`. + pub storage: PathBuf, + pub family: String, + /// The key of the legacy set (`index/`, `semantic/`, …). + pub legacy_key: String, + /// The checkout the semantic import verifies its files against. + pub root: PathBuf, + pub trigram: TrigramPolicy, + /// The semantic producer, when semantic indexing is configured. + pub semantic: Option, + pub symbols: bool, + /// Working-set budget of the streaming trigram inversion, in bytes. + pub working_set: usize, +} + +impl V3ImportRequest { + /// The production request: every plane the family has. + pub fn full_plane( + storage: PathBuf, + family: String, + legacy_key: String, + root: PathBuf, + trigram: TrigramPolicy, + semantic: Option, + ) -> Self { + Self { + storage, + family, + legacy_key, + root, + trigram, + semantic, + symbols: true, + working_set: DEFAULT_WORKING_SET, + } + } + + /// The ledger rows this request drives, one per artifact it imports. + pub fn rows(&self) -> Vec { + let mut rows = vec![self.row(LegacyArtifact::Trigram, self.trigram.fingerprint_hex())]; + if let Some(producer) = &self.semantic { + rows.push(self.row(LegacyArtifact::Semantic, producer.id())); + } + if self.symbols { + rows.push(self.row( + LegacyArtifact::Symbols, + crate::views::symbol_store::SymbolProducer::current().id(), + )); + } + rows + } + + fn row(&self, artifact: LegacyArtifact, producer: String) -> RowKey { + RowKey { + legacy_key: self.legacy_key.clone(), + artifact, + producer, + plane: artifact.plane(), + } + } + + fn stage_input<'a>(&'a self, dir: &'a Path) -> StageInput<'a> { + StageInput { + storage: &self.storage, + legacy_key: &self.legacy_key, + root: &self.root, + dir, + trigram: &self.trigram, + semantic: self.semantic.as_ref(), + working_set: self.working_set, + } + } +} + +/// A durable boundary of one artifact's import, reported after it commits. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum V3Step { + Claimed, + Staged, + Validated, + /// The `n`th registration batch is committed in the plane. + BatchRegistered(u64), + Registered, + Done, + Rejected, + /// The claim was released after a storage gap. + Released, +} + +/// Observes durable boundaries. Tests park or kill the process here. +pub trait V3ImportObserver: Sync { + fn reached(&self, artifact: LegacyArtifact, step: V3Step); +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum ArtifactOutcome { + /// The legacy set has no such artifact; no row was written. + Absent, + /// The row was already terminal; nothing was done. + AlreadyComplete(ImportState), + /// This call finished the import, possibly resuming an earlier attempt. + Done { + registered: Registered, + skipped: u64, + }, + Rejected(String), + /// Another live owner holds the row. + Busy { + owner_pid: u32, + }, + /// A reservation was refused; the plane reports this named gap and + /// serves what v3 already holds. A later run resumes the row. + StorageGap(String), +} + +#[derive(Clone, Debug, Default)] +pub struct V3ImportReport { + pub outcomes: BTreeMap, + /// Durable boundaries committed by this call, in order. + pub steps: Vec<(LegacyArtifact, V3Step)>, + /// Read statistics of the artifacts this call staged. + pub stats: BTreeMap, +} + +struct Run<'a> { + request: &'a V3ImportRequest, + ledger: V3Ledger, + owner: ClaimOwner, + dir: PathBuf, + observer: Option<&'a dyn V3ImportObserver>, + report: V3ImportReport, +} + +impl Run<'_> { + fn observe(&mut self, artifact: LegacyArtifact, step: V3Step) { + self.report.steps.push((artifact, step)); + if let Some(observer) = self.observer { + observer.reached(artifact, step); + } + } + + fn staged_file(&self, row: &V3ImportRow) -> Option { + row.staged_hash + .map(|hash| staged_path(&self.dir, row.key.artifact, &hash)) + } + + fn reject(&mut self, row: &V3ImportRow, reason: String) -> V3Result { + crate::slog_info!( + "v3 legacy import {} rejected: {}", + row.key.describe(), + reason + ); + self.ledger.advance( + &row.key, + &self.owner, + row.state, + ImportState::Rejected, + Advance { + reason: Some(reason.clone()), + ..Advance::default() + }, + )?; + if let Some(path) = self.staged_file(row) { + let _ = fs::remove_file(path); + } + self.observe(row.key.artifact, V3Step::Rejected); + Ok(ArtifactOutcome::Rejected(reason)) + } + + fn drive(&mut self, mut row: V3ImportRow) -> V3Result { + let artifact = row.key.artifact; + let mut registered = Registered::default(); + loop { + match row.state { + ImportState::Claimed => { + let staged = stage(artifact, &self.request.stage_input(&self.dir))?; + let staged = match staged { + Ok(staged) => staged, + Err(reason) => return self.reject(&row, reason), + }; + self.report.stats.insert(artifact, staged.stats); + row = self.ledger.advance( + &row.key, + &self.owner, + ImportState::Claimed, + ImportState::Staged, + Advance { + source_hash: Some(staged.source_hash), + source_len: Some(staged.source_len), + staged_hash: Some(staged.hash), + skipped: Some(staged.skipped), + ..Advance::default() + }, + )?; + self.observe(artifact, V3Step::Staged); + } + ImportState::Staged => { + let Some(path) = self.staged_file(&row) else { + return self.reject(&row, "the row records no staged bundle".to_owned()); + }; + let hash = row.staged_hash.expect("staged file implies hash"); + if let Err(reason) = + check_staged(artifact, &path, &hash, &self.request.stage_input(&self.dir)) + { + return self.reject(&row, reason); + } + // An older daemon may have replaced its artifact since it + // was converted; the bundle then describes bytes that no + // longer exist and the plane is built from the checkout. + let current = + source_identity(artifact, &self.request.storage, &self.request.legacy_key); + if current.ok() != row.source_hash.zip(row.source_len) { + return self.reject( + &row, + "the legacy artifact changed during the import".to_owned(), + ); + } + row = self.ledger.advance( + &row.key, + &self.owner, + ImportState::Staged, + ImportState::Validated, + Advance::default(), + )?; + self.observe(artifact, V3Step::Validated); + } + ImportState::Validated => { + let Some(path) = self.staged_file(&row) else { + return self.reject(&row, "the row records no staged bundle".to_owned()); + }; + let hash = row.staged_hash.expect("staged file implies hash"); + let mut batches = Vec::new(); + let outcome = register_staged( + &self.request.storage, + &self.request.family, + artifact, + &path, + &hash, + &mut |batch| { + batches.push(batch); + if let Some(observer) = self.observer { + observer.reached(artifact, V3Step::BatchRegistered(batch)); + } + }, + )?; + for batch in batches { + self.report + .steps + .push((artifact, V3Step::BatchRegistered(batch))); + } + match outcome { + Err(reason) => return self.reject(&row, reason), + Ok(RegisterOutcome::StorageGap(gap)) => { + self.ledger.release(&row.key, &self.owner, &gap)?; + self.observe(artifact, V3Step::Released); + return Ok(ArtifactOutcome::StorageGap(gap)); + } + Ok(RegisterOutcome::Registered(done)) => { + registered = done.clone(); + row = self.ledger.advance( + &row.key, + &self.owner, + ImportState::Validated, + ImportState::Registered, + Advance { + imported: Some( + done.inserted + done.reused + done.kept_existing, + ), + ..Advance::default() + }, + )?; + self.observe(artifact, V3Step::Registered); + } + } + } + ImportState::Registered => { + if let Some(path) = self.staged_file(&row) { + match fs::remove_file(path) { + Ok(()) => {} + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(error) => return Err(error.into()), + } + } + row = self.ledger.advance( + &row.key, + &self.owner, + ImportState::Registered, + ImportState::Done, + Advance::default(), + )?; + self.observe(artifact, V3Step::Done); + } + ImportState::Done => { + return Ok(ArtifactOutcome::Done { + registered, + skipped: row.skipped, + }) + } + ImportState::Rejected => { + return Ok(ArtifactOutcome::Rejected(row.reason.unwrap_or_default())) + } + } + } + } +} + +/// Runs, resumes or observes the v3 import of `request`'s legacy key. +/// +/// Call it from a background thread, never on a bind path: a first import +/// reads every legacy artifact. Artifacts the legacy set lacks write no row, +/// so a key without legacy data never creates a ledger. +pub fn run_v3_import( + request: &V3ImportRequest, + observer: Option<&dyn V3ImportObserver>, +) -> V3Result { + validate_legacy_key(&request.legacy_key) + .map_err(|_| V3ImportError::InvalidKey(request.legacy_key.clone()))?; + let owner = ClaimOwner::new(); + let _active = ActiveClaim::register(&owner); + let mut run = Run { + request, + ledger: V3Ledger::new(&request.storage, &request.family)?, + owner, + dir: staging_dir(&request.storage, &request.family, &request.legacy_key), + observer, + report: V3ImportReport::default(), + }; + for key in request.rows() { + let artifact = key.artifact; + let existing = run.ledger.row(&key)?; + if existing.is_none() + && !artifact + .source_path(&request.storage, &request.legacy_key) + .is_file() + { + run.report + .outcomes + .insert(artifact, ArtifactOutcome::Absent); + continue; + } + if let Some(row) = existing.filter(|row| row.state.is_terminal()) { + run.report + .outcomes + .insert(artifact, ArtifactOutcome::AlreadyComplete(row.state)); + continue; + } + let outcome = match run.ledger.claim(&key, &run.owner)? { + Claim::Terminal(row) => ArtifactOutcome::AlreadyComplete(row.state), + Claim::Busy(holder) => ArtifactOutcome::Busy { + owner_pid: holder.pid, + }, + Claim::Owned(row) => { + if row.attempt == 1 && row.state == ImportState::Claimed { + run.observe(artifact, V3Step::Claimed); + } + run.drive(row)? + } + }; + run.report.outcomes.insert(artifact, outcome); + } + Ok(run.report) +} + +#[cfg(test)] +#[path = "v3_ledger_tests.rs"] +mod tests; + +/// Keeps a test's claim live until it is dropped. +#[cfg(test)] +pub(crate) struct HeldClaim(#[allow(dead_code)] ActiveClaim); + +/// Test seam: claims a row of `legacy_key` in `family`'s ledger for an owner +/// this process keeps live until the returned guard drops, as a running +/// import would. +#[cfg(test)] +pub(crate) fn hold_claim_for_test(storage: &Path, family: &str, legacy_key: &str) -> HeldClaim { + let owner = ClaimOwner::new(); + let active = ActiveClaim::register(&owner); + let key = RowKey { + legacy_key: legacy_key.to_owned(), + artifact: LegacyArtifact::Trigram, + producer: "held-for-test".to_owned(), + plane: FamilyPlane::Trigram, + }; + let claimed = V3Ledger::new(storage, family) + .unwrap() + .claim(&key, &owner) + .unwrap(); + assert!(matches!(claimed, Claim::Owned(_))); + HeldClaim(active) +} diff --git a/crates/aft/src/migration/v3_ledger_tests.rs b/crates/aft/src/migration/v3_ledger_tests.rs new file mode 100644 index 0000000000..3269a0ed37 --- /dev/null +++ b/crates/aft/src/migration/v3_ledger_tests.rs @@ -0,0 +1,619 @@ +use std::path::Path; +#[cfg(unix)] +use std::time::{Duration, Instant}; + +use super::*; +use crate::blob_store::v2::{ContentHash, FamilyKey, TrigramKey}; +use crate::migration::v3_import::tests::{ + checkout, deterministic, legacy_semantic, legacy_trigram, producer, snapshot_tree, FAMILY, KEY, + POLICY, +}; +#[cfg(unix)] +use crate::views::segment_store::TrigramPayload; + +fn request(storage: &Path, root: &Path, semantic: Option) -> V3ImportRequest { + let mut request = V3ImportRequest::full_plane( + storage.to_path_buf(), + FAMILY.to_owned(), + KEY.to_owned(), + root.to_path_buf(), + POLICY, + semantic, + ); + // The symbol artifact has its own tests; these cover the two others. + request.symbols = false; + request +} + +fn outcome(report: &V3ImportReport, artifact: LegacyArtifact) -> ArtifactOutcome { + report.outcomes[&artifact].clone() +} + +fn row_of(storage: &Path, artifact: LegacyArtifact) -> V3ImportRow { + read_rows(storage, FAMILY) + .unwrap() + .unwrap() + .into_iter() + .find(|row| row.key.artifact == artifact) + .unwrap() +} + +fn trigram_keys( + root_sources: &BTreeMap>, + policy: TrigramPolicy, +) -> Vec { + root_sources + .values() + .map(|source| { + TrigramKey { + content: ContentHash::of(source), + policy, + } + .family_key() + }) + .collect() +} + +fn present(storage: &Path, plane: FamilyPlane, keys: &[FamilyKey]) -> usize { + if !v3::plane_path(storage, FAMILY, plane).unwrap().exists() { + return 0; + } + let reader = v3::PlaneReader::open_existing(storage, FAMILY, plane).unwrap(); + keys.iter() + .filter(|key| reader.contains(key).unwrap()) + .count() +} + +/// A full import puts every compatible row into v3, calling no model and +/// reading no source for the trigram plane, and records one done row per +/// artifact; a second run does nothing. Legacy bytes stay identical. +#[test] +fn a_full_import_lands_every_artifact_and_a_second_run_is_already_complete() { + let _serial = v3::serial_for_test(); + let dir = tempfile::tempdir().unwrap(); + let root = checkout(dir.path()); + let storage = dir.path().join("storage"); + legacy_trigram(&storage, &root); + legacy_semantic(&storage, &root); + let legacy = snapshot_tree(&storage); + let producer = producer(); + let report = run_v3_import(&request(&storage, &root, Some(producer.clone())), None).unwrap(); + for artifact in [LegacyArtifact::Trigram, LegacyArtifact::Semantic] { + assert!( + matches!(outcome(&report, artifact), ArtifactOutcome::Done { ref registered, .. } if registered.inserted > 0), + "{artifact:?}: {:?}", + outcome(&report, artifact) + ); + let row = row_of(&storage, artifact); + assert_eq!( + (row.state, row.attempt, row.owner), + (ImportState::Done, 1, None) + ); + } + // Every imported semantic payload is the one a cold fill stores under + // the same key; the import itself had no model to call. + let reader = v3::PlaneReader::open_existing(&storage, FAMILY, FamilyPlane::Semantic).unwrap(); + let payload_producer = crate::semantic_index::ViewPayloadProducer { + chunker_version: &producer.chunker_version, + template_version: &producer.template_version, + model_fingerprint: &producer.model_fingerprint, + }; + let mut model_calls = 0; + let mut checked = 0; + for path in ["src/alpha.rs", "src/beta.rs", "notes/readme.md"] { + let rel_path = crate::views::RelPath::new(path.as_bytes().to_vec()).unwrap(); + if !crate::views::semantic::applies_to(&rel_path) { + continue; + } + let source = fs::read(root.join(path)).unwrap(); + let chunks = + crate::semantic_index::chunk_view_file(&root, Path::new(path), &source, producer.caps) + .unwrap() + .unwrap(); + let mut embed = |texts: Vec| { + model_calls += 1; + Ok(texts.iter().map(|text| deterministic(text)).collect()) + }; + let runs = crate::semantic_index::embed_view_files(vec![chunks], &mut embed, 64).unwrap(); + assert_eq!( + reader.get(&producer.key(&source, &rel_path)).unwrap(), + Some(runs[0].encode_view_payload(&payload_producer)), + "semantic row of {path}" + ); + checked += 1; + } + assert!( + checked >= 2 && model_calls >= checked, + "{checked} {model_calls}" + ); + let again = run_v3_import(&request(&storage, &root, Some(producer)), None).unwrap(); + assert_eq!( + outcome(&again, LegacyArtifact::Trigram), + ArtifactOutcome::AlreadyComplete(ImportState::Done) + ); + assert!(again.steps.is_empty()); + let after = snapshot_tree(&storage); + for (path, bytes) in &legacy { + assert_eq!(after.get(path), Some(bytes), "{} changed", path.display()); + } + assert!(snapshot_tree(&staging_dir(&storage, FAMILY, KEY)).is_empty()); + assert!(!storage.join("migration/v2").exists()); + assert!(!storage.join("blobs/v2").exists()); +} + +/// Completion is per artifact, producer and plane: a run that completed only +/// the semantic plane (the legacy set had no postings yet) leaves the trigram +/// import to a later run instead of standing for it. +#[test] +fn a_semantic_only_completion_does_not_suppress_a_later_trigram_import() { + let _serial = v3::serial_for_test(); + let dir = tempfile::tempdir().unwrap(); + let root = checkout(dir.path()); + let storage = dir.path().join("storage"); + legacy_semantic(&storage, &root); + let request = request(&storage, &root, Some(producer())); + let first = run_v3_import(&request, None).unwrap(); + assert_eq!( + outcome(&first, LegacyArtifact::Trigram), + ArtifactOutcome::Absent + ); + assert!(matches!( + outcome(&first, LegacyArtifact::Semantic), + ArtifactOutcome::Done { .. } + )); + let status = legacy_key_status(&storage, KEY).unwrap().unwrap(); + assert!(!status.incomplete && status.completed_ms.is_some()); + + legacy_trigram(&storage, &root); + let second = run_v3_import(&request, None).unwrap(); + assert!( + matches!(outcome(&second, LegacyArtifact::Trigram), ArtifactOutcome::Done { ref registered, .. } if registered.inserted > 0), + "{:?}", + second.outcomes + ); + assert_eq!( + outcome(&second, LegacyArtifact::Semantic), + ArtifactOutcome::AlreadyComplete(ImportState::Done) + ); + let sources = snapshot_tree(&root); + assert!( + present( + &storage, + FamilyPlane::Trigram, + &trigram_keys(&sources, POLICY) + ) >= 3 + ); +} + +/// A changed producer is a new row that has to prove compatibility again: +/// a cache built under another size limit or another model is rejected, +/// nothing is stored under the new producer's keys, and the earlier +/// producer's completed row stays as it was. +#[test] +fn a_changed_producer_starts_a_new_row_and_provably_misses() { + let _serial = v3::serial_for_test(); + let dir = tempfile::tempdir().unwrap(); + let root = checkout(dir.path()); + let storage = dir.path().join("storage"); + legacy_trigram(&storage, &root); + legacy_semantic(&storage, &root); + run_v3_import(&request(&storage, &root, Some(producer())), None).unwrap(); + let sources = snapshot_tree(&root); + + let other_policy = TrigramPolicy { + max_file_size: 4096, + }; + let mut other_fingerprint = crate::migration::v3_import::tests::fingerprint(); + other_fingerprint.model = "another".to_owned(); + let other_producer = SemanticProducer::current( + other_fingerprint.as_string(), + other_fingerprint.embed_text_caps, + ); + let mut changed = request(&storage, &root, Some(other_producer.clone())); + changed.trigram = other_policy; + let report = run_v3_import(&changed, None).unwrap(); + for artifact in [LegacyArtifact::Trigram, LegacyArtifact::Semantic] { + assert!( + matches!(outcome(&report, artifact), ArtifactOutcome::Rejected(_)), + "{artifact:?}: {:?}", + outcome(&report, artifact) + ); + } + // Counted misses: no row exists under any key of the changed producers. + assert_eq!( + present( + &storage, + FamilyPlane::Trigram, + &trigram_keys(&sources, other_policy) + ), + 0 + ); + let semantic_keys = sources + .iter() + .map(|(path, source)| { + let relative = path.strip_prefix(&root).unwrap(); + other_producer.key( + source, + &crate::views::RelPath::from_os_path(relative).unwrap(), + ) + }) + .collect::>(); + assert_eq!(present(&storage, FamilyPlane::Semantic, &semantic_keys), 0); + // The original producer's rows are still the ones the first run stored. + assert!( + present( + &storage, + FamilyPlane::Trigram, + &trigram_keys(&sources, POLICY) + ) >= 3 + ); + let rows = read_rows(&storage, FAMILY).unwrap().unwrap(); + assert_eq!(rows.len(), 4, "{rows:#?}"); + assert_eq!( + rows.iter() + .filter(|row| row.state == ImportState::Done) + .count(), + 2 + ); +} + +/// A refused reservation is the plane's named storage gap. The row keeps its +/// state with its claim released and the gap recorded, nothing reads the +/// legacy artifact in place of v3, and a later run with room resumes it. +#[test] +fn a_storage_gap_releases_the_row_and_a_later_run_resumes_it() { + let _serial = v3::serial_for_test(); + let dir = tempfile::tempdir().unwrap(); + let root = checkout(dir.path()); + let storage = dir.path().join("storage"); + legacy_trigram(&storage, &root); + // Room for a 64 KiB ledger write above the 4 GiB floor, not for a batch. + v3::set_free_space_for_test((4 << 30) + (512 << 10), 1 << 30); + let report = run_v3_import(&request(&storage, &root, None), None); + v3::set_free_space_for_test(u64::MAX, u64::MAX / 2); + let report = report.unwrap(); + assert_eq!( + outcome(&report, LegacyArtifact::Trigram), + ArtifactOutcome::StorageGap("storage_gap:device_free_space_floor".to_owned()) + ); + let row = row_of(&storage, LegacyArtifact::Trigram); + assert_eq!(row.state, ImportState::Validated); + assert_eq!(row.owner, None); + assert_eq!( + row.reason.as_deref(), + Some("storage_gap:device_free_space_floor") + ); + let sources = snapshot_tree(&root); + assert_eq!( + present( + &storage, + FamilyPlane::Trigram, + &trigram_keys(&sources, POLICY) + ), + 0 + ); + + let resumed = run_v3_import(&request(&storage, &root, None), None).unwrap(); + assert!(matches!( + outcome(&resumed, LegacyArtifact::Trigram), + ArtifactOutcome::Done { .. } + )); + let row = row_of(&storage, LegacyArtifact::Trigram); + assert_eq!((row.state, row.attempt), (ImportState::Done, 2)); + assert!( + present( + &storage, + FamilyPlane::Trigram, + &trigram_keys(&sources, POLICY) + ) >= 3 + ); +} + +/// A live claim is single flight; once its owner is gone the row is taken +/// over and finished. +#[test] +fn a_live_claim_is_single_flight_and_an_abandoned_one_is_taken_over() { + let _serial = v3::serial_for_test(); + let dir = tempfile::tempdir().unwrap(); + let root = checkout(dir.path()); + let storage = dir.path().join("storage"); + legacy_trigram(&storage, &root); + let request = request(&storage, &root, None); + let key = request.rows().remove(0); + let ledger = V3Ledger::new(&storage, FAMILY).unwrap(); + let other = ClaimOwner::new(); + let active = ActiveClaim::register(&other); + assert!(matches!( + ledger.claim(&key, &other).unwrap(), + Claim::Owned(_) + )); + let busy = run_v3_import(&request, None).unwrap(); + assert!(matches!( + outcome(&busy, LegacyArtifact::Trigram), + ArtifactOutcome::Busy { .. } + )); + let status = legacy_key_status(&storage, KEY).unwrap().unwrap(); + assert!(status.live_claim && status.incomplete); + drop(active); + let done = run_v3_import(&request, None).unwrap(); + assert!(matches!( + outcome(&done, LegacyArtifact::Trigram), + ArtifactOutcome::Done { .. } + )); + assert_eq!(row_of(&storage, LegacyArtifact::Trigram).attempt, 2); +} + +/// A ledger read that has to copy the ledger's files holds descriptors on +/// them; closing those would drop the POSIX locks of any connection this +/// process had open. The copy is held part-way through: it then holds the +/// lock every ledger open takes, and an open attempted meanwhile completes +/// only after the copy has finished. +#[test] +fn a_ledger_open_waits_for_an_in_flight_copy_of_its_files() { + use std::sync::mpsc; + let _serial = v3::serial_for_test(); + let dir = tempfile::tempdir().unwrap(); + let root = checkout(dir.path()); + let storage = dir.path().join("storage"); + legacy_trigram(&storage, &root); + run_v3_import(&request(&storage, &root, None), None).unwrap(); + let ledger = V3Ledger::new(&storage, FAMILY).unwrap(); + assert!(ledger.path.is_file()); + // The import's connection is closed (its last handle dropped), so the + // next read has to copy the files. + assert!(lock(open_connections()) + .get(&ledger.path) + .and_then(Weak::upgrade) + .is_none()); + + let log = Arc::new(Mutex::new(Vec::new())); + let (paused_tx, paused_rx) = mpsc::channel(); + let (release_tx, release_rx) = mpsc::channel::<()>(); + let reader = { + let storage = storage.clone(); + let log = Arc::clone(&log); + std::thread::spawn(move || { + COPY_HOOK.with(|hook| { + *hook.borrow_mut() = Some(Box::new(move |stage| match stage { + "database-copied" => { + paused_tx.send(()).unwrap(); + release_rx.recv().unwrap(); + } + "finished" => lock(&log).push("copy-finished"), + _ => {} + })); + }); + read_rows(&storage, FAMILY).unwrap().unwrap().len() + }) + }; + paused_rx.recv().unwrap(); + // The copy is in flight and holds the lock every ledger open takes first. + assert!(matches!( + open_connections().try_lock(), + Err(std::sync::TryLockError::WouldBlock) + )); + let (attempting_tx, attempting_rx) = mpsc::channel(); + let opener = { + let log = Arc::clone(&log); + std::thread::spawn(move || { + attempting_tx.send(()).unwrap(); + let connection = ledger.connection(false).unwrap().unwrap(); + lock(&log).push("opened"); + drop(connection); + }) + }; + attempting_rx.recv().unwrap(); + assert!( + lock(&log).is_empty(), + "nothing finished while the copy is held" + ); + release_tx.send(()).unwrap(); + assert_eq!(reader.join().unwrap(), 1, "the copy read the trigram row"); + opener.join().unwrap(); + assert_eq!(*lock(&log), vec!["copy-finished", "opened"]); +} + +#[test] +fn a_ledger_from_a_newer_build_is_refused_by_name() { + let _serial = v3::serial_for_test(); + let dir = tempfile::tempdir().unwrap(); + let root = checkout(dir.path()); + let storage = dir.path().join("storage"); + legacy_trigram(&storage, &root); + run_v3_import(&request(&storage, &root, None), None).unwrap(); + let path = V3Ledger::new(&storage, FAMILY) + .unwrap() + .path() + .to_path_buf(); + // Forget this process's ledger handle (its connection is already closed) + // so the next read opens the edited file. + lock(open_connections()).remove(&path); + let connection = rusqlite::Connection::open(&path).unwrap(); + connection + .execute("UPDATE format_meta SET format_version = 2", []) + .unwrap(); + drop(connection); + assert!(matches!( + read_rows(&storage, FAMILY), + Err(V3ImportError::NewerLedger(2)) + )); +} + +// --------------------------------------------------------------------------- +// Crash transitions: a child process is killed with SIGKILL right after each +// durable boundary of the trigram import; the next run must finish the import +// from that state with nothing lost, duplicated or left in staging. + +#[cfg(unix)] +const KILL_SUBJECT: &str = "AFT_V3_IMPORT_KILL_SUBJECT"; +#[cfg(unix)] +const KILL_AT: &str = "AFT_V3_IMPORT_KILL_AT"; + +#[cfg(unix)] +fn step_name(step: V3Step) -> String { + match step { + V3Step::BatchRegistered(batch) => format!("batch-{batch}"), + other => format!("{other:?}").to_lowercase(), + } +} + +#[cfg(unix)] +struct Park { + at: String, + ready: PathBuf, +} + +#[cfg(unix)] +impl V3ImportObserver for Park { + fn reached(&self, artifact: LegacyArtifact, step: V3Step) { + if artifact != LegacyArtifact::Trigram || step_name(step) != self.at { + return; + } + fs::write(&self.ready, step_name(step)).unwrap(); + let deadline = Instant::now() + Duration::from_secs(30); + while Instant::now() < deadline { + std::thread::sleep(Duration::from_millis(10)); + } + panic!("kill-test parent did not send SIGKILL within 30 seconds"); + } +} + +#[cfg(unix)] +#[test] +fn v3_import_kill_child() { + let Ok(subject) = std::env::var(KILL_SUBJECT) else { + return; + }; + let at = std::env::var(KILL_AT).unwrap(); + let subject = PathBuf::from(subject); + v3::set_free_space_for_test(u64::MAX, u64::MAX / 2); + let park = Park { + at, + ready: subject.join("ready"), + }; + let root = subject.join("checkout"); + run_v3_import(&request(&subject.join("storage"), &root, None), Some(&park)).unwrap(); + panic!("the import finished without reaching the kill point"); +} + +#[cfg(unix)] +fn kill_child_at(subject: &Path, at: &str) { + use std::os::unix::process::ExitStatusExt; + let mut child = std::process::Command::new(std::env::current_exe().unwrap()) + .args([ + "--exact", + "migration::v3_ledger::tests::v3_import_kill_child", + "--nocapture", + ]) + .env(KILL_SUBJECT, subject) + .env(KILL_AT, at) + .spawn() + .unwrap(); + let deadline = Instant::now() + Duration::from_secs(60); + while !subject.join("ready").exists() { + assert!( + child.try_wait().unwrap().is_none(), + "kill-test child exited before reaching {at}" + ); + if Instant::now() >= deadline { + child.kill().unwrap(); + panic!("kill-test child did not reach {at} within 60 seconds"); + } + std::thread::sleep(Duration::from_millis(10)); + } + child.kill().unwrap(); + assert_eq!(child.wait().unwrap().signal(), Some(9)); +} + +#[cfg(unix)] +#[test] +fn every_ledger_crash_transition_resumes_to_the_same_rows() { + let _serial = v3::serial_for_test(); + let reference = { + let dir = tempfile::tempdir().unwrap(); + let root = checkout(dir.path()); + let storage = dir.path().join("storage"); + legacy_trigram(&storage, &root); + run_v3_import(&request(&storage, &root, None), None).unwrap(); + let reader = + v3::PlaneReader::open_existing(&storage, FAMILY, FamilyPlane::Trigram).unwrap(); + let keys = trigram_keys(&snapshot_tree(&root), POLICY) + .into_iter() + .filter(|key| reader.contains(key).unwrap()) + .collect::>(); + assert!(keys.len() >= 3); + (keys, row_of(&storage, LegacyArtifact::Trigram).imported) + }; + for at in [ + "claimed", + "staged", + "validated", + "batch-1", + "registered", + "done", + ] { + let dir = tempfile::tempdir().unwrap(); + let subject = dir.path(); + let root = checkout(subject); + let storage = subject.join("storage"); + legacy_trigram(&storage, &root); + let legacy = snapshot_tree(&storage); + kill_child_at(subject, at); + let killed = row_of(&storage, LegacyArtifact::Trigram); + assert_eq!( + killed.owner.is_some(), + at != "done", + "{at}: the killed child held the row until it was done" + ); + + let report = run_v3_import(&request(&storage, &root, None), None).unwrap(); + let expected = if at == "done" { + ArtifactOutcome::AlreadyComplete(ImportState::Done) + } else { + match outcome(&report, LegacyArtifact::Trigram) { + done @ ArtifactOutcome::Done { .. } => done, + other => panic!("{at}: {other:?}"), + } + }; + assert_eq!(outcome(&report, LegacyArtifact::Trigram), expected, "{at}"); + let row = row_of(&storage, LegacyArtifact::Trigram); + assert_eq!(row.state, ImportState::Done, "{at}"); + assert_eq!(row.attempt, if at == "done" { 1 } else { 2 }, "{at}"); + assert_eq!(row.imported, reference.1, "{at}"); + assert_eq!( + present(&storage, FamilyPlane::Trigram, &reference.0), + reference.0.len(), + "{at}: every row is in v3" + ); + let reader = + v3::PlaneReader::open_existing(&storage, FAMILY, FamilyPlane::Trigram).unwrap(); + for (_, source) in snapshot_tree(&root) { + let key = TrigramKey { + content: ContentHash::of(&source), + policy: POLICY, + } + .family_key(); + if !reference.0.contains(&key) { + continue; + } + assert_eq!( + reader.get(&key).unwrap(), + Some(TrigramPayload::extract(&source, &POLICY).encode()), + "{at}" + ); + } + assert!( + snapshot_tree(&staging_dir(&storage, FAMILY, KEY)).is_empty(), + "{at}: staging left behind" + ); + let after = snapshot_tree(&storage); + for (path, bytes) in &legacy { + assert_eq!( + after.get(path), + Some(bytes), + "{at}: {} changed", + path.display() + ); + } + } +} diff --git a/crates/aft/src/migration/v3_symbols.rs b/crates/aft/src/migration/v3_symbols.rs new file mode 100644 index 0000000000..2e48af9bf8 --- /dev/null +++ b/crates/aft/src/migration/v3_symbols.rs @@ -0,0 +1,122 @@ +//! Import of a legacy symbol cache (`symbols//symbols.bin`) into the v3 +//! symbol plane, without parsing. +//! +//! The legacy cache stores, per checkout-relative path, the content hash and +//! size of the bytes it parsed and their symbols. This importer adapts the +//! entries the disk cache reader validates (`DecodedSymbolCache`): the path +//! must stay under the current root and the file there must still be the +//! recorded bytes (same size, same hash). Each such entry becomes a row keyed +//! by content, language and the extraction schema (`views::symbol_store`), +//! so any checkout of the family with the same bytes reuses it. A cache of +//! another format or extraction schema yields nothing: a parser or schema +//! change misses rather than reusing stale symbols. +//! +//! The import reads the legacy cache once for its hash and once to convert +//! it, through bounded reads, and reads each listed source file to verify its +//! hash. It never calls the parser. + +use std::fs; +use std::io::BufReader; +use std::path::Path; + +use super::per_checkout::BundleEntry; +use super::v3_import::Source; +use crate::blob_store::v2::ContentHash; +use crate::symbols::Symbol; +use crate::views::symbol_store::{self, SymbolKey, SymbolProducer}; +use crate::views::RelPath; + +/// Source files larger than this are not read to verify a cached entry; the +/// symbol plane fills them on demand instead. +const MAX_SYMBOL_SOURCE_BYTES: u64 = 16 << 20; +const READ_CHUNK: usize = 64 * 1024; + +/// Converts a legacy `symbols.bin` into symbol-plane entries, handing each +/// to `emit`. Returns the file's BLAKE3 and the number of entries left for +/// the plane to fill (path outside the root, unknown language, source +/// changed or unreadable, undecodable symbols). +pub(super) fn convert_symbols_streaming( + source: &mut Source, + root: &Path, + emit: &mut dyn FnMut(BundleEntry) -> Result<(), String>, +) -> Result<([u8; 32], u64), String> { + let (source_hash, _) = source.checksum_pass(&[])?; + let root = fs::canonicalize(root).map_err(|error| error.to_string())?; + let producer = SymbolProducer::current(); + let mut canonical_parents = crate::search_index::ParentCanonicalizationMemo::default(); + let mut skipped = 0_u64; + let reader = BufReader::with_capacity(READ_CHUNK, source.sequential()); + crate::symbol_cache_disk::for_each_raw_entry(reader, &mut |raw| { + let Some(language) = symbol_store::language_of(&raw.relative_path) else { + skipped += 1; + return Ok(()); + }; + // The validated relative entry: it maps under the current root, and + // the file there still holds the bytes the symbols were parsed from. + let Some(path) = crate::search_index::cached_path_under_root_with_memo( + &root, + &raw.relative_path, + &mut canonical_parents, + ) else { + skipped += 1; + return Ok(()); + }; + let bytes = fs::symlink_metadata(&path) + .ok() + .filter(|meta| { + meta.is_file() && meta.len() == raw.size && meta.len() <= MAX_SYMBOL_SOURCE_BYTES + }) + .and_then(|_| fs::read(&path).ok()); + let Some(bytes) = bytes.filter(|bytes| blake3::hash(bytes) == raw.content_hash) else { + skipped += 1; + return Ok(()); + }; + let Ok(symbols) = serde_json::from_slice::>(&raw.symbols_json) else { + skipped += 1; + return Ok(()); + }; + let Ok(rel_path) = RelPath::from_os_path(&raw.relative_path) else { + skipped += 1; + return Ok(()); + }; + let content = ContentHash::of(&bytes); + let key = SymbolKey { + content, + language: language.clone(), + producer, + } + .family_key(); + emit(BundleEntry { + rel_path, + content, + size: raw.size, + key: *key.as_bytes(), + payload: symbol_store::encode_payload(&producer, &language, &symbols), + }) + })?; + Ok((source_hash, skipped)) +} + +/// Re-derives a staged symbol entry's key from its content and path and +/// checks that its payload decodes for that language and this producer. +pub(super) fn check_staged_entry(entry: &BundleEntry) -> Result<(), String> { + let relative = crate::views::segment_store::rel_path_to_os(&entry.rel_path) + .map_err(|error| error.to_string())?; + let language = + symbol_store::language_of(&relative).ok_or("staged symbol entry has no language")?; + let producer = SymbolProducer::current(); + symbol_store::decode_payload(&entry.payload, &producer, &language)?; + let key = SymbolKey { + content: entry.content, + language, + producer, + } + .family_key(); + (key.as_bytes() == &entry.key) + .then_some(()) + .ok_or_else(|| "staged symbol key differs from its content".to_owned()) +} + +#[cfg(test)] +#[path = "v3_symbols_tests.rs"] +mod tests; diff --git a/crates/aft/src/migration/v3_symbols_tests.rs b/crates/aft/src/migration/v3_symbols_tests.rs new file mode 100644 index 0000000000..5151a1a869 --- /dev/null +++ b/crates/aft/src/migration/v3_symbols_tests.rs @@ -0,0 +1,214 @@ +use std::path::Path; + +use super::*; +use crate::blob_store::v2::FamilyPlane; +use crate::blob_store::v3; +use crate::migration::per_checkout::ImportState; +use crate::migration::v3_import::tests::{checkout, snapshot_tree, FAMILY, KEY, POLICY}; +use crate::migration::v3_import::LegacyArtifact; +use crate::migration::v3_ledger::{read_rows, run_v3_import, ArtifactOutcome, V3ImportRequest}; +use crate::parser::work_counters::cached_parser_parses; + +/// Parses every supported file under `root` (this is the cold work an import +/// must avoid) and writes the previous release's `symbols.bin` for it. +/// Returns each relative path's bytes and symbols. +fn legacy_symbols(storage: &Path, root: &Path) -> Vec<(std::path::PathBuf, Vec, Vec)> { + let mut parser = crate::parser::FileParser::new(); + let mut cache = crate::parser::SymbolCache::new(); + cache.set_project_root(root.to_path_buf()); + let mut files = Vec::new(); + for (path, bytes) in snapshot_tree(root) { + if crate::parser::detect_language(&path).is_none() { + continue; + } + let symbols = parser.extract_symbols(&path).unwrap(); + let metadata = fs::metadata(&path).unwrap(); + cache.insert( + path.clone(), + metadata.modified().unwrap(), + metadata.len(), + blake3::hash(&bytes), + symbols.clone(), + ); + files.push(( + path.strip_prefix(root).unwrap().to_path_buf(), + bytes, + symbols, + )); + } + crate::symbol_cache_disk::write_to_disk(&cache, storage, KEY).unwrap(); + assert!(crate::symbol_cache_disk::cache_path(storage, KEY).is_file()); + files +} + +fn request(storage: &Path, root: &Path) -> V3ImportRequest { + V3ImportRequest::full_plane( + storage.to_path_buf(), + FAMILY.to_owned(), + KEY.to_owned(), + root.to_path_buf(), + POLICY, + None, + ) +} + +/// A compatible symbol cache is imported and then served by key with zero +/// tree-sitter parses; the counter is shown to count real parses. +#[test] +fn a_compatible_symbol_import_needs_no_parse_and_serves_lookups() { + let _serial = v3::serial_for_test(); + let dir = tempfile::tempdir().unwrap(); + let root = checkout(dir.path()); + let storage = dir.path().join("storage"); + let files = legacy_symbols(&storage, &root); + // Every checkout file the parser has a language for, and only those. + assert_eq!( + files + .iter() + .map(|(relative, _, _)| relative.clone()) + .collect::>(), + ["notes/readme.md", "src/alpha.rs", "src/beta.rs"] + .into_iter() + .map(std::path::PathBuf::from) + .collect() + ); + let legacy = snapshot_tree(&storage); + + let parses = cached_parser_parses(); + let report = run_v3_import(&request(&storage, &root), None).unwrap(); + let ArtifactOutcome::Done { registered, .. } = &report.outcomes[&LegacyArtifact::Symbols] + else { + panic!("{:?}", report.outcomes); + }; + assert_eq!(registered.inserted as usize, files.len()); + for (relative, bytes, symbols) in &files { + let stored = symbol_store::lookup(&storage, FAMILY, ContentHash::of(bytes), relative) + .unwrap() + .unwrap_or_else(|| panic!("no row for {}", relative.display())); + assert_eq!(&*stored, symbols, "{}", relative.display()); + } + assert_eq!( + cached_parser_parses() - parses, + 0, + "import and lookups must not parse" + ); + // Control: the counter does count a real parse on this thread. + crate::parser::FileParser::new() + .extract_symbols(&root.join(&files[0].0)) + .unwrap(); + assert!(cached_parser_parses() > parses); + + let rows = read_rows(&storage, FAMILY).unwrap().unwrap(); + let row = rows + .iter() + .find(|row| row.key.artifact == LegacyArtifact::Symbols) + .unwrap(); + assert_eq!(row.state, ImportState::Done); + assert_eq!(row.key.plane, FamilyPlane::Symbol); + assert_eq!(row.key.producer, SymbolProducer::current().id()); + let after = snapshot_tree(&storage); + for (path, bytes) in &legacy { + assert_eq!(after.get(path), Some(bytes), "{} changed", path.display()); + } +} + +/// A cache written under another extraction schema is rejected whole, and +/// a key under another producer never matches a stored row: a parser or +/// schema change misses. +#[test] +fn a_parser_or_schema_change_misses() { + let _serial = v3::serial_for_test(); + let dir = tempfile::tempdir().unwrap(); + let root = checkout(dir.path()); + let storage = dir.path().join("storage"); + let files = legacy_symbols(&storage, &root); + let path = crate::symbol_cache_disk::cache_path(&storage, KEY); + let mut bytes = fs::read(&path).unwrap(); + let schema = crate::symbol_cache_disk::SCHEMA_VERSION; + bytes[12..16].copy_from_slice(&(schema - 1).to_le_bytes()); + fs::write(&path, &bytes).unwrap(); + let report = run_v3_import(&request(&storage, &root), None).unwrap(); + let ArtifactOutcome::Rejected(reason) = &report.outcomes[&LegacyArtifact::Symbols] else { + panic!("{:?}", report.outcomes); + }; + assert!(reason.contains("schema"), "{reason}"); + assert_eq!( + fs::read(&path).unwrap(), + bytes, + "the legacy cache is left as it was" + ); + for (relative, source, _) in &files { + assert!( + symbol_store::lookup(&storage, FAMILY, ContentHash::of(source), relative) + .unwrap() + .is_none() + ); + } + + // A compatible cache imports, but a lookup key of another producer still + // misses every row. + let other = tempfile::tempdir().unwrap(); + let storage = other.path().join("storage"); + let root = checkout(other.path()); + let files = legacy_symbols(&storage, &root); + run_v3_import(&request(&storage, &root), None).unwrap(); + let reader = v3::PlaneReader::open_existing(&storage, FAMILY, FamilyPlane::Symbol).unwrap(); + for (relative, source, _) in &files { + let language = symbol_store::language_of(relative).unwrap(); + let current = SymbolKey { + content: ContentHash::of(source), + language: language.clone(), + producer: SymbolProducer::current(), + }; + let changed = SymbolKey { + producer: SymbolProducer { + extraction_schema: schema + 1, + }, + ..current.clone() + }; + assert!(reader.contains(¤t.family_key()).unwrap()); + assert!(!reader.contains(&changed.family_key()).unwrap()); + } +} + +/// An entry whose file changed since it was parsed cannot vouch for the new +/// bytes and is left for the plane; the others are imported. +#[test] +fn a_changed_source_is_left_for_the_plane() { + let _serial = v3::serial_for_test(); + let dir = tempfile::tempdir().unwrap(); + let root = checkout(dir.path()); + let storage = dir.path().join("storage"); + let files = legacy_symbols(&storage, &root); + let changed = b"pub fn alpha_total() -> u32 { 7 }\n"; + fs::write(root.join("src/alpha.rs"), changed).unwrap(); + let report = run_v3_import(&request(&storage, &root), None).unwrap(); + let ArtifactOutcome::Done { + registered, + skipped, + } = &report.outcomes[&LegacyArtifact::Symbols] + else { + panic!("{:?}", report.outcomes); + }; + assert_eq!(*skipped, 1); + assert_eq!(registered.inserted as usize, files.len() - 1); + for (relative, source, _) in &files { + let found = symbol_store::lookup(&storage, FAMILY, ContentHash::of(source), relative) + .unwrap() + .is_some(); + assert_eq!( + found, + relative != Path::new("src/alpha.rs"), + "{}", + relative.display() + ); + } + assert!(symbol_store::lookup( + &storage, + FAMILY, + ContentHash::of(changed), + Path::new("src/alpha.rs") + ) + .unwrap() + .is_none()); +} diff --git a/crates/aft/src/parser.rs b/crates/aft/src/parser.rs index 4c773a6f88..fc810a11af 100644 --- a/crates/aft/src/parser.rs +++ b/crates/aft/src/parser.rs @@ -1009,6 +1009,13 @@ pub(crate) mod work_counters { pub(crate) static SOURCE_READS: Cell = const { Cell::new(0) }; pub(crate) static OWNED_SYMBOL_COPIES: Cell = const { Cell::new(0) }; pub(crate) static RUST_MODULE_SCANS: Cell = const { Cell::new(0) }; + /// Tree-sitter parses through this thread's retained parsers, by any + /// caller (FileParser, semantic chunking, symbol extraction). + pub(crate) static CACHED_PARSER_PARSES: Cell = const { Cell::new(0) }; + } + + pub(crate) fn cached_parser_parses() -> usize { + CACHED_PARSER_PARSES.with(Cell::get) } pub(crate) fn tree_parses() -> usize { @@ -1037,6 +1044,8 @@ pub(crate) fn parse_source_with_cached_parser( source: &str, lang: LangId, ) -> Result { + #[cfg(test)] + work_counters::CACHED_PARSER_PARSES.with(|count| count.set(count.get() + 1)); REUSABLE_PARSERS.with(|parsers| { let mut parsers = parsers.borrow_mut(); if let std::collections::hash_map::Entry::Vacant(entry) = parsers.entry(lang) { diff --git a/crates/aft/src/persisted_format.rs b/crates/aft/src/persisted_format.rs index 80a22b952e..67ed315d59 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_VERIFICATION_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..7b67b387ba 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 verification format/schema version 3; plane stores + // keep format 1. Neither appears in the startup baseline floor. + for (name, version) in [("views_v3_registry", 3), ("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/runtime_drain.rs b/crates/aft/src/runtime_drain.rs index c8557a85c4..60b61cf15e 100644 --- a/crates/aft/src/runtime_drain.rs +++ b/crates/aft/src/runtime_drain.rs @@ -814,6 +814,10 @@ pub fn mark_semantic_corpus_refresh_success(ctx: &AppContext) { } pub fn drain_search_index_events(ctx: &AppContext) { + if ctx.config().views.enabled { + ctx.retire_search_index_rx("lexical views replace the legacy search loader"); + return; + } let (latest, disconnected, receiver_generation, receiver_epoch) = { let rx_ref = ctx .search_index_rx() @@ -2378,6 +2382,9 @@ fn spawn_search_corpus_refresh_admitted( config: Arc, generation: u64, ) { + if config.views.enabled || ctx.config().views.enabled { + return; + } { let mut search_index = ctx .search_index() @@ -2404,12 +2411,14 @@ fn spawn_search_corpus_refresh_admitted( let content_generation_flag = ctx.configure_content_generation_flag(); let persist_epoch_flag = ctx.search_persist_epoch_flag(); let persist_epoch = ctx.next_search_persist_epoch(); + let publication_gate = ctx.legacy_search_publication_gate(); let lifecycle = ctx.subc_lifecycle_admission(); let cold_build_limiter = ctx.cold_build_limiter(); ctx.search_corpus_refresh_lane.submit( root.clone(), Box::new(move |cancel| { let _terminal_guard = receiver_terminal_guard; + let _publication = publication_gate.install(); log_ctx::with_session(session_id, || { let Some(_permit) = crate::cold_build_limiter::acquire_blocking_while_with_limiter( &cold_build_limiter, @@ -2446,6 +2455,7 @@ fn spawn_search_corpus_refresh_admitted( }; let keep_going = || { !cancel.load(std::sync::atomic::Ordering::Acquire) + && publication_gate.allowed() && lifecycle.is_current(&generation_flag, generation) && persist_epoch_flag.current() == persist_epoch }; @@ -2533,7 +2543,7 @@ pub fn refresh_project_corpus(ctx: &AppContext, reason: &str, ignore_rules_chang } } - if config.indexes.trigram && !ctx.shared_artifacts_read_only() { + if config.indexes.trigram && !config.views.enabled && !ctx.shared_artifacts_read_only() { spawn_search_corpus_refresh_admitted(ctx, root.clone(), config.clone(), generation); status_changed = true; aft::slog_info!("{}", search_refresh_started_log(reason)); @@ -2923,6 +2933,9 @@ fn next_watcher_apply_phase(stage: WatcherDrainApplyPhase) -> WatcherDrainApplyP } fn refresh_search_index_path(ctx: &AppContext, path: &Path) { + if ctx.config().views.enabled { + return; + } let max_file_size = ctx .search_index() .read() @@ -3118,6 +3131,9 @@ impl Drop for FileMutationGuard<'_> { /// read-after-write ordering. Preparation reads at most the index's file-size /// limit, just as it does for a watcher update. pub(crate) fn refresh_search_index_after_write(ctx: &AppContext, path: &Path) { + if ctx.config().views.enabled { + return; + } if !ctx.heavy_root_work_allowed() || (ctx.shared_artifacts_read_only() && !ctx.ram_overlay_active()) { @@ -3695,6 +3711,7 @@ pub fn drain_watcher_events_bounded(ctx: &AppContext, max_paths: usize) -> Drain { return outcome; } + ctx.record_checkout_pending_watcher_gap(); let mut state = match ctx.watcher_drain_slice().lock().take() { Some(state) if state.configure_generation == configure_generation => state, // Lifecycle-only generation change (transient unbind + equivalent @@ -3754,6 +3771,7 @@ pub fn drain_watcher_events_bounded(ctx: &AppContext, max_paths: usize) -> Drain } } Ok(WatcherDispatchEvent::RescanRequired(reason)) => { + ctx.record_checkout_watcher_overflow(); state.view_publication_reason.events |= crate::views::publication_reason::Reason::RESCAN; state.view_publication_full = true; @@ -3776,6 +3794,20 @@ pub fn drain_watcher_events_bounded(ctx: &AppContext, max_paths: usize) -> Drain state.semantic_refresh_paths.clear(); state.scheduler_changed_path_count = 0; } + Ok(WatcherDispatchEvent::CookieObserved(path)) => { + dispatch_events_received += 1; + // Earlier batches may still await sliced Tier-2 work. Record + // their intents now so the cookie cannot overtake them. + for pending in &state.pending_paths { + ctx.record_checkout_watcher_change(pending); + } + if let WatcherDrainPhase::Apply { paths, .. } = &state.phase { + for pending in paths { + ctx.record_checkout_watcher_change(pending); + } + } + ctx.record_checkout_watcher_cookie(&path); + } Ok(WatcherDispatchEvent::IgnoreRulesChanged { paths }) => { state.view_publication_reason.events |= crate::views::publication_reason::Reason::IGNORE; diff --git a/crates/aft/src/search_index.rs b/crates/aft/src/search_index.rs index e96cafd0c7..d4d9d2aff1 100644 --- a/crates/aft/src/search_index.rs +++ b/crates/aft/src/search_index.rs @@ -394,6 +394,86 @@ fn artifact_write_allowed(project_root: &Path, cache_dir: &Path, write_path: &Pa && SearchIndex::check_disk_cache_format(cache_dir).is_ok() } +/// Revokes only legacy search publication when a checkout switches to views. +/// Shared holders cover the final rename, not the build or serialization, so +/// ordinary legacy publications never wait for one another. +pub(crate) struct LegacySearchPublicationGate { + allowed: std::sync::atomic::AtomicBool, + publication: parking_lot::RwLock<()>, + #[cfg(test)] + before_publish: parking_lot::Mutex>>, +} + +impl Default for LegacySearchPublicationGate { + fn default() -> Self { + Self { + allowed: std::sync::atomic::AtomicBool::new(true), + publication: parking_lot::RwLock::new(()), + #[cfg(test)] + before_publish: Default::default(), + } + } +} + +thread_local! { + static LEGACY_SEARCH_PUBLICATION: std::cell::RefCell>> = const { std::cell::RefCell::new(None) }; +} + +pub(crate) struct LegacySearchPublicationScope { + previous: Option>, + _not_send: std::marker::PhantomData<*const ()>, +} + +impl Drop for LegacySearchPublicationScope { + fn drop(&mut self) { + LEGACY_SEARCH_PUBLICATION.with(|slot| slot.replace(self.previous.take())); + } +} + +impl LegacySearchPublicationGate { + pub(crate) fn allowed(&self) -> bool { + self.allowed.load(std::sync::atomic::Ordering::Acquire) + } + + pub(crate) fn revoke(&self) { + let _publication = self.publication.write(); + self.allowed + .store(false, std::sync::atomic::Ordering::Release); + } + + pub(crate) fn install(self: &Arc) -> LegacySearchPublicationScope { + LegacySearchPublicationScope { + previous: LEGACY_SEARCH_PUBLICATION.with(|slot| slot.replace(Some(self.clone()))), + _not_send: std::marker::PhantomData, + } + } + + fn publish(&self, operation: impl FnOnce() -> std::io::Result<()>) -> std::io::Result<()> { + #[cfg(test)] + if let Some(hook) = self.before_publish.lock().take() { + hook(); + } + let _publication = self.publication.read(); + if !self.allowed() { + return Err(std::io::Error::new( + std::io::ErrorKind::Interrupted, + "legacy search publication revoked by views", + )); + } + operation() + } +} + +fn publish_legacy_search_cache( + operation: impl FnOnce() -> std::io::Result<()>, +) -> std::io::Result<()> { + let gate = LEGACY_SEARCH_PUBLICATION.with(|slot| slot.borrow().clone()); + match gate { + Some(gate) => gate.publish(operation), + None => operation(), + } +} + #[derive(Clone, Debug)] pub struct SearchIndex { base: Option>, @@ -742,6 +822,61 @@ impl SearchIndex { } impl SearchIndexSnapshot { + pub(crate) fn content_generation(&self) -> String { + let mut digest = blake3::Hasher::new(); + digest.update(&self.max_file_size.to_be_bytes()); + for file in self.files.iter() { + let path = file.path.as_os_str().as_encoded_bytes(); + digest.update(&(path.len() as u64).to_be_bytes()); + digest.update(path); + digest.update(file.content_hash.as_bytes()); + } + digest.finalize().to_hex().to_string() + } + + /// Reuses the query engine over already verified, in-memory posting rows. + /// This is a read projection, not a mutable index: it has no cache file or + /// persistence path. Missing postings require direct verification by grep. + pub(crate) fn from_verified_rows( + root: &Path, + max_file_size: u64, + rows: impl IntoIterator>)>, + ) -> Self { + let mut files = Vec::new(); + let mut paths = HashMap::new(); + let mut counts = Vec::new(); + let mut unindexed = HashSet::new(); + let mut delta = DeltaState::default(); + for (file, records) in rows { + let id = u32::try_from(files.len()).expect("view file id exceeds u32"); + paths.insert(file.path.clone(), id); + files.push(file); + counts.push(records.as_ref().map_or(0, |records| records.len() as u32)); + if let Some(records) = records { + for (trigram, next_mask, loc_mask) in records { + delta.postings.entry(trigram).or_default().push(Posting { + file_id: id, + next_mask, + loc_mask, + }); + } + } else { + unindexed.insert(id); + } + } + Self { + base: None, + delta: Arc::new(delta), + files: Arc::new(files), + path_to_id: Arc::new(paths), + ready: true, + project_root: root.to_path_buf(), + max_file_size, + file_trigram_count: Arc::new(counts), + unindexed_files: Arc::new(unindexed), + } + } + pub(crate) fn file_modified(&self, path: &Path) -> Option { self.path_to_id .get(path) @@ -3059,6 +3194,7 @@ impl SearchIndex { ) } + #[cfg(test)] pub(crate) fn rebuild_or_refresh_with_strategy( root: &Path, max_file_size: u64, @@ -5309,14 +5445,18 @@ fn write_cache_file_from_sources( drop(writer); check_search_build(keep_going)?; - fs::rename(&tmp_cache, &cache_path)?; - // Credit the bytes handed to the kernel; this cache is not a durable log. - crate::write_ledger::credit( - domain, - plan.project_root.display().to_string(), - file_bytes, - file_bytes, - ); + publish_legacy_search_cache(|| { + fs::rename(&tmp_cache, &cache_path)?; + // Account inside the publication fence so a completed views bind + // cannot be followed by credit for an older legacy publication. + crate::write_ledger::credit( + domain, + plan.project_root.display().to_string(), + file_bytes, + file_bytes, + ); + Ok(()) + })?; let file = open_cache_file_read(&cache_path)?; Ok(BasePostings { file: Arc::new(file), @@ -10905,6 +11045,84 @@ pub(crate) fn audit_work() -> AuditWork { #[cfg(test)] mod tests { + fn lexical_views_paused_legacy_publication(turn_views_on: bool) { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("project"); + let cache = temp.path().join("cache"); + fs::create_dir_all(&root).unwrap(); + fs::write(root.join("a.rs"), "old_marker\n").unwrap(); + let mut initial = SearchIndex::build_with_limit_serial(&root, 1024); + assert!(initial.write_to_disk(&cache, None)); + let original = fs::read(cache.join("cache.bin")).unwrap(); + fs::write(root.join("a.rs"), "new_marker\n").unwrap(); + let context = crate::context::AppContext::new( + Box::new(crate::parser::TreeSitterProvider::new()), + crate::config::Config { + project_root: Some(root.clone()), + ..Default::default() + }, + ); + let gate = context.legacy_search_publication_gate(); + let (ready_tx, ready_rx) = std::sync::mpsc::channel(); + let (release_tx, release_rx) = std::sync::mpsc::channel(); + *gate.before_publish.lock() = Some(Box::new(move || { + ready_tx.send(()).unwrap(); + release_rx.recv().unwrap(); + })); + let build_root = root.clone(); + let build_cache = cache.clone(); + let build_gate = gate.clone(); + let build = std::thread::spawn(move || { + let _publication = build_gate.install(); + SearchIndex::build_with_limit_to_cache_dir_cancellable( + &build_root, + 1024, + &build_cache, + &|| true, + ) + }); + ready_rx.recv_timeout(Duration::from_secs(30)).unwrap(); + if turn_views_on { + context.update_config(|config| config.views.enabled = true); + assert!(!gate.allowed()); + } else { + context.update_config(|config| config.search_index_max_file_size += 1); + context.advance_configure_generation(); + context.mark_subc_unbound(); + assert!(gate.allowed()); + } + release_tx.send(()).unwrap(); + let result = build.join().unwrap(); + if turn_views_on { + assert!(result.is_none()); + assert_eq!(fs::read(cache.join("cache.bin")).unwrap(), original); + context.update_config(|config| config.views.enabled = false); + assert!(context.legacy_search_publication_gate().allowed()); + assert!( + !gate.allowed(), + "old workers stay revoked after views are disabled" + ); + } else { + assert!(result.is_some_and(|index| index.is_ready())); + assert_ne!(fs::read(cache.join("cache.bin")).unwrap(), original); + } + assert!(fs::read_dir(&cache).unwrap().all(|entry| !entry + .unwrap() + .file_name() + .to_string_lossy() + .starts_with("cache.bin.tmp."))); + } + + #[test] + fn lexical_views_cutover_revokes_admitted_legacy_cache_rename() { + lexical_views_paused_legacy_publication(true); + } + + #[test] + fn lexical_views_off_preserves_admitted_publication_after_reconfigure_and_unbind() { + lexical_views_paused_legacy_publication(false); + } + struct IsolatedLegacyMemoIndexes { indexes: Arc>, previous: Option>>, diff --git a/crates/aft/src/storage_retention.rs b/crates/aft/src/storage_retention.rs index 5316ee0964..bb5e100a4e 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()), @@ -2279,6 +2279,144 @@ fn protected(cache: &Path) -> io::Result { Ok(false) } +/// Whether a due legacy cache must stay because a v3 import is reading it. +/// +/// The v3 importer reads `index/`, `semantic/` and `symbols/` +/// while it holds a claim in the family's import ledger +/// (`migration::v3_ledger`). Retiring such a set would pull the source out +/// from under that import, so a live claim protects it, and a ledger that +/// cannot be read is uncertainty and protects it too. A finished, abandoned +/// or absent import leaves the binding-age rules alone. +fn legacy_import_source_pinned(storage: &StoragePaths, cache: &Path, key: &str) -> bool { + let domain = cache + .parent() + .and_then(Path::file_name) + .and_then(|name| name.to_str()); + if !matches!(domain, Some("index" | "semantic" | "symbols")) { + return false; + } + match crate::migration::v3_ledger::legacy_key_status(&storage.artifacts, key) { + Ok(Some(status)) => status.live_claim, + Ok(None) => false, + Err(_) => true, + } +} + +/// One kind of legacy (pre-v3) data that stays on disk for an offline +/// rollback to the previous binary. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd)] +pub enum RetainedKind { + /// `symbols/`: a parsed-symbol cache, a source of the v3 symbol import. + SymbolCache, + /// `views/`: a v1 view, whose generations name v1 blob rows. + V1View, + /// `blobs/`: v1 blob stores, the copy-on-first-use donors of v3. + V1BlobFamily, + /// `blobs/v2/`: v2 family stores, also copy-on-first-use donors. + V2FamilyStore, +} + +impl RetainedKind { + pub const fn describe(self) -> &'static str { + match self { + Self::SymbolCache => "symbol cache", + Self::V1View => "v1 view", + Self::V1BlobFamily => "v1 blob family", + Self::V2FamilyStore => "v2 family store", + } + } +} + +/// One retained legacy item. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct RetainedItem { + pub kind: RetainedKind, + pub path: PathBuf, + pub bytes: u64, + /// The v3 family of the same name exists, so rows of this item may have + /// been copied into v3 (a copy never removes or changes its source). + pub copied_into_v3: bool, + /// Why the item is protected right now (a live import claim, live pins + /// or readers), or `None`. A protected item is never retired. + pub pinned: Option, +} + +/// Upper bound on items listed per retained kind. +const RETAINED_ITEMS_PER_KIND: usize = 10_000; + +/// Lists the legacy data this build keeps but never writes: symbol caches, +/// v1 views and blob families, and v2 family stores, with whether v3 holds +/// a family of the same name and whether something protects them now. It +/// reads directory listings, sizes, ledgers and pin files only; it writes +/// nothing under the storage root. +pub fn retained_legacy_inventory(storage: &Path) -> io::Result> { + let paths = StoragePaths::same(storage); + let v3_family = |name: &str| storage.join("blobs/v3").join(name).is_dir(); + let mut items = Vec::new(); + for (kind, parent) in [ + (RetainedKind::SymbolCache, storage.join("symbols")), + (RetainedKind::V1View, storage.join("views")), + (RetainedKind::V1BlobFamily, storage.join("blobs")), + (RetainedKind::V2FamilyStore, storage.join("blobs/v2")), + ] { + let entries = match fs::read_dir(&parent) { + Ok(entries) => entries, + Err(error) if error.kind() == io::ErrorKind::NotFound => continue, + Err(error) => return Err(error), + }; + let mut names = Vec::new(); + for entry in entries.take(RETAINED_ITEMS_PER_KIND) { + let entry = entry?; + if !entry.file_type()?.is_dir() { + continue; + } + let Some(name) = entry.file_name().to_str().map(str::to_owned) else { + continue; + }; + let listed = match kind { + RetainedKind::SymbolCache => { + crate::migration::per_checkout::validate_legacy_key(&name).is_ok() + } + RetainedKind::V1View | RetainedKind::V1BlobFamily => key_valid(&name), + RetainedKind::V2FamilyStore => { + crate::blob_store::v2::validate_family(&name).is_ok() + } + }; + if listed { + names.push(name); + } + } + names.sort(); + for name in names { + let path = parent.join(&name); + let bytes = crate::migration::prune_legacy::tree_size(&path, TREE_ENTRIES) + .map(|(bytes, _, _)| bytes) + .unwrap_or(0); + let pinned = match kind { + RetainedKind::SymbolCache => legacy_import_source_pinned(&paths, &path, &name) + .then(|| "a v3 import is reading it".to_owned()), + RetainedKind::V1View => match protected(&path) { + Ok(true) => Some("live pins or readers".to_owned()), + Ok(false) => None, + Err(error) => Some(format!("pins unreadable: {error}")), + }, + // Their rows are protected one by one: a copier pins each + // source row before reading it, and the v1/v2 sweeps honour + // those pins. Nothing protects the store as a whole. + RetainedKind::V1BlobFamily | RetainedKind::V2FamilyStore => None, + }; + items.push(RetainedItem { + kind, + copied_into_v3: v3_family(&name), + path, + bytes, + pinned, + }); + } + } + Ok(items) +} + fn owner_has_residency_marker( storage: &StoragePaths, owner: &crate::artifact_owner::ArtifactOwnerManifest, @@ -3495,6 +3633,7 @@ impl PreparedDue { || crate::root_cache::live_scope_keys_for_storage(&storage.durable).contains(key) || owners.protect(storage, key, current.contains_key(key))? || protected(&cache)? + || legacy_import_source_pinned(storage, &cache, key) { report.skipped_protected += 1; return Ok(()); @@ -3983,6 +4122,7 @@ fn candidate_inner( report, )? || crate::root_cache::live_scope_keys_for_storage(&storage.durable).contains(key) || protected(&cache)? + || legacy_import_source_pinned(storage, &cache, key) { report.skipped_protected += 1; return Ok(()); @@ -4660,7 +4800,7 @@ enum V3Step { /// way, so they never keep a member. `Err` is uncertainty (an unreadable pin, /// or a `pins/` or `readers/` listing longer than the bound) and keeps the /// member without resetting its count, as the v2 family sweep does. -fn v3_member_protected( +pub(crate) fn v3_member_protected( tx: &rusqlite::Transaction<'_>, scope: &str, view_dir: &Path, @@ -4887,10 +5027,16 @@ fn v3_finish_eviction( crate::views::registry::remove_view_dir(view_dir)?; registry .with_barrier(|tx| { - tx.execute( + let removed = tx.execute( "DELETE FROM members WHERE scope=?1 AND state=?2", rusqlite::params![scope, crate::views::registry::STATE_EVICTING], )?; + // A deregistered worker's referrer and strict-rebuild rows go in + // the same transaction as its member row, after its directory. + if removed > 0 && crate::views::base_refs::has_base_refs(tx)? { + tx.execute("DELETE FROM base_refs WHERE worker_scope=?1", [scope])?; + tx.execute("DELETE FROM strict_rebuild WHERE worker_scope=?1", [scope])?; + } Ok(()) }) .map_err(|error| io::Error::other(error.to_string()))?; @@ -5203,6 +5349,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. @@ -9196,6 +9380,93 @@ mod storage_retention_tests { assert!(report.skipped_protected > 0); } + #[test] + fn retention_due_legacy_cache_keeps_a_live_v3_import_source() { + let _serial = crate::blob_store::v3::serial_for_test(); + let (temp, root, scope) = fixture(); + fs::remove_dir(&root).unwrap(); + let path = cache(temp.path(), "index", &scope); + // The v3 importer of this key's family holds a live claim: it may be + // reading `index/` right now. + let held = crate::migration::v3_ledger::hold_claim_for_test(temp.path(), &scope, &scope); + let report = run_pass(temp.path(), &|| false); + assert_eq!(report.removed_roots, 0, "{report:?}"); + assert!(path.join("payload.bin").exists()); + assert!(report.skipped_protected > 0, "{report:?}"); + // Once the import is gone the binding-age rule applies again. + drop(held); + let report = run_pass(temp.path(), &|| false); + assert_eq!(report.removed_roots, 1, "{report:?}"); + assert!(!path.exists()); + } + + #[test] + fn retained_legacy_inventory_lists_every_kind_without_writing() { + let temp = tempfile::tempdir().unwrap(); + let storage = temp.path(); + for (path, bytes) in [ + ("symbols/0123456789abcdef/symbols.bin", &b"symbols"[..]), + ("views/0123456789abcdef/manifest.json", b"view"), + ("views/v2/scope/manifest.json", b"v2 view"), + ("blobs/fedcba9876543210/semantic.sqlite", b"v1 blobs"), + ("blobs/v2/family/semantic.sqlite", b"v2 blobs"), + ("blobs/v3/family/members.sqlite", b"v3 registry"), + ] { + let path = storage.join(path); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(path, bytes).unwrap(); + } + let pins = storage.join("views/0123456789abcdef/pins"); + fs::create_dir_all(&pins).unwrap(); + let _pin = + crate::pins::QueryPin::acquire(&storage.join("views/0123456789abcdef"), "g").unwrap(); + let snapshot = crate::migration::v3_import::tests::snapshot_tree; + let before = snapshot(storage); + let items = retained_legacy_inventory(storage).unwrap(); + let summary = items + .iter() + .map(|item| { + ( + item.kind, + item.path.strip_prefix(storage).unwrap().to_path_buf(), + item.copied_into_v3, + item.pinned.is_some(), + ) + }) + .collect::>(); + assert_eq!( + summary, + vec![ + ( + RetainedKind::SymbolCache, + PathBuf::from("symbols/0123456789abcdef"), + false, + false + ), + ( + RetainedKind::V1View, + PathBuf::from("views/0123456789abcdef"), + false, + true + ), + ( + RetainedKind::V1BlobFamily, + PathBuf::from("blobs/fedcba9876543210"), + false, + false + ), + ( + RetainedKind::V2FamilyStore, + PathBuf::from("blobs/v2/family"), + true, + false + ), + ] + ); + assert!(items.iter().all(|item| item.bytes > 0)); + assert_eq!(snapshot(storage), before); + } + #[test] fn retention_due_root_keeps_writer_lock() { let (temp, _, _, path) = due_cache_fixture(); @@ -12827,3 +13098,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/symbol_cache_disk.rs b/crates/aft/src/symbol_cache_disk.rs index 3c610c3a85..3a43917286 100644 --- a/crates/aft/src/symbol_cache_disk.rs +++ b/crates/aft/src/symbol_cache_disk.rs @@ -311,8 +311,41 @@ pub(crate) fn check_disk_format( } fn read_cache_file(path: &Path) -> Result { - let mut reader = BufReader::new(File::open(path).map_err(|error| error.to_string())?); + let reader = BufReader::new(File::open(path).map_err(|error| error.to_string())?); + let mut entries = Vec::new(); + for_each_raw_entry(reader, &mut |raw| { + let symbols: Vec = serde_json::from_slice(&raw.symbols_json) + .map_err(|error| format!("failed to decode cached symbols: {error}"))?; + entries.push(DiskSymbolEntry { + relative_path: raw.relative_path, + mtime: raw.mtime, + size: raw.size, + content_hash: raw.content_hash, + symbols, + }); + Ok(()) + })?; + Ok(DiskSymbolCache { entries }) +} +/// One entry of `symbols.bin` with its symbols still in the stored JSON. +pub(crate) struct RawSymbolEntry { + pub(crate) relative_path: PathBuf, + pub(crate) mtime: SystemTime, + pub(crate) size: u64, + pub(crate) content_hash: blake3::Hash, + pub(crate) symbols_json: Vec, +} + +/// Streams the entries of a `symbols.bin` from `reader` to `visit`, one at a +/// time, with the header, format, schema, bound and relative-path checks of +/// the disk cache reader. A file of another format or extraction schema is an +/// error, so a parser or schema change never yields entries. Oversized +/// entries are skipped, as the cache reader skips them. +pub(crate) fn for_each_raw_entry( + mut reader: R, + visit: &mut dyn FnMut(RawSymbolEntry) -> Result<(), String>, +) -> Result<(), String> { let mut magic = [0u8; 8]; reader .read_exact(&mut magic) @@ -345,7 +378,6 @@ fn read_cache_file(path: &Path) -> Result { } let _project_root = PathBuf::from(read_string_with_len(&mut reader, root_len)?); - let mut entries = Vec::with_capacity(entry_count); for _ in 0..entry_count { let path_len = read_u32(&mut reader)? as usize; @@ -390,23 +422,19 @@ fn read_cache_file(path: &Path) -> Result { continue; } - let mut symbol_bytes = vec![0u8; symbol_bytes_len]; + let mut symbols_json = vec![0u8; symbol_bytes_len]; reader - .read_exact(&mut symbol_bytes) + .read_exact(&mut symbols_json) .map_err(|error| format!("failed to read symbol payload: {error}"))?; - let symbols: Vec = serde_json::from_slice(&symbol_bytes) - .map_err(|error| format!("failed to decode cached symbols: {error}"))?; - - entries.push(DiskSymbolEntry { + visit(RawSymbolEntry { relative_path, mtime: system_time_from_parts(mtime_secs, mtime_nanos)?, size, content_hash, - symbols, - }); + symbols_json, + })?; } - - Ok(DiskSymbolCache { entries }) + Ok(()) } fn write_cache_file( diff --git a/crates/aft/src/views/assembly.rs b/crates/aft/src/views/assembly.rs index 0a8e9efd39..dc240087db 100644 --- a/crates/aft/src/views/assembly.rs +++ b/crates/aft/src/views/assembly.rs @@ -2203,7 +2203,7 @@ fn manifest_entry_callgraph_key(entry: &ManifestEntry) -> Option<&str> { } } -pub(super) fn is_resolution_input(path: &[u8]) -> bool { +pub(crate) fn is_resolution_input(path: &[u8]) -> bool { let name = path.rsplit(|byte| *byte == b'/').next().unwrap_or(path); name == b"package.json" || name == b"Cargo.toml" diff --git a/crates/aft/src/views/base_builder.rs b/crates/aft/src/views/base_builder.rs new file mode 100644 index 0000000000..7b195bd488 --- /dev/null +++ b/crates/aft/src/views/base_builder.rs @@ -0,0 +1,797 @@ +//! 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 storage(&self) -> &Path { + &self.storage + } + pub fn family(&self) -> &str { + &self.family + } + 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", + ))); + } + // Seal it as the base's donor before the pointer can name it: no + // reader can have it open yet, and every later opener reads it + // immutably. Worker checkouts clone it instead of rebuilding. + super::donor::seal(&database) + .map_err(|reason| BaseRefError::Io(std::io::Error::other(reason)))?; + super::donor::record(store.view_dir(), super::donor::DonorEvent::Sealed); + 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())?; + // Windows refuses FlushFileBuffers on a read-only handle, so sync + // through the views helper, which opens the file for writing there. + super::sync_file(&temporary)?; + 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", + )) +} + +/// A base member entry. Whether a path is resolver configuration is part of +/// the membership digest, so it is fixed here, before the base identity is +/// computed: a later readiness successor cannot change it. +pub(crate) fn base_entry( + path: &super::RelPath, + content: crate::blob_store::v2::ContentHash, + size: u64, + planes: super::manifest_v2::EntryPlanes, +) -> super::manifest_v2::EntryV2 { + let mut entry = super::manifest_v2::EntryV2::regular(content, size, planes); + if let super::manifest_v2::EntryV2::Regular { + resolution_input, .. + } = &mut entry + { + *resolution_input = super::assembly::is_resolution_input(path.as_bytes()); + } + entry +} + +/// 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.clone(), + }, + 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; + // A registered graph producer completes only the members it needs a + // blob for; any other member stays without callgraph state, so a + // completed base projects. An unregistered producer keeps every + // member pending, as lexical-only bases always have. + let graph_registered = callgraph_producer != super::semantic_runtime::UNREGISTERED_PRODUCER; + 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: (!graph_registered || super::callgraph::applies_to(path)) + .then(|| PlaneState::pending("awaiting the base owner")), + }; + manifest.insert( + path.clone(), + base_entry( + path, + 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..244eaf3dae --- /dev/null +++ b/crates/aft/src/views/base_refs.rs @@ -0,0 +1,1177 @@ +//! 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. Tree-keyed scopes use +/// [`TREE_BASE_SCOPE_PREFIX`]; a scope is never relabelled from one to the other. +pub const BASE_SCOPE_PREFIX: &str = "base-m1-"; +/// Domain tag of a tree-keyed base's membership digest, version 1. It covers +/// each entry's `git_oid`, which the membership digest refuses. +pub const TREE_MEMBERSHIP_DIGEST_TAG: &[u8] = b"aft/views-v3/base-tree-membership/1"; +/// Domain tag of a tree-keyed base's effective-identity digest, version 1. +pub const TREE_IDENTITY_DIGEST_TAG: &[u8] = b"aft/views-v3/base-identity-tree/1"; +/// Tree-keyed base scopes. Readers that know only membership-keyed bases +/// refuse this prefix, so they never authenticate such a base. +pub const TREE_BASE_SCOPE_PREFIX: &str = "base-t1-"; +/// 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. +/// +/// Membership-keyed bases carry no tree claim: a manifest that claims a HEAD +/// tree or any `git_oid`, or carries composition sections, is not a valid +/// membership-keyed base. Tree claims use [`tree_membership_digest`]. +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 two kinds of base. The kind follows from the manifest (a `head_tree` +/// makes it tree-keyed) and is visible in the scope prefix. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum BaseKind { + Membership, + Tree, +} + +/// The kind of base `scope` names, or `None` when it names no base. +pub fn base_kind(scope: &str) -> Option { + if scope.starts_with(BASE_SCOPE_PREFIX) { + Some(BaseKind::Membership) + } else if scope.starts_with(TREE_BASE_SCOPE_PREFIX) { + Some(BaseKind::Tree) + } else { + None + } +} + +/// The object format of a lowercase-hex object ID, or `None` when `text` is +/// not one. Uppercase is refused so that each OID has a single spelling. +fn object_format_of(text: &str) -> Option { + text.bytes() + .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) + .then(|| crate::git_index_reader::ObjectFormat::from_hex_len(text.len())) + .flatten() +} + +/// The membership digest of a tree-keyed base: like [`membership_digest`], +/// plus each entry's `git_oid`. Returns the object format shared by the +/// manifest's tree and every OID. These strings are checked for shape only; +/// whether the bytes really are that tree is proven at bind time, never +/// taken from the manifest. +pub fn tree_membership_digest( + manifest: &ManifestV2, +) -> Result<([u8; 32], crate::git_index_reader::ObjectFormat)> { + 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(), + )); + } + let format = manifest + .header() + .head_tree + .as_deref() + .and_then(object_format_of) + .ok_or_else(|| { + BaseRefError::Corrupt("a tree-keyed base needs a hex HEAD tree object ID".into()) + })?; + let mut hasher = TaggedHasher::new(TREE_MEMBERSHIP_DIGEST_TAG); + hasher.u64(manifest.len() as u64); + for (path, entry) in manifest.entries() { + let EntryV2::Regular { + mode, + content, + size, + git_oid: Some(git_oid), + resolution_input, + .. + } = entry + else { + return Err(BaseRefError::Corrupt(format!( + "tree-keyed base entry {path:?} is not a regular file with a git_oid" + ))); + }; + if object_format_of(git_oid) != Some(format) { + return Err(BaseRefError::Corrupt(format!( + "tree-keyed base entry {path:?} has a malformed git_oid" + ))); + } + hasher.field(path.as_bytes()); + hasher.byte(1); + hasher.u64(u64::from(*mode)); + hasher.field(content.as_bytes()); + hasher.u64(*size); + hasher.byte(u8::from(*resolution_input)); + hasher.field(git_oid.as_bytes()); + } + Ok((hasher.finish(), format)) +} + +/// The effective identity of a tree-keyed base: the inputs of +/// [`identity_digest`] with the object format and HEAD tree in place of +/// `head_tree:none`, over the tree-membership digest. +pub fn tree_identity_digest( + family: &str, + header: &ManifestHeader, + format: crate::git_index_reader::ObjectFormat, + membership: &[u8; 32], +) -> [u8; 32] { + let mut hasher = TaggedHasher::new(TREE_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(format.name().as_bytes()); + let tree = format!("head_tree:{}", header.head_tree.as_deref().unwrap_or("")); + hasher.field(tree.as_bytes()); + hasher.field(membership); + hasher.finish() +} + +/// 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 { + /// A manifest with a `head_tree` gets a tree-keyed identity; any other + /// manifest keeps the membership-keyed identity, computed exactly as before. + pub fn of(family: &str, manifest: &ManifestV2) -> Result { + if manifest.header().head_tree.is_some() { + let (membership, format) = tree_membership_digest(manifest)?; + let identity = tree_identity_digest(family, manifest.header(), format, &membership); + return Ok(Self { + scope: format!("{TREE_BASE_SCOPE_PREFIX}{}", to_hex(&identity)), + membership, + identity, + }); + } + let membership = membership_digest(manifest)?; + let identity = identity_digest(family, manifest.header(), &membership); + Ok(Self { + scope: base_scope_for(&identity), + membership, + identity, + }) + } + + /// Which kind of base this identity names. + pub fn kind(&self) -> BaseKind { + base_kind(&self.scope).expect("identities only derive base scopes") + } + + 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 base_kind(scope).is_none() { + return Err(BaseRefError::Corrupt(format!( + "{scope} is not a 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 base_kind(scope).is_some() { + 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(()), &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. +/// `final_validate` runs after marker and registry verification, immediately +/// before the pointer CAS, so admission and artifact work cannot stale a +/// checkout's freshness proof. +pub fn publish_worker_with( + request: WorkerPublication<'_>, + observer: Option<&dyn DurabilityObserver>, + before_cas: &mut dyn FnMut(&str) -> Result<()>, + final_validate: &mut dyn FnMut() -> Result<()>, +) -> Result { + publish_worker_guarded( + request, + observer, + before_cas, + final_validate, + &mut |commit| commit(), + ) +} + +/// Publishes with ownership serialization around final validation and the CAS. +/// `publish_cas` may decline without calling its operation, but may call it only +/// once. Artifact construction and admission happen before this fence. +pub fn publish_worker_guarded( + request: WorkerPublication<'_>, + observer: Option<&dyn DurabilityObserver>, + before_cas: &mut dyn FnMut(&str) -> Result<()>, + final_validate: &mut dyn FnMut() -> Result<()>, + publish_cas: &mut dyn FnMut(&mut dyn FnMut() -> Result) -> 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 mut prepared = Some(prepared); + let mut invoked = false; + let mut cas_started = false; + let outcome = publish_cas(&mut || { + if invoked { + return Err(BaseRefError::Unavailable( + "publication fence invoked CAS twice".into(), + )); + } + invoked = true; + final_validate()?; + cas_started = true; + worker_store + .commit_v2( + prepared.take().expect("prepared CAS consumed once"), + observer, + ) + .map_err(BaseRefError::from) + }); + if !cas_started { + return Err(abort( + outcome.err().unwrap_or_else(|| { + BaseRefError::Unavailable("publication fence did not invoke CAS".into()) + }), + marker, + )); + } + #[cfg(test)] + let outcome = + match AFTER_WORKER_CAS.with(|hook| hook.borrow_mut().as_mut().and_then(|hook| hook())) { + Some(error) => Err(error.into()), + 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..d2aae788b4 --- /dev/null +++ b/crates/aft/src/views/base_refs_tests.rs @@ -0,0 +1,882 @@ +//! 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(_)) + )); +} + +/// A tree claim over `files`: every entry carries its real Git blob OID and +/// the header names `tree` (a well-formed but unproven string). +pub(crate) fn tree_manifest(files: &[(&str, &[u8])], tree: &str) -> ManifestV2 { + let mut manifest = base_manifest(files); + manifest.header_mut().head_tree = Some(tree.into()); + for (name, bytes) in files { + if let Some(EntryV2::Regular { git_oid, .. }) = manifest.get_mut(&path(name)) { + *git_oid = Some(to_hex( + &crate::git_index_reader::ObjectFormat::Sha1.blob_oid(bytes), + )); + } + } + manifest +} + +#[test] +fn tree_claims_get_their_own_identity_kind_and_never_a_membership_scope() { + let files: &[(&str, &[u8])] = &[("a.rs", b"one"), ("b.rs", b"two")]; + let tree = "4b825dc642cb6eb9a060e54bf8d69288fbee4904"; + let manifest = tree_manifest(files, tree); + let identity = BaseIdentity::of(FAMILY, &manifest).unwrap(); + assert_eq!(identity.kind(), BaseKind::Tree); + assert!(identity.scope.starts_with(TREE_BASE_SCOPE_PREFIX)); + let membership = BaseIdentity::of(FAMILY, &base_manifest(files)).unwrap(); + assert_eq!(membership.kind(), BaseKind::Membership); + assert_ne!(identity.scope, membership.scope); + assert_ne!(identity.membership, membership.membership); + + // The tree string and every OID are part of the identity. + let other_tree = tree_manifest(files, &"1".repeat(40)); + assert_ne!(BaseIdentity::of(FAMILY, &other_tree).unwrap(), identity); + let mut other_oid = manifest.clone(); + if let Some(EntryV2::Regular { git_oid, .. }) = other_oid.get_mut(&path("a.rs")) { + *git_oid = Some("2".repeat(40)); + } + assert_ne!(BaseIdentity::of(FAMILY, &other_oid).unwrap(), identity); + // Readiness successors keep the identity. + let mut ready = manifest.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!(BaseIdentity::of(FAMILY, &ready).unwrap(), identity); + + // Malformed claims are refused, never reinterpreted. + for forged in ["caller-supplied", &tree.to_uppercase(), &tree[..39]] { + let mut bad = manifest.clone(); + bad.header_mut().head_tree = Some(forged.to_owned()); + assert!(matches!( + BaseIdentity::of(FAMILY, &bad), + Err(BaseRefError::Corrupt(_)) + )); + } + let mut missing_oid = manifest.clone(); + if let Some(EntryV2::Regular { git_oid, .. }) = missing_oid.get_mut(&path("b.rs")) { + *git_oid = None; + } + assert!(matches!( + BaseIdentity::of(FAMILY, &missing_oid), + Err(BaseRefError::Corrupt(_)) + )); + let mut sha256_oid = manifest.clone(); + if let Some(EntryV2::Regular { git_oid, .. }) = sha256_oid.get_mut(&path("b.rs")) { + *git_oid = Some("3".repeat(64)); + } + assert!(matches!( + BaseIdentity::of(FAMILY, &sha256_oid), + Err(BaseRefError::Corrupt(_)) + )); + // Dropping the tree while keeping the OIDs does not relabel the claim as + // membership-keyed. + let mut relabelled = manifest.clone(); + relabelled.header_mut().head_tree = None; + assert!(matches!( + BaseIdentity::of(FAMILY, &relabelled), + Err(BaseRefError::Corrupt(_)) + )); +} + +/// A reader that knows only membership-keyed bases (the rules this module had +/// before tree-keyed bases): it accepts a scope only with the membership +/// prefix, and only a manifest whose membership digest derives that scope. +fn membership_only_reader_accepts(manifest: &ManifestV2, scope: &str) -> bool { + scope.starts_with(BASE_SCOPE_PREFIX) + && membership_digest(manifest).is_ok_and(|membership| { + base_scope_for(&identity_digest(FAMILY, manifest.header(), &membership)) == scope + }) +} + +#[test] +fn an_older_membership_only_reader_refuses_a_published_tree_keyed_base() { + let _serial = v3::serial_for_test(); + let storage = tempfile::tempdir().unwrap(); + let files: &[(&str, &[u8])] = &[("a.rs", b"alpha")]; + put_rows(storage.path(), &[b"alpha"]); + let manifest = tree_manifest(files, "4b825dc642cb6eb9a060e54bf8d69288fbee4904"); + let identity = BaseIdentity::of(FAMILY, &manifest).unwrap(); + let BasePublication::Published { generation, .. } = + publish_base(storage.path(), FAMILY, &manifest, None).unwrap() + else { + panic!("first tree base publication lost") + }; + // The current resolver authenticates the scope with its tree identity. + let resolved = resolve_base(storage.path(), FAMILY, &identity.scope).unwrap(); + assert_eq!(resolved.identity().kind(), BaseKind::Tree); + assert_eq!(resolved.generation(), generation); + // The durable manifest, as an older reader would load it, is refused + // under both of its rules: the scope prefix and the membership digest. + let stored = ViewStore::existing_dir(resolved.view_dir().to_path_buf()) + .unwrap() + .load_manifest_v2(&generation) + .unwrap(); + assert!(!membership_only_reader_accepts(&stored, &identity.scope)); + assert!(matches!( + membership_digest(&stored), + Err(BaseRefError::Corrupt(_)) + )); + // No membership-keyed scope can name the tree-keyed manifest either. + let membership = BaseIdentity::of(FAMILY, &base_manifest(files)).unwrap(); + assert!(!membership_only_reader_accepts(&stored, &membership.scope)); + // A worker cannot register under a tree-keyed base scope. + assert!(matches!( + with_base_barrier(storage.path(), FAMILY, |tx| register_view_in( + tx, + storage.path(), + FAMILY, + &identity.scope, + Path::new("/checkout") + )), + 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 final_checkout_validation_runs_after_marker_validation_and_can_refuse_cas() { + let _serial = v3::serial_for_test(); + for remove_base_before_validation in [false, true] { + let storage = tempfile::tempdir().unwrap(); + let (identity, generation) = + publish_test_base(storage.path(), &[("src/changed.rs", b"old")]); + let header = identity.header(&generation); + let root = storage.path().join("worker-root"); + std::fs::create_dir(&root).unwrap(); + let finished = std::cell::Cell::new(false); + let validated = std::cell::Cell::new(false); + let result = publish_worker_with( + WorkerPublication { + storage: storage.path(), + family: FAMILY, + worker_scope: "worker-a", + root: &root, + base: &header, + delta: worker_delta(&identity, &generation, b"new"), + previous: None, + }, + None, + &mut |_| { + finished.set(true); + if remove_base_before_validation { + with_base_barrier(storage.path(), FAMILY, |tx| { + tx.execute( + "UPDATE members SET state='evicting' WHERE scope=?1", + [&identity.scope], + )?; + Ok(()) + })?; + } + Ok(()) + }, + &mut || { + assert!(finished.get()); + validated.set(true); + Err(BaseRefError::Unavailable( + "HEAD changed after admission".into(), + )) + }, + ); + assert!(result.is_err()); + assert_eq!(validated.get(), !remove_base_before_validation); + assert!(refs(storage.path()).is_empty()); + let worker = v3::view_dir(storage.path(), FAMILY, "worker-a").unwrap(); + assert!(ViewStore::existing_dir(worker) + .unwrap() + .current_generation_read_only() + .unwrap() + .is_none()); + } +} + +#[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..a1d2f59567 100644 --- a/crates/aft/src/views/callgraph.rs +++ b/crates/aft/src/views/callgraph.rs @@ -48,6 +48,15 @@ pub(crate) fn source_language(path: &Path) -> Option { .filter(|language| *language != crate::parser::LangId::Markdown) } +/// Whether a member needs a callgraph blob: a source file in a callgraph +/// language, or resolver configuration such as `package.json`. +pub(crate) fn applies_to(path: &RelPath) -> bool { + super::assembly::is_resolution_input(path.as_bytes()) + || std::str::from_utf8(path.as_bytes()) + .ok() + .is_some_and(|p| source_language(Path::new(p)).is_some()) +} + /// Attach evidence computed from exactly the caller's already-read byte buffer. /// No disk access, blob put, publication or membership decision occurs here. pub fn attach( @@ -61,8 +70,11 @@ pub fn attach( let blob = if language == "config" { CallgraphBlob::config(source.to_vec(), PRODUCER) } else { + // A source that is not valid UTF-8 cannot be parsed. As in checkout + // assembly, it gets the call graph of an empty file rather than + // failing the whole reconciliation. CallgraphBlob::extract( - std::str::from_utf8(source).map_err(error)?, + std::str::from_utf8(source).unwrap_or(""), language, PRODUCER, ) @@ -90,6 +102,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 +171,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, @@ -190,10 +246,7 @@ impl PlaneAdapter for CallgraphPlane { PRODUCER.into() } fn applies_to(&self, path: &RelPath) -> bool { - super::assembly::is_resolution_input(path.as_bytes()) - || std::str::from_utf8(path.as_bytes()) - .ok() - .is_some_and(|p| source_language(Path::new(p)).is_some()) + applies_to(path) } fn open_generation( &self, @@ -201,12 +254,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)? @@ -227,7 +294,13 @@ impl PlaneAdapter for CallgraphPlane { dir, generation.name(), ) - .map_err(error)?; + .map_err(|e| { + if e.is_transient_lock_contention() { + error(format!("{DERIVED_STORE_BUSY}: {e}")) + } else { + error(e) + } + })?; let retired = self .readers .write() @@ -279,6 +352,341 @@ impl PlaneAdapter for CallgraphPlane { } } } +/// Why a checkout view could not supply a reader. `busy` marks a transient +/// SQLite lock on the view's database: the graph exists and the caller should +/// retry, which is reported as building rather than unavailable. +#[derive(Clone, Debug)] +pub(crate) struct CallgraphViewError { + pub reason: String, + pub busy: bool, +} + +impl From for CallgraphViewError { + fn from(reason: String) -> Self { + Self { + reason, + busy: false, + } + } +} + +impl From<&str> for CallgraphViewError { + fn from(reason: &str) -> Self { + reason.to_owned().into() + } +} + +impl std::fmt::Display for CallgraphViewError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.reason) + } +} + +/// Prefix of the plane error for a derived database that a SQLite lock kept +/// from opening. +pub(crate) const DERIVED_STORE_BUSY: &str = "callgraph derived store is busy"; + +/// Supplies a checkout's verified call graph to one request, without +/// exposing its storage layout. Implementations never publish v1/v2 data and +/// never substitute another store when acquisition fails. +pub(crate) trait CallgraphReaderSource: Send + Sync { + fn acquire(&self) -> Result, CallgraphViewError>; + fn record_change(&self, path: &Path); + fn record_overflow(&self) {} + fn record_cookie(&self, _: &Path) {} + /// Source files parsed for call-graph evidence so far. + #[cfg(test)] + fn extraction_count(&self) -> usize { + 0 + } +} + +/// The production source: a v3-only checkout driver carrying the lexical +/// plane (bases are lexical-ready first) and the call-graph plane. +struct CheckoutCallgraphSource { + driver: Arc, + access: ViewAccess, + bridge: Arc, + storage: PathBuf, + family: String, + scope: String, + producers: super::manifest_v2::Producers, +} + +/// True when the snapshot's live entries differ from its published +/// generation: a graph opened for that generation would answer for older +/// bytes than the checkout holds. +fn has_unpublished_changes(snapshot: &Snapshot) -> bool { + snapshot + .live_entries() + .any(|(path, entry)| entry.disk != snapshot.generation().disk_state(path)) +} + +impl CallgraphReaderSource for CheckoutCallgraphSource { + fn acquire(&self) -> Result, CallgraphViewError> { + use super::first_load::FirstLoadDriver; + let installed = self.driver.installed_snapshot(); + // After a restart, start from this checkout's own published worker + // generation; verification still reconciles it against the disk. + let seed = if installed.generation().name() == "empty" { + match super::base_refs::open_foreign_v3( + &self.storage, + &self.family, + &self.scope, + &self.producers, + ) { + Ok(Some(generation)) => generation, + Ok(None) => installed.generation().clone(), + Err(error) => { + crate::slog_debug!( + "callgraph v3 seed unavailable; reconciling current tree: {}", + error + ); + installed.generation().clone() + } + } + } else { + installed.generation().clone() + }; + let (mut snapshot, revision) = self + .driver + .verified_bind(&self.access, seed) + .map_err(|error| error.to_string())?; + if has_unpublished_changes(&snapshot) { + // Publish the edits as a new worker generation (a donor clone + // patched with the diff) before the single install, as a fresh + // bootstrap does inside `verified_bind`. A second install would + // see the first one marked overflowed on a checkout without a + // steady watcher subscription and refuse its own cut. + let own = self + .driver + .build_own_generation( + &self.access, + &snapshot, + super::first_load::DERIVED_CALLGRAPH_SEEDING, + ) + .map_err(|error| error.to_string())?; + let cut = snapshot + .install_cut() + .cloned() + .ok_or("callgraph view snapshot has no install cut")?; + let mut delta = super::snapshot::LiveDelta::from_snapshot(&snapshot); + let draft = super::snapshot::derive_successor( + &delta.cut(), + own, + &super::snapshot::carry_disk_state_only, + ); + delta + .replay_and_swap(draft, &super::snapshot::carry_disk_state_only) + .map_err(|error| format!("{error:?}"))?; + snapshot = delta + .snapshot() + .with_install_cut(cut.clone()) + .with_reconciled_epoch(snapshot.reconciled_epoch().unwrap_or(cut.epoch)); + } + self.driver + .install(&self.access, &snapshot, revision) + .map_err(|error| error.to_string())?; + // Read the installed cut directly: `installed_snapshot` would mark a + // checkout without a steady watcher subscription overflowed at once. + // That mark only decides how the next bind reconciles; this cut was + // just verified against the disk. + let (snapshot, _) = self.driver.installed_cut(); + if snapshot.pending_intent().next().is_some() + || snapshot.watcher() != super::snapshot::WatcherState::Healthy + || has_unpublished_changes(&snapshot) + { + return Err(format!( + "callgraph view has unverified writes (pending={:?}, watcher={:?}, unpublished={:?})", + snapshot.pending_intent().take(3).collect::>(), + snapshot.watcher(), + snapshot + .live_entries() + .filter(|(path, entry)| entry.disk + != snapshot.generation().disk_state(path)) + .map(|(path, _)| path.clone()) + .take(3) + .collect::>(), + ) + .into()); + } + let reader = match self.bridge.adapter.reader(&self.access, &snapshot) { + Ok(reader) => reader, + Err(error) if error.reason == super::read::CALLGRAPH_DISABLED => { + return Err(error.reason.into()) + } + Err(_) => { + // A generation installed from the durable pointer (after a + // restart) has no resident reader yet; it is this checkout's + // own v3 member, so open its derived graph here. + self.bridge + .adapter + .open_generation(&self.access, snapshot.generation()) + .map_err(|error| CallgraphViewError { + busy: error.reason.starts_with(DERIVED_STORE_BUSY), + reason: error.to_string(), + })?; + self.bridge + .adapter + .reader(&self.access, &snapshot) + .map_err(|error| error.to_string())? + } + }; + Ok(reader.store.clone()) + } + + fn record_change(&self, path: &Path) { + self.driver.record_absolute_change(path); + } + fn record_overflow(&self) { + self.driver.record_watcher_overflow(); + } + fn record_cookie(&self, path: &Path) { + self.driver.record_watcher_cookie(path); + } + #[cfg(test)] + fn extraction_count(&self) -> usize { + self.bridge.extraction_count() + } +} + +/// Checkout-local call-graph authority used when views are enabled. Its v3 +/// member is separate from the lexical view's, so lexical service never waits +/// for graph extraction; both share the family's content-addressed rows. +pub(crate) struct CallgraphView { + root: PathBuf, + storage: crate::root_cache::StoragePaths, + family: String, + policy: crate::blob_store::v2::TrigramPolicy, + source: Arc, + query: std::sync::Mutex<()>, + ready: std::sync::atomic::AtomicBool, +} + +/// The v3 member scope of a checkout's call-graph view. +pub(crate) fn view_scope(root: &Path) -> String { + format!( + "{}.callgraph", + crate::path_identity::project_scope_key(root) + ) +} + +impl CallgraphView { + pub(crate) fn matches( + &self, + root: &Path, + storage: &crate::root_cache::StoragePaths, + family: &str, + policy: crate::blob_store::v2::TrigramPolicy, + ) -> bool { + self.root == root + && self.storage == *storage + && self.family == family + && self.policy == policy + } + + pub(crate) fn new( + root: PathBuf, + storage: crate::root_cache::StoragePaths, + family: String, + policy: crate::blob_store::v2::TrigramPolicy, + ) -> Result { + let scope = view_scope(&root); + let producers = super::manifest_v2::Producers { + trigram: policy.fingerprint_hex(), + semantic: None, + callgraph: PRODUCER.into(), + }; + let trigram = Arc::new(super::first_load::TrigramBridge::new( + storage.artifacts.clone(), + policy, + )); + let bridge = Arc::new(super::first_load::CallgraphBridge::default()); + let adapter: Arc = bridge.adapter.clone(); + let driver = Arc::new( + super::first_load::CheckoutDriver::new_v3( + &storage.artifacts, + &family, + &scope, + &root, + producers.clone(), + Arc::new(super::first_load::ConfiguredMembershipWalker), + vec![trigram, bridge.clone()], + ) + .map_err(|error| error.to_string())? + .with_adapters(vec![adapter]), + ); + driver.register_write_intent(); + let source = Arc::new(CheckoutCallgraphSource { + access: driver.access(), + driver, + bridge, + storage: storage.artifacts.clone(), + family: family.clone(), + scope, + producers, + }); + Ok(Self::with_source(root, storage, family, policy, source)) + } + + pub(crate) fn with_source( + root: PathBuf, + storage: crate::root_cache::StoragePaths, + family: String, + policy: crate::blob_store::v2::TrigramPolicy, + source: Arc, + ) -> Self { + Self { + root, + storage, + family, + policy, + source, + query: std::sync::Mutex::new(()), + ready: std::sync::atomic::AtomicBool::new(false), + } + } + + pub(crate) fn record_change(&self, path: &Path) { + self.ready + .store(false, std::sync::atomic::Ordering::Release); + self.source.record_change(path); + } + + pub(crate) fn record_overflow(&self) { + self.ready + .store(false, std::sync::atomic::Ordering::Release); + self.source.record_overflow(); + } + + pub(crate) fn record_cookie(&self, path: &Path) { + self.source.record_cookie(path); + } + + pub(crate) fn is_ready(&self) -> bool { + self.ready.load(std::sync::atomic::Ordering::Acquire) + } + + #[cfg(test)] + pub(crate) fn extraction_count(&self) -> usize { + self.source.extraction_count() + } + + /// Acquires a reader pinned to one verified generation. Acquisitions are + /// serialized so an installed membership and its graph come from the + /// same reconciliation. + pub(crate) fn reader(&self) -> Result, CallgraphViewError> { + let _query = self + .query + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let result = self.source.acquire(); + self.ready + .store(result.is_ok(), std::sync::atomic::Ordering::Release); + result + } +} + fn queryable_manifest( manifest: &super::snapshot::ComposedGeneration, ) -> Result { @@ -301,6 +709,176 @@ fn error(reason: impl std::fmt::Display) -> PlaneError { mod tests { use super::*; + const FILES: &[(&str, &str)] = &[ + ("a.ts", "export function target() { return 1; }\n"), + ( + "b.ts", + "import { target } from './a';\nexport function caller() { return target(); }\n", + ), + ("package.json", "{\"name\": \"donor-fixture\"}\n"), + ("notes.md", "# notes\n"), + ]; + + fn checkout(dir: &Path) -> PathBuf { + std::fs::create_dir_all(dir).unwrap(); + for (name, text) in FILES { + std::fs::write(dir.join(name), text).unwrap(); + } + std::fs::canonicalize(dir).unwrap() + } + + fn callers(store: &ReadonlyCallGraphStore, file: &str, symbol: &str) -> Vec { + let mut names: Vec<_> = store + .callers_of(Path::new(file), symbol, 1) + .unwrap() + .callers + .iter() + .map(|caller| format!("{caller:?}")) + .collect(); + names.sort(); + names + } + + fn view(root: &Path, storage: &Path) -> CallgraphView { + CallgraphView::new( + root.to_path_buf(), + crate::root_cache::StoragePaths::same(storage), + "donor-reuse".into(), + crate::blob_store::v2::TrigramPolicy { + max_file_size: 1 << 20, + }, + ) + .unwrap() + } + + fn events(dir: &Path) -> Vec { + super::super::donor::events_under(dir) + } + + #[test] + fn sealed_base_donor_serves_checkouts_without_reextraction_or_cold_builds() { + use super::super::donor::DonorEvent; + let _serial = crate::blob_store::v3::serial_for_test(); + let temp = tempfile::tempdir().unwrap(); + let storage = temp.path().join("storage"); + let first = checkout(&temp.path().join("first")); + let second = checkout(&temp.path().join("second")); + let member = |root: &Path| { + crate::blob_store::v3::view_dir(&storage, "donor-reuse", &view_scope(root)).unwrap() + }; + + // A fresh checkout bootstraps the base through the base builder, + // completes and seals its graph, then clones the donor for itself. + let first_view = view(&first, &storage); + let first_store = first_view.reader().unwrap(); + assert_eq!(callers(&first_store, "a.ts", "target").len(), 1); + assert_eq!(first_view.extraction_count(), 3, "a.ts, b.ts, package.json"); + let all = events(&storage); + assert_eq!( + all.iter().filter(|e| **e == DonorEvent::Sealed).count(), + 1, + "{all:?}" + ); + assert!( + !all.iter().any(|e| matches!(e, DonorEvent::Cold(_))), + "{all:?}" + ); + assert!(matches!( + events(&member(&first)).as_slice(), + [DonorEvent::Cloned(_)] + )); + + // The base fixed configuration metadata before its first publication. + let producers = super::super::manifest_v2::Producers { + trigram: crate::blob_store::v2::TrigramPolicy { + max_file_size: 1 << 20, + } + .fingerprint_hex(), + semantic: None, + callgraph: PRODUCER.into(), + }; + let worker = super::super::base_refs::open_foreign_v3( + &storage, + "donor-reuse", + &view_scope(&first), + &producers, + ) + .unwrap() + .unwrap(); + let base_scope = worker.base_header().unwrap().scope.clone(); + let base = + super::super::base_refs::resolve_base(&storage, "donor-reuse", &base_scope).unwrap(); + let config = RelPath::new(b"package.json".to_vec()).unwrap(); + assert!(matches!( + base.manifest().get(&config), + Some(EntryV2::Regular { + resolution_input: true, + .. + }) + )); + let donor = super::super::resolve_derived_path(base.view_dir(), base.generation()).unwrap(); + let seal = super::super::donor::verify(&donor).unwrap(); + drop((base, worker)); + + // A second checkout of the same bytes reuses the base's blobs and its + // sealed donor: nothing is parsed and no graph is built cold. + let second_view = view(&second, &storage); + let second_store = second_view.reader().unwrap(); + assert_eq!(second_view.extraction_count(), 0); + assert_eq!( + callers(&second_store, "a.ts", "target"), + callers(&first_store, "a.ts", "target") + ); + assert!(matches!( + events(&member(&second)).as_slice(), + [DonorEvent::Cloned(_)] + )); + + // An edit publishes a new worker generation: another fresh clone of + // the donor, patched with the diff; only the edited file is parsed. + std::fs::write( + second.join("c.ts"), + "import { caller } from './b';\nexport function top() { return caller(); }\n", + ) + .unwrap(); + second_view.record_change(&second.join("c.ts")); + let edited = second_view.reader().unwrap(); + assert_eq!(callers(&edited, "b.ts", "caller").len(), 1); + assert!(callers(&first_store, "b.ts", "caller").is_empty()); + assert_eq!(second_view.extraction_count(), 1); + let second_events = events(&member(&second)); + assert_eq!(second_events.len(), 2, "{second_events:?}"); + assert!(second_events + .iter() + .all(|e| matches!(e, DonorEvent::Cloned(_)))); + // Readers of the donor never see a write. + super::super::donor::verify_fingerprint(&donor, &seal).unwrap(); + + // Deleting a file the worker added leaves no tombstone behind: the + // base never had it. + std::fs::remove_file(second.join("c.ts")).unwrap(); + second_view.record_change(&second.join("c.ts")); + let deleted = second_view.reader().unwrap(); + assert!(callers(&deleted, "b.ts", "caller").is_empty()); + // A reader acquired before the deletion keeps answering for its own + // generation: publication never reclaims a generation a reader holds. + assert_eq!(callers(&edited, "b.ts", "caller").len(), 1); + assert_eq!( + callers(&deleted, "a.ts", "target"), + callers(&first_store, "a.ts", "target") + ); + + // A restart reopens the published worker generation without parsing + // or rebuilding anything. + drop((first_store, first_view)); + let restarted = view(&first, &storage); + let reopened = restarted.reader().unwrap(); + assert_eq!(callers(&reopened, "a.ts", "target").len(), 1); + assert_eq!(restarted.extraction_count(), 0); + assert_eq!(events(&member(&first)).len(), 1); + assert!(!storage.join("blobs/v2").exists()); + } + #[test] fn markdown_is_not_a_callgraph_input_but_resolution_config_is() { let plane = CallgraphPlane::default(); 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/donor.rs b/crates/aft/src/views/donor.rs new file mode 100644 index 0000000000..f1258406cb --- /dev/null +++ b/crates/aft/src/views/donor.rs @@ -0,0 +1,502 @@ +//! Sealed call-graph donors. +//! +//! A base's derived call graph is built privately before the base pointer can +//! name it. Before that pointer moves, the builder seals the file: it switches +//! it to `journal_mode=DELETE`, checkpoints, syncs it, proves that no `-wal`, +//! `-shm` or `-journal` file remains, and atomically publishes a seal record +//! next to it (`-seal`). From then on the file never changes: +//! +//! * every reader opens it with SQLite `immutable=1` (no locks, no WAL); +//! * a write open through `IdentityConnection` is refused while the seal +//! record exists (see `refuse_sealed_write`); +//! * a worker checkout clones it into a fresh, never-reused private name +//! (`clonefile` on APFS, `FICLONE` reflink on Linux, otherwise a plain copy +//! whose reason is logged) and patches the clone with the manifest diff. +//! +//! A live SQLite database is never cloned or copied here: `clone_sealed` +//! refuses a donor whose seal record is missing or no longer matches. + +use std::io::{Read as _, Write as _}; +use std::path::{Path, PathBuf}; +use std::sync::Mutex; + +use serde::{Deserialize, Serialize}; + +/// Appended to a sealed database's path to name its seal record. The +/// generation sweep treats it as one of the database's sibling files, so the +/// record is reclaimed together with its donor. +pub(crate) const SEAL_SUFFIX: &str = "-seal"; +const SEAL_FORMAT: u32 = 1; + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub(crate) struct SealRecord { + pub format: u32, + /// BLAKE3 of the sealed file's bytes, as hex. + pub fingerprint: String, + pub size: u64, + pub journal_mode: String, +} + +/// How a worker's private copy of a donor was made. +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) enum CloneMechanism { + /// APFS `clonefile(2)`: copy-on-write, no data copied. + #[cfg_attr(not(target_os = "macos"), allow(dead_code))] + Clonefile, + /// Linux `FICLONE` reflink: copy-on-write, no data copied. + #[cfg_attr(not(target_os = "linux"), allow(dead_code))] + Reflink, + /// The filesystem cannot share extents; the sealed bytes were copied. + /// Safe because a sealed file never changes. Carries the reason. + Copy(String), +} + +/// Why a worker graph was built cold instead of from a sealed donor. This is +/// the closed list callers report; ordinary fixtures never produce one. +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) enum ColdReason { + /// The base has no sealed donor (for example, a base published before + /// sealing existed). + NoSealedDonor, + /// The donor's seal record is missing, unreadable or does not match. + SealInvalid(String), + /// The donor's recorded manifest fingerprint is not the base's. + BaseFingerprintMismatch, +} + +/// One donor decision for a worker graph, recorded per member directory so +/// tests can count reuse without reading global state of other fixtures. +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) enum DonorEvent { + Sealed, + Cloned(CloneMechanism), + Cold(ColdReason), +} + +static EVENTS: Mutex> = Mutex::new(Vec::new()); + +pub(crate) fn record(dir: &Path, event: DonorEvent) { + match &event { + DonorEvent::Cloned(CloneMechanism::Copy(reason)) => log::info!( + "views donor clone fell back to a copy dir={} reason={reason}", + dir.display() + ), + DonorEvent::Cold(reason) => log::info!( + "views worker graph built cold dir={} reason={reason:?}", + dir.display() + ), + _ => {} + } + EVENTS + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .push((dir.to_path_buf(), event)); +} + +/// Donor events recorded for directories under `prefix`. +#[cfg(test)] +pub(crate) fn events_under(prefix: &Path) -> Vec { + EVENTS + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .iter() + .filter(|(dir, _)| dir.starts_with(prefix)) + .map(|(_, event)| event.clone()) + .collect() +} + +pub(crate) fn seal_path(database: &Path) -> PathBuf { + let mut name = database.as_os_str().to_owned(); + name.push(SEAL_SUFFIX); + PathBuf::from(name) +} + +pub(crate) fn is_sealed(database: &Path) -> bool { + seal_path(database).exists() +} + +/// Write opens of a sealed database are refused: its readers open it with +/// `immutable=1`, so any change would be invisible to them or corrupt them. +pub(crate) fn refuse_sealed_write(database: &Path) -> rusqlite::Result<()> { + if is_sealed(database) { + return Err(rusqlite::Error::SqliteFailure( + rusqlite::ffi::Error::new(rusqlite::ffi::SQLITE_READONLY), + Some(format!( + "sealed donor {} cannot be opened for writing", + database.display() + )), + )); + } + Ok(()) +} + +fn sibling(database: &Path, suffix: &str) -> PathBuf { + let mut name = database.as_os_str().to_owned(); + name.push(suffix); + PathBuf::from(name) +} + +fn error(step: &str, reason: impl std::fmt::Display) -> String { + format!("donor {step}: {reason}") +} + +/// SQLite header bytes 18 and 19 are the write and read format versions: +/// 1 means a rollback journal, 2 means WAL. +fn header_journal_mode(database: &Path) -> Result<&'static str, String> { + let mut header = [0u8; 20]; + std::fs::File::open(database) + .and_then(|mut file| file.read_exact(&mut header)) + .map_err(|e| error("header", e))?; + match (header[18], header[19]) { + (1, 1) => Ok("delete"), + (2, 2) => Ok("wal"), + other => Err(error( + "header", + format!("unknown format versions {other:?}"), + )), + } +} + +fn fingerprint(database: &Path) -> Result<(String, u64), String> { + let mut file = std::fs::File::open(database).map_err(|e| error("fingerprint", e))?; + let mut hasher = blake3::Hasher::new(); + let mut buffer = vec![0u8; 1 << 16]; + let mut size = 0u64; + loop { + let read = file + .read(&mut buffer) + .map_err(|e| error("fingerprint", e))?; + if read == 0 { + break; + } + size += read as u64; + hasher.update(&buffer[..read]); + } + Ok((hasher.finalize().to_hex().to_string(), size)) +} + +fn no_journal_siblings(database: &Path) -> Result<(), String> { + for suffix in ["-wal", "-shm", "-journal"] { + if sibling(database, suffix).exists() { + return Err(error("seal", format!("{suffix} file remains"))); + } + } + Ok(()) +} + +/// Seals a private, fully built database that no reader can reach yet. +/// Must run before any pointer names it: the journal-mode switch needs +/// exclusive access, and the sync below opens a plain descriptor, which is +/// only safe while no SQLite connection in this process has the file open. +pub(crate) fn seal(database: &Path) -> Result { + { + let connection = + crate::db::file_identity::IdentityConnection::open(database, "views::donor::seal") + .map_err(|e| error("open", e))?; + connection + .query_row("PRAGMA wal_checkpoint(TRUNCATE)", [], |_| Ok(())) + .map_err(|e| error("checkpoint", e))?; + let mode: String = connection + .query_row("PRAGMA journal_mode=DELETE", [], |row| row.get(0)) + .map_err(|e| error("journal_mode", e))?; + if !mode.eq_ignore_ascii_case("delete") { + return Err(error("journal_mode", mode)); + } + } + { + let _files = crate::db::file_identity::filesystem_guard(); + if crate::db::file_identity::open_connections(database) != 0 { + return Err(error("seal", "the database is still open")); + } + // Windows refuses FlushFileBuffers on a read-only handle; the views + // helper opens the file for writing there. + super::sync_file(database).map_err(|e| error("fsync", e))?; + } + no_journal_siblings(database)?; + let journal_mode = header_journal_mode(database)?; + if journal_mode != "delete" { + return Err(error("seal", format!("header names {journal_mode}"))); + } + let (fingerprint, size) = fingerprint(database)?; + let record = SealRecord { + format: SEAL_FORMAT, + fingerprint, + size, + journal_mode: journal_mode.into(), + }; + let path = seal_path(database); + let temporary = sibling(&path, &format!(".tmp.{}", super::base_refs::fresh_nonce())); + let bytes = serde_json::to_vec(&record).map_err(|e| error("record", e))?; + let written = (|| -> std::io::Result<()> { + let mut file = std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&temporary)?; + file.write_all(&bytes)?; + file.sync_all()?; + std::fs::rename(&temporary, &path) + })(); + if let Err(e) = written { + let _ = std::fs::remove_file(&temporary); + return Err(error("record", e)); + } + if let Some(parent) = path.parent() { + super::sync_directory(parent).map_err(|e| error("record", e))?; + } + Ok(record) +} + +/// Reads the seal record and checks it against the file's size, header and +/// siblings. The full fingerprint is checked by `verify_fingerprint`. +pub(crate) fn verify(database: &Path) -> Result { + let bytes = std::fs::read(seal_path(database)).map_err(|e| error("record", e))?; + let record: SealRecord = serde_json::from_slice(&bytes).map_err(|e| error("record", e))?; + if record.format != SEAL_FORMAT || record.journal_mode != "delete" { + return Err(error("record", "unsupported seal record")); + } + let size = std::fs::metadata(database) + .map_err(|e| error("metadata", e))? + .len(); + if size != record.size { + return Err(error("verify", "size differs from the seal record")); + } + no_journal_siblings(database)?; + if header_journal_mode(database)? != "delete" { + return Err(error("verify", "the header is not in rollback mode")); + } + Ok(record) +} + +/// Checks the file's bytes against the recorded fingerprint. +pub(crate) fn verify_fingerprint(database: &Path, record: &SealRecord) -> Result<(), String> { + let (fingerprint, size) = fingerprint(database)?; + if fingerprint != record.fingerprint || size != record.size { + return Err(error("verify", "bytes differ from the seal record")); + } + Ok(()) +} + +/// Clones a sealed donor into `destination`, which must not exist yet: a +/// clone name is fresh and never reused. On failure nothing is left behind. +pub(crate) fn clone_sealed(donor: &Path, destination: &Path) -> Result { + let record = verify(donor)?; + if destination.exists() { + return Err(error("clone", "the destination name was used before")); + } + let result = clone_file(donor, destination).and_then(|mechanism| { + let size = std::fs::metadata(destination) + .map_err(|e| error("clone", e))? + .len(); + if size != record.size { + return Err(error("clone", "the copy has the wrong size")); + } + if cfg!(test) { + verify_fingerprint(destination, &record)?; + } + super::sync_file(destination).map_err(|e| error("fsync", e))?; + if let Some(parent) = destination.parent() { + super::sync_directory(parent).map_err(|e| error("fsync", e))?; + } + Ok(mechanism) + }); + if result.is_err() { + let _ = std::fs::remove_file(destination); + } + result +} + +#[cfg(test)] +thread_local! { + /// Forces the copy fallback so tests can cover it on clone-capable disks. + static FORCE_COPY: std::cell::Cell = const { std::cell::Cell::new(false) }; +} + +#[cfg(test)] +pub(crate) fn force_copy_for_test(force: bool) { + FORCE_COPY.with(|cell| cell.set(force)); +} + +fn forced_copy() -> bool { + #[cfg(test)] + { + FORCE_COPY.with(std::cell::Cell::get) + } + #[cfg(not(test))] + { + false + } +} + +fn copy(donor: &Path, destination: &Path, reason: String) -> Result { + let mut source = std::fs::File::open(donor).map_err(|e| error("copy", e))?; + let mut target = std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(destination) + .map_err(|e| error("copy", e))?; + std::io::copy(&mut source, &mut target).map_err(|e| error("copy", e))?; + Ok(CloneMechanism::Copy(reason)) +} + +#[cfg(target_os = "macos")] +fn clone_file(donor: &Path, destination: &Path) -> Result { + use std::os::unix::ffi::OsStrExt; + if forced_copy() { + return copy(donor, destination, "forced by test".into()); + } + let source = + std::ffi::CString::new(donor.as_os_str().as_bytes()).map_err(|e| error("clone", e))?; + let target = std::ffi::CString::new(destination.as_os_str().as_bytes()) + .map_err(|e| error("clone", e))?; + // `CLONE_NOFOLLOW` from : refuse a symlinked source. + const CLONE_NOFOLLOW: u32 = 0x0001; + // Both C strings outlive the call. + if unsafe { libc::clonefile(source.as_ptr(), target.as_ptr(), CLONE_NOFOLLOW) } == 0 { + return Ok(CloneMechanism::Clonefile); + } + let reason = std::io::Error::last_os_error(); + copy(donor, destination, format!("clonefile: {reason}")) +} + +#[cfg(target_os = "linux")] +fn clone_file(donor: &Path, destination: &Path) -> Result { + use std::os::fd::AsRawFd; + if forced_copy() { + return copy(donor, destination, "forced by test".into()); + } + // A plain descriptor on the donor is safe: every opener of a sealed file + // uses `immutable=1`, so no SQLite connection holds a POSIX lock on it + // that closing this descriptor could release. + let source = std::fs::File::open(donor).map_err(|e| error("clone", e))?; + let target = std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(destination) + .map_err(|e| error("clone", e))?; + // SAFETY: both descriptors are open for the duration of the call. + if unsafe { libc::ioctl(target.as_raw_fd(), libc::FICLONE, source.as_raw_fd()) } == 0 { + return Ok(CloneMechanism::Reflink); + } + let reason = std::io::Error::last_os_error(); + drop(target); + let _ = std::fs::remove_file(destination); + copy(donor, destination, format!("FICLONE: {reason}")) +} + +#[cfg(not(any(target_os = "macos", target_os = "linux")))] +fn clone_file(donor: &Path, destination: &Path) -> Result { + let reason = if forced_copy() { + "forced by test" + } else { + "no copy-on-write clone on this platform" + }; + copy(donor, destination, reason.into()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn database(dir: &Path) -> PathBuf { + let path = dir.join("derived-donor.sqlite"); + let connection = + crate::db::file_identity::IdentityConnection::open(&path, "views::donor::tests") + .unwrap(); + connection + .execute_batch( + "PRAGMA journal_mode=WAL; CREATE TABLE t(v); INSERT INTO t VALUES (1), (2);", + ) + .unwrap(); + drop(connection); + path + } + + #[test] + fn sealing_leaves_a_rollback_file_with_a_record_and_refuses_write_opens() { + let temp = tempfile::tempdir().unwrap(); + let path = database(temp.path()); + assert!(!is_sealed(&path)); + let record = seal(&path).unwrap(); + assert!(is_sealed(&path)); + assert_eq!(record.journal_mode, "delete"); + for suffix in ["-wal", "-shm", "-journal"] { + assert!(!sibling(&path, suffix).exists(), "{suffix} remains"); + } + assert_eq!(header_journal_mode(&path).unwrap(), "delete"); + assert_eq!(verify(&path).unwrap(), record); + verify_fingerprint(&path, &record).unwrap(); + let refused = + crate::db::file_identity::IdentityConnection::open(&path, "views::donor::tests") + .unwrap_err(); + assert!(refused.to_string().contains("sealed donor"), "{refused}"); + let refused = crate::db::file_identity::IdentityConnection::open_with_flags( + &path, + rusqlite::OpenFlags::SQLITE_OPEN_READ_WRITE, + "views::donor::tests", + ) + .unwrap_err(); + assert!(refused.to_string().contains("sealed donor"), "{refused}"); + // Read-only opens stay allowed, and an immutable reader sees the rows. + let uri = format!( + "file:{}?mode=ro&immutable=1", + path.to_string_lossy().replace('\\', "/") + ); + let reader = rusqlite::Connection::open_with_flags( + uri, + rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_URI, + ) + .unwrap(); + let rows: i64 = reader + .query_row("SELECT COUNT(*) FROM t", [], |row| row.get(0)) + .unwrap(); + assert_eq!(rows, 2); + drop(reader); + assert!(!sibling(&path, "-wal").exists()); + } + + #[test] + fn clones_use_fresh_names_keep_the_donor_unchanged_and_refuse_tampering() { + let temp = tempfile::tempdir().unwrap(); + let path = database(temp.path()); + let record = seal(&path).unwrap(); + for (name, forced) in [("clone-a.sqlite", false), ("clone-b.sqlite", true)] { + force_copy_for_test(forced); + let destination = temp.path().join(name); + let mechanism = clone_sealed(&path, &destination).unwrap(); + force_copy_for_test(false); + if forced { + assert_eq!(mechanism, CloneMechanism::Copy("forced by test".into())); + } + verify_fingerprint(&destination, &record).unwrap(); + // The clone is an ordinary private database: writable, unsealed. + assert!(!is_sealed(&destination)); + let connection = crate::db::file_identity::IdentityConnection::open( + &destination, + "views::donor::tests", + ) + .unwrap(); + connection.execute("INSERT INTO t VALUES (3)", []).unwrap(); + drop(connection); + // A name is never reused, even after a successful clone. + assert!(clone_sealed(&path, &destination) + .unwrap_err() + .contains("used before")); + } + verify_fingerprint(&path, &record).unwrap(); + // A donor whose bytes no longer match its record is never cloned. + std::fs::OpenOptions::new() + .append(true) + .open(&path) + .unwrap() + .write_all(b"tamper") + .unwrap(); + let destination = temp.path().join("clone-c.sqlite"); + assert!(clone_sealed(&path, &destination).is_err()); + assert!(!destination.exists()); + // An unsealed (live) database is never cloned. + let live = temp.path().join("live"); + std::fs::create_dir_all(&live).unwrap(); + let live = database(&live); + assert!(clone_sealed(&live, &temp.path().join("clone-d.sqlite")).is_err()); + } +} 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..f9e4bbf155 100644 --- a/crates/aft/src/views/first_load.rs +++ b/crates/aft/src/views/first_load.rs @@ -5,6 +5,7 @@ use std::collections::BTreeMap; use std::sync::Arc; use crate::blob_store::v2::FamilyPlane; +pub use crate::watcher_backend::cookie::{CookieObservation, CookieTicket}; use super::contracts::{LoaderStage, PlaneError, ViewAccess}; use super::manifest_v2::Producers; @@ -13,6 +14,14 @@ use super::snapshot::{ }; use super::RelPath; +#[cfg(test)] +#[path = "first_load/overflow_recovery_tests.rs"] +mod overflow_recovery_tests; + +#[cfg(test)] +#[path = "first_load/publication_fence_tests.rs"] +mod publication_fence_tests; + /// A complete strict content walk under the checkout's current ignore rules. /// The revision must change for every delivered edit, write intent, watcher gap, /// or membership change; a partial walk must return an error, not this value. @@ -564,6 +573,14 @@ impl SiblingLoader { &self, access: &ViewAccess, base: Arc, + ) -> Result<(Snapshot, u64), PlaneError> { + Self::reconciled_snapshot(self.driver.as_ref(), access, base) + } + + fn reconciled_snapshot( + driver: &dyn FirstLoadDriver, + access: &ViewAccess, + base: Arc, ) -> Result<(Snapshot, u64), PlaneError> { for _ in 0..3 { // Read before the walk, so an ignore edit racing the walk shows up @@ -575,8 +592,8 @@ impl SiblingLoader { } _ => None, }; - let checkout = self.driver.reconcile(access)?; - if checkout.cut.is_none() && self.driver.revision(access) != checkout.revision { + let checkout = driver.reconcile(access)?; + if checkout.cut.is_none() && driver.revision(access) != checkout.revision { continue; } let mut delta = super::snapshot::LiveDelta::new(Arc::clone(&base)); @@ -593,7 +610,11 @@ impl SiblingLoader { } } for (path, entry) in checkout.entries { - delta.apply(path, entry); + if entry.reconciliation == super::readiness::ReconciledDelta::Pending { + delta.record_intent(path); + } else { + delta.apply(path, entry); + } } for (path, patch) in checkout.readiness { delta.set_readiness(path, patch); @@ -1224,6 +1245,51 @@ mod tests { pub trait CompositePlane: Send + Sync { fn plane(&self) -> FamilyPlane; fn applies_to(&self, path: &RelPath) -> bool; + fn bootstrap_policy(&self) -> Option { + None + } + + /// Whether this plane can complete its entries of a freshly built v3 + /// base itself (see [`Self::complete_v3_base`]). Fresh bootstrap refuses a + /// nonlexical plane that cannot, rather than strand its base entries. + fn completes_fresh_base(&self) -> bool { + false + } + + /// Completes this plane's pending entries of the claimed base from the + /// attachments the bootstrap's strict reconciliation computed for the + /// same bytes, and publishes the result as a readiness successor. + fn complete_v3_base( + &self, + claim: &super::base_builder::BuildClaim, + observed: &BTreeMap, + ) -> Result<(), PlaneError> { + let _ = (claim, observed); + Ok(()) + } + + /// Derive a payload key without extraction. Planes with deferred extraction + /// may leave this absent and keep their own cheap attachment format. + fn key( + &self, + _path: &RelPath, + _content: crate::blob_store::v2::ContentHash, + ) -> Option { + None + } + fn cached_attachment( + &self, + _content: crate::blob_store::v2::ContentHash, + key: crate::blob_store::v2::FamilyKey, + ) -> super::snapshot::PlaneAttachment { + Arc::new(CachedAttachment(key)) + } + fn attachment_key( + &self, + _attachment: &super::snapshot::PlaneAttachment, + ) -> Option { + None + } /// Called during strict reconciliation with the very bytes that were hashed. /// May compute extraction data, but must not write artifacts or read source. @@ -1257,12 +1323,53 @@ 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 /// adapters must return walker errors rather than silently truncating membership. pub trait MembershipWalker: Send + Sync { fn files(&self, root: &std::path::Path) -> Result, PlaneError>; + fn changed_files( + &self, + _root: &std::path::Path, + _candidates: &std::collections::BTreeSet, + ) -> Result>, PlaneError> { + Ok(None) + } } #[derive(Clone)] @@ -1280,15 +1387,17 @@ pub struct InstalledBundle { own_installed: bool, } -/// In-memory evidence that a previous source read still describes this file. -/// No stamps survive a restart. Platforms without a kernel change time and -/// file identity keep reading bytes instead of trusting mtime alone. +/// Evidence that a previous source read still describes this file. V3 stores +/// stable observations with their published generation. Missing change-time +/// or file-identity support means reading bytes instead of trusting mtime alone. #[derive(Clone)] struct ObservedStat { stamp: crate::watcher::FileStamp, #[cfg(unix)] device: u64, recorded_ns: u128, + delivery_seq: u64, + native: Option, } /// Publish stat evidence together with the extracted content it validates. @@ -1298,6 +1407,9 @@ struct ObservedStat { struct ObservedCheckout { entries: BTreeMap, stats: BTreeMap, + evidence: BTreeMap, + guard: Option>, + subscription: Option, } impl ObservedStat { @@ -1310,10 +1422,15 @@ impl ObservedStat { metadata.dev() }, recorded_ns, + delivery_seq: 0, + native: None, } } fn reusable(&self, current: &Self) -> bool { + if let (Some(old), Some(current)) = (&self.native, ¤t.native) { + return old.reusable(current, self.recorded_ns); + } #[cfg(unix)] { self.stamp == current.stamp && self.device == current.device @@ -1360,6 +1477,84 @@ mod stat_tests { .unwrap(); } + #[test] + fn strict_reconcile_without_a_frontier_verifies_membership() { + let root = tempfile::tempdir().unwrap(); + let storage = tempfile::tempdir().unwrap(); + std::fs::write(root.path().join("file.rs"), b"fn fresh() {}\n").unwrap(); + let (driver, access) = driver(root.path(), storage.path()); + let base = driver.installed_snapshot().generation().clone(); + let (snapshot, _) = SiblingLoader::reconciled_snapshot(&driver, &access, base).unwrap(); + let path = RelPath::new(b"file.rs".to_vec()).unwrap(); + assert_eq!( + snapshot.disk_state(&path), + super::super::snapshot::DiskState::of_bytes(b"fn fresh() {}\n") + ); + assert_eq!(driver.take_reconcile_work().reads, 1); + assert!(snapshot.install_cut().is_some()); + } + + #[test] + fn unstable_strict_reads_retry_three_times_and_name_pending_without_bytes() { + let root = tempfile::tempdir().unwrap(); + let storage = tempfile::tempdir().unwrap(); + std::fs::write(root.path().join("file.rs"), b"initial").unwrap(); + let (driver, access) = driver(root.path(), storage.path()); + *driver.read_hook.lock().unwrap() = Some(Box::new(|file, attempt| { + std::fs::write(file, vec![b'x'; 20 + attempt]).unwrap(); + })); + let base = driver.installed_snapshot().generation().clone(); + let (snapshot, _) = SiblingLoader::reconciled_snapshot(&driver, &access, base).unwrap(); + let path = RelPath::new(b"file.rs".to_vec()).unwrap(); + assert_eq!(snapshot.pending_intent().collect::>(), vec![&path]); + assert_eq!( + snapshot.disk_state(&path), + super::super::snapshot::DiskState::Absent + ); + assert_eq!(driver.take_reconcile_work().reads, 3); + assert!(driver.observed.lock().unwrap().stats.is_empty()); + } + + #[test] + fn event_during_exception_read_invalidates_even_unchanged_stats() { + let root = tempfile::tempdir().unwrap(); + let storage = tempfile::tempdir().unwrap(); + std::fs::write(root.path().join("file.rs"), b"unchanged").unwrap(); + let (driver, access) = driver(root.path(), storage.path()); + let driver = Arc::new(driver); + let weak = Arc::downgrade(&driver); + let path = RelPath::new(b"file.rs".to_vec()).unwrap(); + let changed = path.clone(); + *driver.read_hook.lock().unwrap() = Some(Box::new(move |_, attempt| { + if attempt == 0 { + weak.upgrade().unwrap().record_change(changed.clone()); + } + })); + driver.reconcile(&access).unwrap(); + assert_eq!(driver.take_reconcile_work().reads, 2); + assert_eq!(driver.observed.lock().unwrap().stats[&path].delivery_seq, 1); + } + + #[test] + fn overflow_updates_the_canonical_epoch_and_preserves_bundle_readers() { + let root = tempfile::tempdir().unwrap(); + let storage = tempfile::tempdir().unwrap(); + let (driver, access) = driver(root.path(), storage.path()); + let before = driver.installed_snapshot(); + driver.record_watcher_overflow(); + let after = driver.installed_snapshot(); + assert_eq!( + after.watcher(), + super::super::snapshot::WatcherState::Overflowed + ); + assert!(after.install_cut().unwrap().epoch > before.install_cut().unwrap().epoch); + assert!(Arc::ptr_eq( + after.readers().unwrap(), + before.readers().unwrap() + )); + assert!(driver.validate_cut(&access, &before, 0).is_err()); + } + #[test] fn stat_reconcile_detects_unreported_same_size_backdated_edit() { let root = tempfile::tempdir().unwrap(); @@ -1449,10 +1644,41 @@ pub struct CheckoutDriver { // without holding query locks during I/O or plane-reader retirement. publication_transition: std::sync::Mutex<()>, retry: std::sync::atomic::AtomicBool, + watcher_cookies: crate::watcher_backend::cookie::CookieBarrier, + delivery: std::sync::Mutex, + v3_reconcile: std::sync::atomic::AtomicBool, + advisory_token: std::sync::Mutex>, + base_claim: std::sync::Mutex>, + #[cfg(test)] + read_hook: std::sync::Mutex>, #[cfg(test)] work: std::sync::Mutex, } +#[derive(Default)] +struct DeliveryState { + seq: u64, + paths: BTreeMap, +} + +#[cfg(test)] +type ReadHook = Box; + +enum StableRead { + Absent, + Bytes(Vec, ObservedStat), + Pending, +} + +#[derive(Clone)] +struct CachedAttachment(crate::blob_store::v2::FamilyKey); + +fn v3_contains(route: &super::snapshot::V3Route, key: &crate::blob_store::v2::FamilyKey) -> bool { + crate::blob_store::v3::PlaneReader::open_existing(&route.storage, &route.family, key.plane()) + .and_then(|reader| reader.contains(key)) + .unwrap_or(false) +} + struct PreparedReaders { seq: u64, readers: Arc, @@ -1467,14 +1693,459 @@ pub(crate) struct ReconcileWork { pub reads: usize, pub content_hash_bytes: usize, pub attachments: usize, + /// Paths whose read was skipped on a tree-keyed base's blob receipt. + pub tree_receipts: usize, +} + +/// Why this driver cannot own and complete a fresh base. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum FreshBootstrapRefusal { + NonLexicalPlane(FamilyPlane), + MissingLexicalPlane, + LexicalProducerMismatch, +} + +impl std::fmt::Display for FreshBootstrapRefusal { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::NonLexicalPlane(plane) => write!( + f, + "fresh_bootstrap_nonlexical_plane: {} requires its base completion coordinator", + plane.as_str() + ), + Self::MissingLexicalPlane => f.write_str("fresh_bootstrap_missing_lexical_plane"), + Self::LexicalProducerMismatch => { + f.write_str("fresh_bootstrap_lexical_producer_mismatch") + } + } + } } +impl std::error::Error for FreshBootstrapRefusal {} impl CheckoutDriver { + /// Pure eligibility check for callers choosing between new and existing + /// runtime paths. The bind hook reports the same refusal through PlaneError. + pub fn fresh_bootstrap_policy( + &self, + ) -> Result { + if let Some(plane) = self + .planes + .iter() + .find(|plane| plane.plane() != FamilyPlane::Trigram && !plane.completes_fresh_base()) + .map(|plane| plane.plane()) + { + return Err(FreshBootstrapRefusal::NonLexicalPlane(plane)); + } + if self.producers.semantic.is_some() { + return Err(FreshBootstrapRefusal::NonLexicalPlane( + FamilyPlane::Semantic, + )); + } + // A registered call-graph producer needs a registered plane that + // completes the base's graph entries. + if self.producers.callgraph != super::semantic_runtime::UNREGISTERED_PRODUCER + && !self.planes.iter().any(|plane| { + plane.plane() == FamilyPlane::Callgraph && plane.completes_fresh_base() + }) + { + return Err(FreshBootstrapRefusal::NonLexicalPlane( + FamilyPlane::Callgraph, + )); + } + let policy = self + .planes + .iter() + .find_map(|plane| plane.bootstrap_policy()) + .ok_or(FreshBootstrapRefusal::MissingLexicalPlane)?; + if self.producers.trigram != policy.fingerprint_hex() { + return Err(FreshBootstrapRefusal::LexicalProducerMismatch); + } + Ok(policy) + } + fn stable_read( + &self, + path: &RelPath, + absolute: &std::path::Path, + recorded_ns: u128, + first_seq: u64, + first_stat: Option, + ) -> Result { + for attempt in 0..3 { + let seq = if attempt == 0 { + first_seq + } else { + self.delivery + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .seq + }; + let before = if attempt == 0 { + first_stat.clone() + } else { + self.source_stat(absolute, recorded_ns)? + }; + let bytes = super::assembly::read_working_tree_file(absolute) + .map_err(|error| SiblingLoader::error(error.to_string()))?; + #[cfg(test)] + { + if let Some(bytes) = &bytes { + let mut work = self.work.lock().unwrap(); + work.reads += 1; + let _ = bytes; + } + if let Some(hook) = &*self.read_hook.lock().unwrap() { + hook(absolute, attempt); + } + } + let after = self.source_stat(absolute, recorded_ns)?; + let delivery = self + .delivery + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let unchanged = delivery.paths.get(path).copied().unwrap_or(0) <= seq; + drop(delivery); + match (before, after, bytes) { + (None, None, None) if unchanged => return Ok(StableRead::Absent), + (Some(mut before), Some(after), Some(bytes)) if unchanged => { + let stable = before.stamp == after.stamp && before.native == after.native; + #[cfg(unix)] + let stable = stable && before.device == after.device; + if stable { + before.delivery_seq = seq; + return Ok(StableRead::Bytes(bytes, before)); + } + } + _ => {} + } + // A changing file must not contribute bytes to a ready generation. + // Back off between bounded attempts; tests inject changes directly. + #[cfg(not(test))] + if attempt < 2 { + std::thread::sleep(std::time::Duration::from_millis(1 << attempt)); + } + } + Ok(StableRead::Pending) + } + + /// Verifies this checkout against `base` and returns a reconciliation cut. + /// Unsupported freshness proofs use strict reconciliation instead. + /// A fresh checkout bootstraps a lexical-ready v3 base and publishes a v3 + /// worker over it; it never falls back to legacy storage. Installation is + /// left to the caller and still validates the cut's epoch and bundle identity. + /// Fresh bases are lexical-only until nonlexical completion can be scheduled; + /// already-built bases can be verified for any registered plane. + pub fn verified_bind( + &self, + access: &ViewAccess, + base: Arc, + ) -> Result<(Snapshot, u64), PlaneError> { + self.check_owner(access)?; + if !self.owner.registry().is_v3_only() { + return Err(SiblingLoader::error( + "verified_bind requires a v3-only registration", + )); + } + let bootstrap = base.base_header().is_none(); + if bootstrap { + self.fresh_bootstrap_policy() + .map_err(|reason| SiblingLoader::error(reason.to_string()))?; + } else { + base.ensure_producers(&self.producers).map_err(|error| { + SiblingLoader::stage_error(LoaderStage::ProducerValidation, error.to_string()) + })?; + } + if let Some(token) = self + .advisory_token + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .take() + { + let route = self.owner.v3_route(); + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64; + // Token rejection is advisory; every outcome follows the same + // verification path. Tree-base warming is not enabled yet. + let _ = super::registry::consume_view_token( + &route.storage, + &route.family, + &token, + self.owner.root(), + now, + ); + } + self.v3_reconcile + .store(true, std::sync::atomic::Ordering::Release); + let base = if bootstrap { + self.bootstrap_v3_base(access, base)? + } else { + base + }; + let (snapshot, revision) = SiblingLoader::reconciled_snapshot(self, access, base)?; + if !bootstrap { + return Ok((snapshot, revision)); + } + let own = self.build_own_generation(access, &snapshot, DERIVED_CALLGRAPH_SEEDING); + // Every plane of a fresh bootstrap has completed its base entries by + // now (lexical in the build, others in `bootstrap_v3_base`). + if self + .planes + .iter() + .all(|plane| plane.plane() == FamilyPlane::Trigram || plane.completes_fresh_base()) + { + if let Some(claim) = self + .base_claim + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .take() + { + claim + .release() + .map_err(|e| SiblingLoader::error(e.to_string()))?; + } + } + let own = own?; + let mut delta = LiveDelta::from_snapshot(&snapshot); + let draft = super::snapshot::derive_successor( + &delta.cut(), + own, + &super::snapshot::carry_disk_state_only, + ); + delta + .replay_and_swap(draft, &super::snapshot::carry_disk_state_only) + .map_err(|error| SiblingLoader::error(format!("{error:?}")))?; + Ok(( + delta + .snapshot() + .with_install_cut(snapshot.install_cut().unwrap().clone()) + .with_reconciled_epoch( + snapshot + .reconciled_epoch() + .unwrap_or(snapshot.install_cut().unwrap().epoch), + ), + revision, + )) + } + + fn bootstrap_v3_base( + &self, + access: &ViewAccess, + seed: Arc, + ) -> Result, PlaneError> { + let policy = self + .fresh_bootstrap_policy() + .map_err(|reason| SiblingLoader::error(reason.to_string()))?; + let (checkout, _) = SiblingLoader::reconciled_snapshot(self, access, seed)?; + if checkout.pending_intent().next().is_some() { + return Err(SiblingLoader::error( + "v3 bootstrap has pending unstable paths", + )); + } + let route = self.owner.v3_route(); + let ignore_fingerprint = checkout + .install_cut() + .and_then(|cut| cut.verification.as_ref()) + .map(|g| g.policy.digest.clone()); + let header = super::manifest_v2::ManifestHeader { + producers: self.producers.clone(), + head_tree: None, + ignore_fingerprint: ignore_fingerprint.clone(), + segment: None, + }; + let mut manifest = super::manifest_v2::ManifestV2::new_v3(header, None, Default::default()); + for (path, state) in checkout.membership() { + if let super::snapshot::DiskState::Present { content, size } = state { + manifest + .insert( + path.clone(), + super::base_builder::base_entry(&path, content, size, Default::default()), + ) + .map_err(|e| SiblingLoader::error(e.to_string()))?; + } + } + let expected = super::base_refs::BaseIdentity::of(&route.family, &manifest) + .map_err(|e| SiblingLoader::error(e.to_string()))?; + // A Git-proven tree-keyed base is preferred; without that proof the + // membership-keyed base below is resolved or built exactly as before. + let paths: Vec<_> = manifest.entries().map(|(path, _)| path.clone()).collect(); + let tree_base = super::tree_base::bootstrap( + super::tree_base::TreeBaseInput { + route: &route, + root: self.owner.root(), + paths: &paths, + policy, + producers: &self.producers, + ignore_fingerprint: ignore_fingerprint.clone(), + }, + &self.base_claim, + ); + let resolved = + match super::tree_base::resolve_preferring(tree_base, &route, &expected.scope) { + Ok(base) => base, + Err(_) => { + match super::base_builder::build_base( + super::base_builder::BaseBuildInput { + storage: &route.storage, + family: &route.family, + root: self.owner.root(), + paths: &paths, + policy, + semantic_producer: self.producers.semantic.clone(), + callgraph_producer: self.producers.callgraph.clone(), + ignore_fingerprint, + history: None, + budget: Default::default(), + }, + None, + ) + .map_err(|e| SiblingLoader::error(e.to_string()))? + { + super::base_builder::BaseBuild::Published { + identity, claim, .. + } => { + *self + .base_claim + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(claim); + super::base_refs::resolve_base( + &route.storage, + &route.family, + &identity.scope, + ) + .map_err(|e| SiblingLoader::error(e.to_string()))? + } + super::base_builder::BaseBuild::Busy => super::base_refs::resolve_base( + &route.storage, + &route.family, + &expected.scope, + ) + .map_err(|_| { + SiblingLoader::error( + "v3 base build busy; retain direct-scan coverage and retry", + ) + })?, + super::base_builder::BaseBuild::Strict(reason) => { + return Err(SiblingLoader::error(format!( + "v3 base admission refused: {reason}" + ))) + } + } + } + }; + let resolved = self.complete_fresh_base(&route, resolved)?; + let header = resolved.header(); + let generation = resolved.into_generation(&route.storage, &route.family); + let manifest = super::manifest_v2::ManifestV2::new_v3( + generation.header().clone(), + Some(header), + Default::default(), + ); + let seed_route = generation.v3_route().unwrap().clone(); + let composed = + ComposedGeneration::compose(generation.name(), manifest, generation.clone(), None) + .map_err(|e| SiblingLoader::error(e.to_string()))?; + Ok(Arc::new(composed.with_v3_route(seed_route))) + } + + /// Completes the nonlexical planes of a fresh base before a worker + /// publishes over it, so the worker's graph composes a ready base. The + /// base's build claim is the completion lock: this driver uses the claim + /// it took building the base, or takes over a dead builder's claim. A live + /// foreign builder still owns the completion, so the bind refuses. + fn complete_fresh_base( + &self, + route: &super::snapshot::V3Route, + base: super::base_refs::AuthenticatedBase, + ) -> Result { + let completing: Vec<_> = self + .planes + .iter() + .filter(|plane| plane.completes_fresh_base()) + .filter(|plane| { + base.manifest().entries().any(|(_, entry)| { + entry + .plane_state(plane.plane()) + .is_some_and(super::readiness::PlaneState::is_pending) + }) + }) + .cloned() + .collect(); + if completing.is_empty() { + return Ok(base); + } + let scope = base.scope().to_owned(); + drop(base); + let mut held = self + .base_claim + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if held.as_ref().is_none_or(|claim| claim.scope() != scope) { + match super::base_builder::claim_base(&route.storage, &route.family, &scope, None) + .map_err(|e| SiblingLoader::error(e.to_string()))? + { + super::base_builder::ClaimOutcome::Claimed(claim) => { + if let Some(previous) = held.replace(claim) { + let _ = previous.release(); + } + } + outcome => { + return Err(SiblingLoader::error(format!( + "v3 base plane completion is owned elsewhere: {outcome:?}" + ))) + } + } + } + let claim = held.as_ref().expect("claim held above"); + let observed = self + .observed + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .entries + .clone(); + for plane in completing { + plane.complete_v3_base(claim, &observed)?; + } + super::base_refs::resolve_base(&route.storage, &route.family, &scope) + .map_err(|e| SiblingLoader::error(e.to_string())) + } + + #[allow(clippy::too_many_arguments)] + pub fn new_v3( + storage: &std::path::Path, + family: &str, + scope: &str, + root: &std::path::Path, + producers: Producers, + walker: Arc, + planes: Vec>, + ) -> Result { + let owner = super::registry::ViewRegistration::register_v3(storage, family, scope, root) + .map_err(|e| SiblingLoader::error(e.to_string()))?; + let driver = Self::new(owner, producers, None, walker, planes); + driver + .v3_reconcile + .store(true, std::sync::atomic::Ordering::Release); + Ok(driver) + } + + pub fn set_view_token(&self, token: String) { + *self + .advisory_token + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(token); + } + + pub fn access(&self) -> ViewAccess { + ViewAccess::Owner(self.owner.clone()) + } + pub fn take_revalidated_job(&self) -> bool { self.retry.swap(false, std::sync::atomic::Ordering::AcqRel) } pub fn installed_bundle(&self) -> Arc { + self.check_subscription(); self.installed .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) @@ -1509,6 +2180,7 @@ impl CheckoutDriver { epoch: delta.epoch(), version: delta.version(), intent_version: delta.snapshot().intent_version(), + verification: None, }); Self { owner, @@ -1519,6 +2191,13 @@ impl CheckoutDriver { adapters: Vec::new(), publish_fence: std::sync::RwLock::new(None), observed: Default::default(), + watcher_cookies: Default::default(), + delivery: Default::default(), + v3_reconcile: Default::default(), + advisory_token: Default::default(), + base_claim: Default::default(), + #[cfg(test)] + read_hook: Default::default(), installed: std::sync::Mutex::new(Arc::new(InstalledBundle { seq: 0, composed, @@ -1568,6 +2247,15 @@ impl CheckoutDriver { .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); let installed = Arc::make_mut(&mut installed); + { + let mut delivery = self + .delivery + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + delivery.seq += 1; + let seq = delivery.seq; + delivery.paths.insert(path.clone(), seq); + } installed.revision += 1; // Keep prior membership until reconciliation, but mark the edited path // as unreflected so queries cannot report stale data as installed. @@ -1581,6 +2269,8 @@ impl CheckoutDriver { delta.record_intent(path); // Membership is unchanged, so are the ignore rules it was walked under. delta.record_root_ignore_files(installed.snapshot.root_ignore_files().cloned()); + // One known-path event cannot prove that delivery remained continuous + // for the whole watcher stream. installed.snapshot = delta .snapshot() .with_readers(installed.readers.clone()) @@ -1590,19 +2280,75 @@ impl CheckoutDriver { epoch: delta.epoch(), version: delta.version(), intent_version: delta.snapshot().intent_version(), + verification: installed + .snapshot + .install_cut() + .and_then(|cut| cut.verification.clone()), }); } - fn check_owner(&self, access: &ViewAccess) -> Result<(), PlaneError> { - match access { - ViewAccess::Owner(owner) - if owner.family() == self.owner.family() && owner.scope() == self.owner.scope() => - { - Ok(()) - } - _ => Err(SiblingLoader::error( - "checkout driver refuses foreign writes", - )), + /// Invalidates stale builds and indexed membership until strict reconciliation. + pub fn record_watcher_overflow(&self) { + crate::watcher_backend::subscription::gap(self.owner.root()); + let mut delta = self + .live + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let mut installed = self + .installed + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let installed = Arc::make_mut(&mut installed); + installed.revision += 1; + delta.bump_epoch(); + delta.set_watcher(super::snapshot::WatcherState::Overflowed); + installed.snapshot = delta + .snapshot() + .with_readers(installed.readers.clone()) + .with_install_cut(InstallCut { + seq: installed.seq, + resident: installed.composed.clone(), + epoch: delta.epoch(), + version: delta.version(), + intent_version: delta.snapshot().intent_version(), + verification: installed + .snapshot + .install_cut() + .and_then(|cut| cut.verification.clone()), + }); + } + + /// Creates an exclusive root cookie. This does not enable verified bind; + /// the caller must establish subscription coverage and reject delivery gaps. + pub fn begin_watcher_cookie(&self) -> std::io::Result { + self.watcher_cookies.begin(self.owner.root()) + } + + /// Called only after preceding dispatch paths have entered the driver. + pub(crate) fn record_watcher_cookie(&self, path: &std::path::Path) { + let installed = self + .installed + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + self.watcher_cookies.observe( + path, + CookieObservation { + revision: installed.revision, + watcher: installed.snapshot.watcher(), + }, + ); + } + + fn check_owner(&self, access: &ViewAccess) -> Result<(), PlaneError> { + match access { + ViewAccess::Owner(owner) + if owner.family() == self.owner.family() && owner.scope() == self.owner.scope() => + { + Ok(()) + } + _ => Err(SiblingLoader::error( + "checkout driver refuses foreign writes", + )), } } @@ -1628,7 +2374,12 @@ impl CheckoutDriver { } match live.watcher() { super::snapshot::WatcherState::Healthy => Ok(()), - super::snapshot::WatcherState::Reconciling if strict => Ok(()), + super::snapshot::WatcherState::Reconciling + | super::snapshot::WatcherState::Overflowed + if strict => + { + Ok(()) + } _ => Err(SiblingLoader::error(format!( "{CHECKOUT_CHANGED_BEFORE_INSTALL}: watcher is not healthy or strictly reconciled" ))), @@ -1690,15 +2441,130 @@ impl FirstLoadDriver for CheckoutDriver { .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) .clone(); - let mut entries = BTreeMap::new(); - let mut stats = BTreeMap::new(); + let v3 = self.v3_reconcile.load(std::sync::atomic::Ordering::Acquire) + || installed.generation().v3_route().is_some(); + let guard = if v3 { + Some(Arc::new( + super::verification::BindGuard::capture(self.owner.root(), None) + .map_err(|e| SiblingLoader::error(e.to_string()))?, + )) + } else { + None + }; + let route = self.owner.v3_route(); + let durable = if v3 && previous.entries.is_empty() { + super::base_refs::open_foreign_v3( + &route.storage, + &route.family, + &route.scope, + &self.producers, + ) + .ok() + .flatten() + } else { + None + }; + let persisted = durable + .as_ref() + .and_then(|generation| { + super::verification::read_evidence( + &route.member_dir(), + generation.name(), + guard.as_ref().unwrap(), + ) + }) + .unwrap_or_default(); + // A proven tree-keyed base adds its blob receipts as candidates; + // membership-keyed bases add none. + let (persisted, _receipt_paths) = super::tree_base::with_receipts( + persisted, + durable.as_deref(), + guard.as_deref(), + &route, + ); + let subscription = crate::watcher_backend::subscription::current(self.owner.root()); + let mut changed = pending.clone(); + changed.extend( + installed + .live_entries() + .filter(|(_, entry)| { + entry.reconciliation != super::readiness::ReconciledDelta::Reconciled + }) + .map(|(path, _)| path.clone()), + ); + let mut candidates = std::collections::BTreeSet::new(); + let mut policy_or_directory = false; + for path in &changed { + let relative = super::segment_store::rel_path_to_os(path) + .map_err(|e| SiblingLoader::error(e.to_string()))?; + let absolute = self.owner.root().join(relative); + let name = absolute.file_name().and_then(|n| n.to_str()).unwrap_or(""); + policy_or_directory |= matches!( + name, + ".gitattributes" | ".gitignore" | ".ignore" | ".aftignore" + ) || path.as_bytes().starts_with(b".git/") + || super::assembly::is_resolution_input(path.as_bytes()) + || std::fs::symlink_metadata(&absolute).is_ok_and(|m| m.is_dir()); + candidates.insert(absolute); + } + let continuous = v3 + && subscription.is_some() + && subscription == previous.subscription + && installed.watcher() == super::snapshot::WatcherState::Healthy + && guard + .as_ref() + .zip(previous.guard.as_ref()) + .is_some_and(|(now, old)| now.root == old.root && now.policy == old.policy) + && guard + .as_ref() + .is_some_and(|guard| guard.policy.reusable && guard.steady_supported) + && !policy_or_directory; + let changed_files = if continuous { + self.walker.changed_files(self.owner.root(), &candidates)? + } else { + None + }; + let incremental = changed_files.is_some(); + let files = match changed_files { + Some(files) => files, + None => self.walker.files(self.owner.root())?, + }; + let mut entries = if incremental { + previous.entries.clone() + } else { + BTreeMap::new() + }; + let mut stats = if incremental { + previous.stats.clone() + } else { + BTreeMap::new() + }; + let mut evidence = if incremental { + previous.evidence.clone() + } else { + BTreeMap::new() + }; + if incremental { + for path in &changed { + entries.remove(path); + stats.remove(path); + evidence.remove(path); + let mut prefix = path.as_bytes().to_vec(); + prefix.push(b'/'); + entries.retain(|p, _| !p.as_bytes().starts_with(&prefix)); + stats.retain(|p, _| !p.as_bytes().starts_with(&prefix)); + evidence.retain(|p, _| !p.as_bytes().starts_with(&prefix)); + } + } #[cfg(test)] { - self.work.lock().unwrap().walks += 1; + if !incremental { + self.work.lock().unwrap().walks += 1; + } } - // Full configured membership, even for copied and non-git roots. Stat - // reuse avoids source I/O, not the discovery of unreported changes. - for absolute in self.walker.files(self.owner.root())? { + // Restart and delivery gaps require full membership. A continuous + // subscription checks only reported files under the same walker policy. + for absolute in files { let relative = absolute .strip_prefix(self.owner.root()) .map_err(|error| SiblingLoader::error(error.to_string()))?; @@ -1706,12 +2572,12 @@ impl FirstLoadDriver for CheckoutDriver { .map_err(|error| SiblingLoader::error(error.to_string()))?; // Round down to the second so coarse filesystem timestamps in the // observation's second are also treated as racily clean. - let recorded_ns = u128::from( - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_secs(), - ) * 1_000_000_000; + let recorded_ns = super::verification::now_ns(); + let delivery_seq = self + .delivery + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .seq; let before = self.source_stat(&absolute, recorded_ns)?; if !pending.contains(&path) { if let (Some(old), Some(cached), Some(current)) = ( @@ -1719,47 +2585,162 @@ impl FirstLoadDriver for CheckoutDriver { previous.stats.get(&path), before.as_ref(), ) { - if cached.reusable(current) { + let delivered = self + .delivery + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .paths + .get(&path) + .copied() + .unwrap_or(0); + let policy_matches = !v3 + || guard.as_ref().is_some_and(|guard| { + guard.policy.reusable + && previous.guard.as_ref().is_some_and(|old| { + old.policy == guard.policy && old.root == guard.root + }) + }); + let keys_present = old.attachments.values().all(|a| { + a.downcast_ref::() + .is_none_or(|cached| v3_contains(&route, &cached.0)) + }); + if cached.reusable(current) + && delivered <= cached.delivery_seq + && policy_matches + && keys_present + { let mut entry = old.clone(); entry.seq = revision; entries.insert(path.clone(), entry); - stats.insert(path, cached.clone()); + stats.insert(path.clone(), cached.clone()); + if let Some(record) = previous.evidence.get(&path) { + evidence.insert(path.clone(), record.clone()); + } continue; } } } - let Some(bytes) = super::assembly::read_working_tree_file(&absolute) - .map_err(|error| SiblingLoader::error(error.to_string()))? - else { - continue; - }; - let after = self.source_stat(&absolute, recorded_ns)?; - if let (Some(before), Some(after)) = (before, after) { - let stable = before.stamp == after.stamp; - #[cfg(unix)] - let stable = stable && before.device == after.device; - if stable { - stats.insert(path.clone(), before); + if !pending.contains(&path) && guard.as_ref().is_some_and(|g| g.policy.reusable) { + if let (Some(record), Some(current), Some(generation)) = + (persisted.get(&path), before.as_ref(), durable.as_ref()) + { + if record.stat.reusable( + current.native.as_ref().unwrap_or(&record.stat), + record.observed_ns, + ) && current.native.is_some() + && record.content().is_some_and(|content| { + generation.disk_state(&path) + == (super::snapshot::DiskState::Present { + content, + size: record.size, + }) + }) + && self.planes.iter().filter(|p| p.applies_to(&path)).all(|p| { + record.key(p.plane()).is_some_and(|key| { + matches!(generation.plane_readiness(&path, p.plane()), Some(super::readiness::PlaneState::Ready { key: ready }) if *ready == key.to_hex()) + && record.content().is_some_and(|content| p.key(&path, content).is_none_or(|expected| expected == key)) + && v3_contains(&route, &key) + }) + }) + && self + .delivery + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .paths + .get(&path) + .copied() + .unwrap_or(0) + == 0 + { + let mut entry = LiveEntry::new(generation.disk_state(&path), revision); + entry.reconciliation = super::readiness::ReconciledDelta::Reconciled; + for plane in &self.planes { + if plane.applies_to(&path) { + let key = record.key(plane.plane()).unwrap(); + entry.attachments.insert( + plane.plane(), + plane.cached_attachment(record.content().unwrap(), key), + ); + } + } + let mut current = current.clone(); + current.recorded_ns = record.observed_ns; + #[cfg(test)] + if _receipt_paths.contains(&path) { + self.work.lock().unwrap().tree_receipts += 1; + } + stats.insert(path.clone(), current); + entries.insert(path.clone(), entry); + evidence.insert(path, record.clone()); + continue; + } } } + let (bytes, stat_evidence) = + match self.stable_read(&path, &absolute, recorded_ns, delivery_seq, before)? { + StableRead::Absent => continue, + StableRead::Bytes(bytes, evidence) => (bytes, evidence), + StableRead::Pending => { + let mut entry = LiveEntry::new(installed.disk_state(&path), revision); + entry.reconciliation = super::readiness::ReconciledDelta::Pending; + entries.insert(path, entry); + continue; + } + }; + stats.insert(path.clone(), stat_evidence); + let content = crate::blob_store::v2::ContentHash::of(&bytes); #[cfg(test)] { - let mut work = self.work.lock().unwrap(); - work.reads += 1; - work.content_hash_bytes += bytes.len(); + self.work.lock().unwrap().content_hash_bytes += bytes.len(); } - let mut entry = LiveEntry::new(super::snapshot::DiskState::of_bytes(&bytes), revision); + let mut entry = LiveEntry::new( + super::snapshot::DiskState::Present { + content, + size: bytes.len() as u64, + }, + revision, + ); + entry.reconciliation = super::readiness::ReconciledDelta::Reconciled; + let mut keys = BTreeMap::new(); for plane in &self.planes { if plane.applies_to(&path) { + let key = plane.key(&path, content); + if v3 && key.is_some_and(|key| v3_contains(&route, &key)) { + let key = key.unwrap(); + keys.insert(format!("{:?}", plane.plane()), key.to_hex()); + entry + .attachments + .insert(plane.plane(), plane.cached_attachment(content, key)); + continue; + } #[cfg(test)] { self.work.lock().unwrap().attachments += 1; } - entry - .attachments - .insert(plane.plane(), plane.attachment(&path, &bytes)?); + let attachment = plane.attachment(&path, &bytes)?; + if let Some(key) = key.or_else(|| plane.attachment_key(&attachment)) { + keys.insert(format!("{:?}", plane.plane()), key.to_hex()); + } + entry.attachments.insert(plane.plane(), attachment); } } + if let (Some(guard), Some(stat)) = + (&guard, stats.get(&path).and_then(|s| s.native.clone())) + { + evidence.insert( + path.clone(), + super::verification::Evidence { + version: 1, + stat, + observed_ns: recorded_ns, + content: content.to_hex(), + size: bytes.len() as u64, + git_oid: None, + policy: guard.policy.digest.clone(), + keys, + }, + ); + } entries.insert(path, entry); } *self @@ -1768,11 +2749,18 @@ impl FirstLoadDriver for CheckoutDriver { .unwrap_or_else(std::sync::PoisonError::into_inner) = ObservedCheckout { entries: entries.clone(), stats, + evidence, + guard: guard.clone(), + subscription, }; + let mut cut = installed.install_cut().cloned(); + if let Some(cut) = &mut cut { + cut.verification = guard; + } Ok(ReconciledCheckout { revision, entries, - cut: installed.install_cut().cloned(), + cut, readiness: installed.readiness_patches().clone(), publication_base: Some(publication_base), }) @@ -1869,6 +2857,7 @@ impl FirstLoadDriver for CheckoutDriver { epoch: successor.epoch(), version: successor.version(), intent_version: successor.snapshot().intent_version(), + verification: cut.verification.clone(), }); if snapshot.reconciled_epoch().is_some() { if let Some(base) = snapshot.publication_base() { @@ -1947,8 +2936,13 @@ 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()); + } + if self.owner.registry().is_v3_only() { + return Err(SiblingLoader::error( + "v3-only driver requires verified_bind bootstrap before publication", + )); } super::io::WORK .own_generation_folds @@ -2115,12 +3109,300 @@ 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()))?; + // A deleted path the base never had (a file this worker added and then + // removed) leaves the delta without an entry or a tombstone: a + // tombstone may only name an inherited base entry. + for (path, entry) in snapshot.live_entries() { + if entry.disk == super::snapshot::DiskState::Absent + && generation + .base() + .is_some_and(|base| base.entry(path).is_none()) + { + delta.inherit(path.clone(), None); + } + } + 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 fence = self + .publish_fence + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clone(); + let outcome = super::base_refs::publish_worker_guarded( + 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)) + })?; + } + if let Some(guard) = snapshot + .install_cut() + .and_then(|cut| cut.verification.as_ref()) + { + let evidence = self + .observed + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .evidence + .clone(); + super::verification::write_evidence(&route, published, guard, &evidence) + .map_err(super::base_refs::BaseRefError::Io)?; + } + Ok(()) + }, + &mut || { + if let Some(guard) = snapshot + .install_cut() + .and_then(|cut| cut.verification.as_ref()) + { + guard + .validate(self.owner.root()) + .map_err(super::base_refs::BaseRefError::Io)?; + } + self.validate_cut(access, snapshot, 0) + .map_err(|error| super::base_refs::BaseRefError::Unavailable(error.reason)) + }, + &mut |commit| { + let mut actual = None; + let mut publish = || { + let _transition = self + .publication_transition + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + actual = + Some(commit().map_err(|error| SiblingLoader::error(error.to_string()))?); + Ok(()) + }; + match &fence { + Some(fence) => fence.publish(&mut publish), + None => publish(), + } + .map_err(|error| super::base_refs::BaseRefError::Unavailable(error.reason))?; + drop(publish); + actual.ok_or_else(|| { + super::base_refs::BaseRefError::Unavailable( + "publication fence did not invoke CAS".into(), + ) + }) + }, + ); + 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, access: &ViewAccess, plane: FamilyPlane, ) -> (Snapshot, Vec) { + self.check_subscription(); let installed = self .installed .lock() @@ -2156,7 +3438,14 @@ impl QueryState for CheckoutDriver { gaps.extend( snapshot .live_entries() - .filter(|(path, _)| applicable(path)) + .filter(|(path, entry)| { + applicable(path) + && (entry.reconciliation != super::readiness::ReconciledDelta::Reconciled + || !matches!( + snapshot.plane_readiness(path, plane), + Some(super::readiness::PlaneState::Ready { .. }) + )) + }) .map(|(path, _)| path.clone()), ); for (path, _entry) in snapshot.generation().entries() { @@ -3072,40 +4361,193 @@ 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()); - let base = driver.installed_snapshot().generation().clone(); + // 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.header().clone(), - Some(super::super::manifest_v2::BaseHeader { - scope: "base".into(), - membership_digest: "11".repeat(32), - identity_digest: "22".repeat(32), - bound_generation: base.name().into(), - }), + base.manifest().header().clone(), + Some(base.header()), Default::default(), ); - 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 + 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(); + // The install check needs an identity recorded by the driver. Reconcile + // and install here; the name `installed-worker` alone is not that proof. + let (reconciled, revision) = SiblingLoader::reconciled_snapshot( + driver.as_ref(), + &access, + snapshot.generation().clone(), + ) + .unwrap(); + driver.install(&access, &reconciled, revision).unwrap(); + let snapshot = + snapshot.with_install_cut(driver.installed_snapshot().install_cut().unwrap().clone()); + let published = driver .build_own_generation(&access, &snapshot, false) - .unwrap_err(); - assert!(error - .reason - .contains("durable base publication binding is not wired")); - assert!(store.current_generation().unwrap().is_none()); - assert!(!storage.path().join("blobs/v3").exists()); - } - - #[test] - fn actual_cas_losers_never_install_foreign_verification_and_request_fresh_jobs() { - for equivalent in [false, true] { - let root = tempfile::tempdir().unwrap(); - let storage = tempfile::tempdir().unwrap(); + .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()); + let base = driver.installed_snapshot().generation().clone(); + let worker = super::super::manifest_v2::ManifestV2::new_v3( + base.header().clone(), + Some(super::super::manifest_v2::BaseHeader { + 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(), + }), + Default::default(), + ); + let composed = Arc::new(ComposedGeneration::compose("worker", worker, base, None).unwrap()); + let snapshot = LiveDelta::new(composed).snapshot(); + let store = driver.owner.view_store().unwrap(); + let error = driver + .build_own_generation(&access, &snapshot, false) + .unwrap_err(); + assert!(error.reason.contains("base unavailable"), "{error:?}"); + assert!(store.current_generation().unwrap().is_none()); + assert!(!storage.path().join("blobs/v3/family/views/local").exists()); + } + + #[test] + fn actual_cas_losers_never_install_foreign_verification_and_request_fresh_jobs() { + for equivalent in [false, true] { + let root = tempfile::tempdir().unwrap(); + let storage = tempfile::tempdir().unwrap(); let (driver, _, _, access) = fixture(root.path(), storage.path()); let loader = SiblingLoader::new(driver.clone(), vec![]); loader.load(&access).unwrap(); @@ -3151,9 +4593,146 @@ mod composite_tests { #[derive(Default)] pub struct CallgraphBridge { pub adapter: Arc, + /// Source files this bridge parsed for call-graph evidence. Unchanged + /// files whose blob already exists reuse it and are not counted. + extractions: std::sync::atomic::AtomicUsize, +} + +impl CallgraphBridge { + pub fn extraction_count(&self) -> usize { + self.extractions.load(std::sync::atomic::Ordering::Relaxed) + } +} + +/// The sealed donor of `base`'s derived graph, or why there is none. +fn sealed_donor(base: &ComposedGeneration) -> Result { + use super::donor::ColdReason; + let route = base.v3_route().ok_or(ColdReason::NoSealedDonor)?; + let donor = super::resolve_derived_path(&route.member_dir(), base.name()) + .map_err(|_| ColdReason::NoSealedDonor)?; + if !super::donor::is_sealed(&donor) { + return Err(ColdReason::NoSealedDonor); + } + super::donor::verify(&donor).map_err(ColdReason::SealInvalid)?; + Ok(donor) +} + +fn remove_private_database(database: &std::path::Path) { + for suffix in ["", "-wal", "-shm", "-journal"] { + let mut name = database.as_os_str().to_owned(); + name.push(suffix); + let _ = std::fs::remove_file(std::path::PathBuf::from(name)); + } } impl CompositePlane for CallgraphBridge { + fn completes_fresh_base(&self) -> bool { + true + } + + fn complete_v3_base( + &self, + claim: &super::base_builder::BuildClaim, + observed: &BTreeMap, + ) -> Result<(), PlaneError> { + let error = |reason: String| PlaneError { + plane: Some(FamilyPlane::Callgraph), + reason, + }; + let base = super::base_refs::resolve_base(claim.storage(), claim.family(), claim.scope()) + .map_err(|e| error(e.to_string()))?; + let route = super::base_refs::route(claim.storage(), claim.family(), claim.scope()); + let mut keys = Vec::new(); + let mut payloads = Vec::new(); + for (path, entry) in base.manifest().entries() { + if !entry + .plane_state(FamilyPlane::Callgraph) + .is_some_and(super::readiness::PlaneState::is_pending) + { + continue; + } + // The base was read independently of the reconciliation; only + // evidence for the very bytes the base names may complete it. + let observation = observed.get(path).filter(|live| { + matches!( + (live.disk, entry), + ( + super::snapshot::DiskState::Present { content, size }, + super::manifest_v2::EntryV2::Regular { content: c, size: s, .. }, + ) if content == *c && size == *s + ) + }); + let attachment = observation + .and_then(|live| live.attachments.get(&FamilyPlane::Callgraph)) + .ok_or_else(|| { + error(format!( + "{path:?}: base member changed before its graph was completed" + )) + })?; + if let Some(cached) = attachment.downcast_ref::() { + keys.push((path.clone(), cached.0)); + } else if let Some(attachment) = + attachment.downcast_ref::() + { + keys.push((path.clone(), attachment.key)); + payloads.push(( + attachment.key, + attachment + .blob + .to_bytes() + .map_err(|e| error(e.to_string()))?, + )); + } else { + return Err(error(format!("{path:?}: callgraph attachment missing"))); + } + } + drop(base); + let mut live = + crate::pins::LivePin::create_v3(claim.storage(), claim.family(), claim.scope()) + .map_err(|e| error(e.to_string()))?; + live.protect(&keys.iter().map(|(_, key)| *key).collect::>()) + .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(claim.storage(), claim.family(), request, None, |p| { + for (key, bytes) in &payloads { + p.put(key, bytes, schema)?; + } + Ok(()) + }) + .map_err(|e| error(e.to_string()))?; + } + for (_, key) in &keys { + if !v3_contains(&route, key) { + return Err(error( + "base callgraph row disappeared before publication".into(), + )); + } + } + super::base_builder::publish_successor( + claim, + |manifest| { + for (path, key) in &keys { + if let Some(super::manifest_v2::EntryV2::Regular { planes, .. }) = + manifest.get_mut(path) + { + planes.callgraph = Some(super::readiness::PlaneState::ready(key)); + } + } + }, + None, + ) + .map_err(|e| error(e.to_string()))?; + drop(live); + Ok(()) + } + fn plane(&self) -> FamilyPlane { FamilyPlane::Callgraph } @@ -3161,6 +4740,30 @@ impl CompositePlane for CallgraphBridge { use super::contracts::PlaneAdapter; self.adapter.applies_to(path) } + fn key( + &self, + path: &RelPath, + content: crate::blob_store::v2::ContentHash, + ) -> Option { + let language = if super::assembly::is_resolution_input(path.as_bytes()) { + "config".to_owned() + } else { + let path = std::str::from_utf8(path.as_bytes()).ok()?; + format!( + "{:?}", + crate::parser::detect_language(std::path::Path::new(path))? + ) + .to_lowercase() + }; + Some(crate::blob_store::v2::FamilyKey::from( + &crate::blob_store::CallgraphKey::from_source_digest( + *content.as_bytes(), + language, + super::callgraph::PRODUCER, + ) + .full_key(), + )) + } fn attachment( &self, path: &RelPath, @@ -3185,6 +4788,8 @@ impl CompositePlane for CallgraphBridge { .to_lowercase() }; let mut entry = LiveEntry::new(super::snapshot::DiskState::of_bytes(bytes), 0); + self.extractions + .fetch_add(1, std::sync::atomic::Ordering::Relaxed); let attachment = super::callgraph::attach(&mut entry, bytes, &language)?; Ok(Arc::new(attachment)) } @@ -3308,6 +4913,208 @@ 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; + } + if let Some(cached) = entry + .attachments + .get(&FamilyPlane::Callgraph) + .and_then(|a| a.downcast_ref::()) + { + live.protect(&[cached.0]) + .map_err(|e| error(e.to_string()))?; + if !v3_contains(route, &cached.0) { + return Err(error( + "cached callgraph row disappeared before protection".into(), + )); + } + if let Some(super::manifest_v2::EntryV2::Regular { + planes, + resolution_input, + .. + }) = delta.get_mut(path) + { + planes.callgraph = Some(super::readiness::PlaneState::ready(&cached.0)); + *resolution_input = super::assembly::is_resolution_input(path.as_bytes()); + } + 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()))?; + // Prefer a private clone of the base's sealed donor, patched with the + // manifest diff, over rebuilding the whole graph. `staging` is a fresh + // name, so the clone never reuses another file's name. + match sealed_donor(base) { + Ok(donor) => { + let base_projected = super::callgraph::project_manifest(base)?; + let projected = super::callgraph::project_manifest(&composed)?; + let mechanism = super::donor::clone_sealed(&donor, &database).map_err(error)?; + match super::materialization::apply_manifest_diff_from_blob_reader( + &database, + &base_projected, + &projected, + &reader, + ) { + Ok(_) => { + super::donor::record(&dir, super::donor::DonorEvent::Cloned(mechanism)); + return Ok(()); + } + Err(e) if super::materialization::is_base_fingerprint_mismatch(&e) => { + remove_private_database(&database); + super::donor::record( + &dir, + super::donor::DonorEvent::Cold( + super::donor::ColdReason::BaseFingerprintMismatch, + ), + ); + } + Err(e) => { + remove_private_database(&database); + return Err(error(e.to_string())); + } + } + } + Err(reason) => { + super::donor::record(&dir, super::donor::DonorEvent::Cold(reason)); + } + } + 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)] @@ -3761,6 +5568,20 @@ thread_local! { pub(crate) static MEMBERSHIP_WALKS: std::cell::Cell = const { std::cell::Cell::new(0) }; } impl MembershipWalker for ConfiguredMembershipWalker { + fn changed_files( + &self, + root: &std::path::Path, + candidates: &std::collections::BTreeSet, + ) -> Result>, PlaneError> { + let listing = crate::search_index::project_walk_lists(root, candidates); + if candidates + .iter() + .any(|path| listing.gap_for(path).is_some()) + { + return Ok(None); + } + Ok(Some(listing.listed.into_iter().collect())) + } fn files(&self, root: &std::path::Path) -> Result, PlaneError> { #[cfg(test)] MEMBERSHIP_WALKS.with(|walks| walks.set(walks.get() + 1)); @@ -3848,12 +5669,28 @@ impl TrigramBridge { } } impl CompositePlane for TrigramBridge { + fn bootstrap_policy(&self) -> Option { + Some(self.policy) + } fn plane(&self) -> FamilyPlane { FamilyPlane::Trigram } fn applies_to(&self, path: &RelPath) -> bool { !path.is_synthetic() } + fn key( + &self, + _: &RelPath, + content: crate::blob_store::v2::ContentHash, + ) -> Option { + Some( + crate::blob_store::v2::TrigramKey { + content, + policy: self.policy, + } + .family_key(), + ) + } fn attachment( &self, _: &RelPath, @@ -3893,9 +5730,93 @@ 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> { + if delta.header().producers.trigram != self.policy.fingerprint_hex() { + return Err(SiblingLoader::error("trigram producer mismatch")); + } + let mut keys = Vec::new(); + let mut payloads = Vec::new(); + for (path, entry) in delta.entries() { + let super::manifest_v2::EntryV2::Regular { content, .. } = entry else { + continue; + }; + let key = self.key(path, *content).unwrap(); + live.protect(&[key]) + .map_err(|e| SiblingLoader::error(e.to_string()))?; + if !v3_contains(route, &key) { + let attachment = observed + .get(path) + .filter(|e| e.disk == super::snapshot::DiskState::in_generation(delta, path)) + .and_then(|e| e.attachments.get(&FamilyPlane::Trigram)) + .and_then(|a| a.downcast_ref::()) + .filter(|a| a.content == *content && a.policy == self.policy.fingerprint()) + .ok_or_else(|| { + SiblingLoader::error( + "trigram row missing; reconcile source before retrying", + ) + })?; + payloads.push((key, attachment.payload.encode())); + } + keys.push((path.clone(), key)); + } + if !payloads.is_empty() { + let request = payloads + .iter() + .map(|(_, p)| p.len() as u64) + .sum::() + .saturating_mul(2) + .saturating_add(1 << 20); + crate::blob_store::v3::publish(&route.storage, &route.family, request, None, |p| { + for (key, payload) in &payloads { + p.put(key, payload, FamilyPlane::Trigram.payload_schema())?; + } + Ok(()) + }) + .map_err(|e| SiblingLoader::error(e.to_string()))?; + } + for (path, key) in keys { + if let Some(super::manifest_v2::EntryV2::Regular { planes, .. }) = delta.get_mut(&path) + { + planes.trigram = Some(super::readiness::PlaneState::ready(&key)); + } + } + Ok(()) + } } impl CheckoutDriver { + fn check_subscription(&self) { + let stale = { + let installed = self + .installed + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + installed.snapshot.watcher() == super::snapshot::WatcherState::Healthy + && installed + .snapshot + .install_cut() + .and_then(|cut| cut.verification.as_ref()) + .is_some_and(|guard| { + !guard.steady_supported + || guard.subscription.is_none() + || crate::watcher_backend::subscription::current(self.owner.root()) + != guard.subscription + }) + }; + if stale { + self.record_watcher_overflow(); + } + } + fn source_stat( &self, path: &std::path::Path, @@ -3907,7 +5828,9 @@ impl CheckoutDriver { } match std::fs::symlink_metadata(path) { Ok(metadata) if metadata.is_file() => { - Ok(Some(ObservedStat::new(&metadata, recorded_ns))) + let mut stat = ObservedStat::new(&metadata, recorded_ns); + stat.native = super::verification::NativeStat::strict_read(path).ok(); + Ok(Some(stat)) } Ok(_) => Ok(None), Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None), @@ -3931,6 +5854,7 @@ impl CheckoutDriver { /// The snapshot last installed for this checkout, without computing the /// per-plane gaps `installed_state` reports. pub fn installed_snapshot(&self) -> Snapshot { + self.check_subscription(); self.installed .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) @@ -3991,6 +5915,7 @@ impl super::intent::WriteIntentListener for CheckoutDriver { epoch: live.epoch(), version: live.version(), intent_version: live.snapshot().intent_version(), + verification: None, }); } let snapshot = self @@ -4254,3 +6179,625 @@ mod checkout_kind_tests { } } } + +#[cfg(test)] +mod v3_bootstrap_tests { + use super::*; + + struct NamedPlane(FamilyPlane); + impl CompositePlane for NamedPlane { + fn plane(&self) -> FamilyPlane { + self.0 + } + fn applies_to(&self, _: &RelPath) -> bool { + false + } + fn attachment( + &self, + _: &RelPath, + _: &[u8], + ) -> Result { + panic!("unsupported bootstrap must not extract") + } + fn materialize( + &self, + _: &super::super::registry::ViewRegistration, + _: &str, + _: &Snapshot, + _: &BTreeMap, + _: &mut super::super::manifest_v2::ManifestV2, + _: &mut crate::pins::LivePin, + _: bool, + ) -> Result<(), PlaneError> { + panic!("unsupported bootstrap must not materialize") + } + fn finish_generation( + &self, + _: &super::super::registry::ViewRegistration, + _: &str, + _: &str, + ) -> Result<(), PlaneError> { + Ok(()) + } + } + + #[test] + fn fresh_nonlexical_bootstrap_has_a_typed_refusal_and_takes_no_claim() { + let _serial = crate::blob_store::v3::serial_for_test(); + for plane in [FamilyPlane::Semantic, FamilyPlane::Callgraph] { + let root = tempfile::tempdir().unwrap(); + let storage = tempfile::tempdir().unwrap(); + std::fs::write(root.path().join("file.rs"), b"fn untouched() {}\n").unwrap(); + let policy = crate::blob_store::v2::TrigramPolicy { + max_file_size: 1 << 20, + }; + let driver = CheckoutDriver::new_v3( + storage.path(), + "family", + "worker", + root.path(), + Producers { + trigram: policy.fingerprint_hex(), + semantic: (plane == FamilyPlane::Semantic).then(|| "model".into()), + callgraph: if plane == FamilyPlane::Callgraph { + super::super::callgraph::PRODUCER.into() + } else { + "unregistered".into() + }, + }, + Arc::new(ConfiguredMembershipWalker), + vec![ + Arc::new(TrigramBridge::new(storage.path().to_owned(), policy)), + Arc::new(NamedPlane(plane)), + ], + ) + .unwrap(); + assert_eq!( + driver.fresh_bootstrap_policy(), + Err(FreshBootstrapRefusal::NonLexicalPlane(plane)) + ); + let error = driver + .verified_bind( + &driver.access(), + driver.installed_snapshot().generation().clone(), + ) + .unwrap_err(); + assert!(error.reason.contains("fresh_bootstrap_nonlexical_plane")); + let work = driver.take_reconcile_work(); + assert_eq!((work.walks, work.reads), (0, 0)); + let registry = + crate::blob_store::v3::LedgerReader::open_existing(storage.path(), "family") + .unwrap(); + assert_eq!( + registry + .read( + |c| Ok(c.query_row("SELECT COUNT(*) FROM base_builds", [], |r| r + .get::<_, u64>(0))?) + ) + .unwrap(), + 0 + ); + assert_eq!( + registry + .read(|c| Ok(c.query_row( + "SELECT COUNT(*) FROM members WHERE kind='base'", + [], + |r| r.get::<_, u64>(0) + )?)) + .unwrap(), + 0 + ); + } + } + + fn trigram_driver(root: &std::path::Path, storage: &std::path::Path) -> CheckoutDriver { + let policy = crate::blob_store::v2::TrigramPolicy { + max_file_size: 1 << 20, + }; + let plane = Arc::new(TrigramBridge::new(storage.to_owned(), policy)); + CheckoutDriver::new_v3( + storage, + "family", + "worker", + root, + Producers { + trigram: policy.fingerprint_hex(), + semantic: None, + callgraph: "unregistered".into(), + }, + Arc::new(ConfiguredMembershipWalker), + vec![plane.clone()], + ) + .unwrap() + .with_adapters(vec![plane.adapter.clone()]) + } + + #[test] + fn continuous_refresh_reads_only_reported_paths_and_gaps_or_policy_edits_walk() { + let _serial = crate::blob_store::v3::serial_for_test(); + let root = tempfile::tempdir().unwrap(); + let storage = tempfile::tempdir().unwrap(); + let _attributes = super::super::verification::known_attribute_sources_for_test(root.path()); + for name in ["file.rs", "other.rs"] { + std::fs::write(root.path().join(name), b"fn before() {}\n").unwrap(); + } + let subscription = crate::watcher_backend::subscription::start( + &std::fs::canonicalize(root.path()).unwrap(), + crate::watcher_backend::InitialCoverage::Complete, + ); + let driver = trigram_driver(root.path(), storage.path()); + let access = driver.access(); + let (first, revision) = driver + .verified_bind(&access, driver.installed_snapshot().generation().clone()) + .unwrap(); + driver.install(&access, &first, revision).unwrap(); + driver.take_reconcile_work(); + std::fs::write(root.path().join("file.rs"), b"fn edited() {}\n").unwrap(); + driver.record_change(RelPath::new(b"file.rs".to_vec()).unwrap()); + let (changed, revision) = driver + .verified_bind(&access, first.generation().clone()) + .unwrap(); + let work = driver.take_reconcile_work(); + assert_eq!(work.walks, 0); + assert_eq!(work.reads, 1); + assert!(work.stats <= 3); + driver.install(&access, &changed, revision).unwrap(); + assert!(!driver.installed_snapshot().needs_reconcile()); + driver.record_watcher_overflow(); + let (reconciled, revision) = driver + .verified_bind(&access, changed.generation().clone()) + .unwrap(); + assert_eq!(driver.take_reconcile_work().walks, 1); + driver.install(&access, &reconciled, revision).unwrap(); + std::fs::write(root.path().join(".gitignore"), b"other.rs\n").unwrap(); + driver.record_change(RelPath::new(b".gitignore".to_vec()).unwrap()); + let (ignored, revision) = driver + .verified_bind(&access, reconciled.generation().clone()) + .unwrap(); + assert_eq!(driver.take_reconcile_work().walks, 1); + assert_eq!( + ignored.disk_state(&RelPath::new(b"other.rs".to_vec()).unwrap()), + super::super::snapshot::DiskState::Absent + ); + driver.install(&access, &ignored, revision).unwrap(); + drop(subscription); + assert!(driver.installed_snapshot().needs_reconcile()); + assert_eq!( + driver.installed_snapshot().watcher(), + super::super::snapshot::WatcherState::Overflowed + ); + } + + #[test] + fn no_subscription_keeps_v3_queries_on_the_strict_path_until_registration() { + let _serial = crate::blob_store::v3::serial_for_test(); + let root = tempfile::tempdir().unwrap(); + let storage = tempfile::tempdir().unwrap(); + let _attributes = super::super::verification::known_attribute_sources_for_test(root.path()); + std::fs::write(root.path().join("file.rs"), b"fn observed() {}\n").unwrap(); + let driver = trigram_driver(root.path(), storage.path()); + let access = driver.access(); + let (snapshot, revision) = driver + .verified_bind(&access, driver.installed_snapshot().generation().clone()) + .unwrap(); + driver.install(&access, &snapshot, revision).unwrap(); + assert!(driver.installed_snapshot().needs_reconcile()); + driver.take_reconcile_work(); + let _subscription = crate::watcher_backend::subscription::start( + &std::fs::canonicalize(root.path()).unwrap(), + crate::watcher_backend::InitialCoverage::Complete, + ); + let (snapshot, revision) = driver + .verified_bind(&access, snapshot.generation().clone()) + .unwrap(); + assert_eq!(driver.take_reconcile_work().walks, 1); + driver.install(&access, &snapshot, revision).unwrap(); + assert!(!driver.installed_snapshot().needs_reconcile()); + let installed = driver.installed_snapshot(); + let mut cut = installed.install_cut().unwrap().clone(); + let mut guard = cut.verification.as_ref().unwrap().as_ref().clone(); + guard.steady_supported = false; + cut.verification = Some(Arc::new(guard)); + driver + .install(&access, &installed.with_install_cut(cut), revision) + .unwrap(); + assert!(driver.installed_snapshot().needs_reconcile()); + } + + #[test] + fn v3_lazy_attachments_probe_the_actual_producer_key_before_extraction() { + let _serial = crate::blob_store::v3::serial_for_test(); + let root = tempfile::tempdir().unwrap(); + let storage = tempfile::tempdir().unwrap(); + let bytes = b"pub fn cached() -> u32 { 42 }\n"; + std::fs::write(root.path().join("file.rs"), bytes).unwrap(); + let path = RelPath::new(b"file.rs".to_vec()).unwrap(); + let content = crate::blob_store::v2::ContentHash::of(bytes); + let policy = crate::blob_store::v2::TrigramPolicy { + max_file_size: 1 << 20, + }; + let graph = CallgraphBridge::default(); + let attachment = graph.attachment(&path, bytes).unwrap(); + let extracted = attachment + .downcast_ref::() + .unwrap(); + assert_eq!(graph.key(&path, content), Some(extracted.key)); + let trigram_key = crate::blob_store::v2::TrigramKey { content, policy }.family_key(); + let trigram = super::super::segment_store::TrigramPayload::extract(bytes, &policy).encode(); + let graph_payload = extracted.blob.to_bytes().unwrap(); + crate::blob_store::v3::publish(storage.path(), "family", 1 << 20, None, |p| { + p.put( + &trigram_key, + &trigram, + FamilyPlane::Trigram.payload_schema(), + )?; + p.put( + &extracted.key, + &graph_payload, + FamilyPlane::Callgraph.payload_schema(), + )?; + Ok(()) + }) + .unwrap(); + for (family, expected_extractions) in [("family", 0), ("miss", 2)] { + let driver = CheckoutDriver::new_v3( + storage.path(), + family, + "worker", + root.path(), + Producers { + trigram: policy.fingerprint_hex(), + semantic: None, + callgraph: super::super::callgraph::PRODUCER.into(), + }, + Arc::new(ConfiguredMembershipWalker), + vec![ + Arc::new(TrigramBridge::new(storage.path().to_owned(), policy)), + Arc::new(CallgraphBridge::default()), + ], + ) + .unwrap(); + driver.reconcile(&driver.access()).unwrap(); + let work = driver.take_reconcile_work(); + assert_eq!(work.reads, 1); + assert_eq!(work.attachments, expected_extractions); + let mut header = driver.installed_snapshot().generation().header().clone(); + header.producers.trigram = "wrong-producer".into(); + let mut delta = + super::super::manifest_v2::ManifestV2::new_v3(header, None, Default::default()); + let bridge = TrigramBridge::new(storage.path().to_owned(), policy); + let mut pin = + crate::pins::LivePin::create_v3(storage.path(), family, "worker").unwrap(); + assert!(bridge + .materialize_v3( + &driver.owner, + &driver.owner.v3_route(), + "unused", + driver.installed_snapshot().generation(), + &BTreeMap::new(), + &mut delta, + &mut pin + ) + .is_err()); + } + } + + #[test] + fn cached_callgraph_configuration_keeps_resolution_input_metadata() { + let _serial = crate::blob_store::v3::serial_for_test(); + let root = tempfile::tempdir().unwrap(); + let storage = tempfile::tempdir().unwrap(); + let owner = super::super::registry::ViewRegistration::register_v3( + storage.path(), + "family", + "worker", + root.path(), + ) + .unwrap(); + let bridge = CallgraphBridge::default(); + let path = RelPath::new(b"package.json".to_vec()).unwrap(); + let bytes = b"{\"name\":\"cached-configuration\"}"; + let content = crate::blob_store::v2::ContentHash::of(bytes); + let attachment = bridge.attachment(&path, bytes).unwrap(); + let attachment = attachment + .downcast_ref::() + .unwrap(); + let key = attachment.key; + crate::blob_store::v3::publish(storage.path(), "family", 1 << 20, None, |p| { + p.put( + &key, + &attachment.blob.to_bytes().unwrap(), + FamilyPlane::Callgraph.payload_schema(), + )?; + Ok(()) + }) + .unwrap(); + let header = super::super::manifest_v2::ManifestHeader { + producers: Producers { + trigram: "unused".into(), + semantic: None, + callgraph: super::super::callgraph::PRODUCER.into(), + }, + head_tree: None, + ignore_fingerprint: None, + segment: None, + }; + let mut base_manifest = + super::super::manifest_v2::ManifestV2::new_v3(header.clone(), None, Default::default()); + base_manifest + .insert( + RelPath::new(b"base.rs".to_vec()).unwrap(), + super::super::manifest_v2::EntryV2::regular( + crate::blob_store::v2::ContentHash::of(b"base"), + 4, + super::super::manifest_v2::EntryPlanes { + callgraph: Some(super::super::readiness::PlaneState::pending( + "base graph pending", + )), + ..Default::default() + }, + ), + ) + .unwrap(); + let identity = super::super::base_refs::BaseIdentity::of("family", &base_manifest).unwrap(); + let base = Arc::new(ComposedGeneration::new("base", base_manifest, None)); + let mut delta = super::super::manifest_v2::ManifestV2::new_v3( + header, + Some(identity.header("base")), + Default::default(), + ); + delta + .insert( + path.clone(), + super::super::manifest_v2::EntryV2::regular( + content, + bytes.len() as u64, + Default::default(), + ), + ) + .unwrap(); + let mut entry = LiveEntry::new(super::super::snapshot::DiskState::of_bytes(bytes), 0); + entry.attachments.insert( + FamilyPlane::Callgraph, + bridge.cached_attachment(content, key), + ); + let mut pin = crate::pins::LivePin::create_v3(storage.path(), "family", "worker").unwrap(); + bridge + .materialize_v3( + &owner, + &owner.v3_route(), + "private", + &base, + &BTreeMap::from([(path.clone(), entry)]), + &mut delta, + &mut pin, + ) + .unwrap(); + assert!(matches!( + delta.get(&path), + Some(super::super::manifest_v2::EntryV2::Regular { + resolution_input: true, + .. + }) + )); + } + + fn legacy_inventory(storage: &std::path::Path) -> BTreeMap> { + fn visit( + base: &std::path::Path, + path: &std::path::Path, + files: &mut BTreeMap>, + ) { + if path == base.join("v3") { + return; + } + if !path.exists() { + return; + } + for entry in std::fs::read_dir(path).unwrap() { + let path = entry.unwrap().path(); + if path.is_dir() { + visit(base, &path, files); + } else { + files.insert( + path.strip_prefix(base).unwrap().to_owned(), + std::fs::read(path).unwrap(), + ); + } + } + } + let mut files = BTreeMap::new(); + let root = storage.join("blobs"); + visit(&root, &root, &mut files); + files + } + + #[test] + fn verified_bind_fresh_trigram_driver_bootstraps_and_publishes_only_v3() { + let _serial = crate::blob_store::v3::serial_for_test(); + let root = tempfile::tempdir().unwrap(); + let storage = tempfile::tempdir().unwrap(); + let _attributes = super::super::verification::known_attribute_sources_for_test(root.path()); + let _subscription = crate::watcher_backend::subscription::start( + &std::fs::canonicalize(root.path()).unwrap(), + crate::watcher_backend::InitialCoverage::Complete, + ); + let source = b"fn fresh_checkout() { let marker = 42; }\n"; + std::fs::write(root.path().join("file.rs"), source).unwrap(); + std::fs::OpenOptions::new() + .write(true) + .open(root.path().join("file.rs")) + .unwrap() + .set_times( + std::fs::FileTimes::new() + .set_modified(std::time::UNIX_EPOCH + std::time::Duration::from_secs(60)), + ) + .unwrap(); + let legacy_v1 = storage.path().join("blobs/family"); + let legacy_v2 = storage.path().join("blobs/v2/family"); + for dir in [&legacy_v1, &legacy_v2] { + std::fs::create_dir_all(dir).unwrap(); + std::fs::write(dir.join("untouched"), b"legacy sentinel").unwrap(); + } + let before = legacy_inventory(storage.path()); + let policy = crate::blob_store::v2::TrigramPolicy { + max_file_size: 1 << 20, + }; + let producers = Producers { + trigram: policy.fingerprint_hex(), + semantic: None, + callgraph: "unregistered".into(), + }; + let plane = Arc::new(TrigramBridge::new(storage.path().to_owned(), policy)); + let driver = CheckoutDriver::new_v3( + storage.path(), + "family", + "worker", + root.path(), + producers.clone(), + Arc::new(ConfiguredMembershipWalker), + vec![plane.clone()], + ) + .unwrap() + .with_adapters(vec![plane.adapter.clone()]); + let access = driver.access(); + assert!(driver.owner.open_store(FamilyPlane::Trigram).is_err()); + assert_eq!(legacy_inventory(storage.path()), before); + let empty = driver.installed_snapshot().generation().clone(); + let (snapshot, revision) = driver.verified_bind(&access, empty).unwrap(); + assert!(snapshot.generation().base_header().is_some()); + assert_eq!(snapshot.generation().v3_route().unwrap().scope, "worker"); + assert!(snapshot.generation().header().head_tree.is_none()); + let path = RelPath::new(b"file.rs".to_vec()).unwrap(); + assert_eq!( + snapshot.disk_state(&path), + super::super::snapshot::DiskState::of_bytes(source) + ); + assert!(matches!( + snapshot.plane_readiness(&path, FamilyPlane::Trigram), + Some(super::super::readiness::PlaneState::Ready { .. }) + )); + driver.install(&access, &snapshot, revision).unwrap(); + assert!(!driver.installed_snapshot().needs_reconcile()); + assert!(driver + .installed_state(&access, FamilyPlane::Trigram) + .1 + .is_empty()); + let route = driver.owner.v3_route(); + let store = super::super::ViewStore::existing_dir(route.member_dir()).unwrap(); + let generation = store.current_generation_read_only().unwrap().unwrap(); + let manifest = store.load_manifest_v2(&generation).unwrap(); + assert_eq!( + manifest.len(), + 0, + "unchanged bytes inherit the lexical-ready base" + ); + assert!( + super::super::verification::evidence_path(&route.member_dir(), &generation).exists() + ); + assert_eq!(legacy_inventory(storage.path()), before); + let ledger = + crate::blob_store::v3::LedgerReader::open_existing(storage.path(), "family").unwrap(); + assert_eq!( + ledger + .read(|c| Ok(c.query_row( + "SELECT COUNT(*) FROM view_sessions WHERE scope='worker'", + [], + |r| r.get::<_, u32>(0) + )?)) + .unwrap(), + 1 + ); + let second = CheckoutDriver::new_v3( + storage.path(), + "family", + "worker", + root.path(), + producers, + Arc::new(ConfiguredMembershipWalker), + vec![Arc::new(TrigramBridge::new( + storage.path().to_owned(), + policy, + ))], + ) + .unwrap(); + let base = super::super::base_refs::open_foreign_v3( + storage.path(), + "family", + "worker", + &second.producers, + ) + .unwrap() + .unwrap(); + second.verified_bind(&second.access(), base).unwrap(); + let work = second.take_reconcile_work(); + assert_eq!( + work.walks, 1, + "membership-keyed bases still take strict startup verification" + ); + assert_eq!( + work.reads, 0, + "generation-paired evidence avoids rereading stable source" + ); + let other_content = crate::blob_store::v2::ContentHash::of(b"other bytes"); + let other_key = crate::blob_store::v2::TrigramKey { + content: other_content, + policy, + } + .family_key(); + let other_payload = + super::super::segment_store::TrigramPayload::extract(b"other bytes", &policy).encode(); + crate::blob_store::v3::publish(storage.path(), "family", 1 << 20, None, |p| { + p.put( + &other_key, + &other_payload, + FamilyPlane::Trigram.payload_schema(), + )?; + Ok(()) + }) + .unwrap(); + let evidence = rusqlite::Connection::open(super::super::verification::evidence_path( + &route.member_dir(), + &generation, + )) + .unwrap(); + let bytes: Vec = evidence + .query_row( + "SELECT record FROM stat_evidence WHERE path=?1", + [path.as_bytes()], + |r| r.get(0), + ) + .unwrap(); + let mut record: super::super::verification::Evidence = + serde_json::from_slice(&bytes).unwrap(); + record.keys.insert("Trigram".into(), other_key.to_hex()); + evidence + .execute( + "UPDATE stat_evidence SET record=?1 WHERE path=?2", + rusqlite::params![serde_json::to_vec(&record).unwrap(), path.as_bytes()], + ) + .unwrap(); + drop(evidence); + let third = trigram_driver(root.path(), storage.path()); + let base = super::super::base_refs::open_foreign_v3( + storage.path(), + "family", + "worker", + &third.producers, + ) + .unwrap() + .unwrap(); + third.set_view_token("forged-advisory-token".into()); + let (checked, _) = third.verified_bind(&third.access(), base).unwrap(); + assert_eq!( + third.take_reconcile_work().reads, + 1, + "an existing but unrelated key is not stat provenance, even with a token" + ); + assert_eq!( + checked.disk_state(&path), + super::super::snapshot::DiskState::of_bytes(source) + ); + assert_eq!(legacy_inventory(storage.path()), before); + } +} diff --git a/crates/aft/src/views/first_load/overflow_recovery_tests.rs b/crates/aft/src/views/first_load/overflow_recovery_tests.rs new file mode 100644 index 0000000000..5aa2f2d39a --- /dev/null +++ b/crates/aft/src/views/first_load/overflow_recovery_tests.rs @@ -0,0 +1,92 @@ +use super::*; + +fn fixture() -> ( + tempfile::TempDir, + tempfile::TempDir, + Arc, + ViewAccess, + crate::watcher_backend::subscription::Subscription, +) { + let root = tempfile::tempdir().unwrap(); + let storage = tempfile::tempdir().unwrap(); + std::fs::write(root.path().join("file.rs"), b"fn content() {}\n").unwrap(); + let subscription = crate::watcher_backend::subscription::start( + &std::fs::canonicalize(root.path()).unwrap(), + crate::watcher_backend::InitialCoverage::Complete, + ); + let policy = crate::blob_store::v2::TrigramPolicy { + max_file_size: 1 << 20, + }; + let driver = Arc::new( + CheckoutDriver::new_v3( + storage.path(), + "family", + "worker", + root.path(), + Producers { + trigram: policy.fingerprint_hex(), + semantic: None, + callgraph: "unregistered".into(), + }, + Arc::new(ConfiguredMembershipWalker), + vec![Arc::new(TrigramBridge::new( + storage.path().to_owned(), + policy, + ))], + ) + .unwrap(), + ); + let access = driver.access(); + let (snapshot, revision) = driver + .verified_bind(&access, driver.installed_snapshot().generation().clone()) + .unwrap(); + driver.install(&access, &snapshot, revision).unwrap(); + driver.take_reconcile_work(); + (root, storage, driver, access, subscription) +} + +#[test] +fn completed_strict_reconcile_recovers_the_overflow_epoch_it_covered() { + let _serial = crate::blob_store::v3::serial_for_test(); + let (_root, _storage, driver, access, _subscription) = fixture(); + driver.record_watcher_overflow(); + let epoch = driver.live.lock().unwrap().epoch(); + let (snapshot, revision) = driver + .verified_bind(&access, driver.installed_snapshot().generation().clone()) + .unwrap(); + assert_eq!(driver.take_reconcile_work().walks, 1); + assert_eq!(snapshot.reconciled_epoch(), Some(epoch)); + assert_eq!( + driver.live.lock().unwrap().watcher(), + super::super::snapshot::WatcherState::Overflowed + ); + driver.install(&access, &snapshot, revision).unwrap(); + assert_eq!( + driver.live.lock().unwrap().watcher(), + super::super::snapshot::WatcherState::Healthy + ); +} + +#[test] +fn overflow_during_strict_reconcile_cannot_be_cleared_by_its_older_cut() { + let _serial = crate::blob_store::v3::serial_for_test(); + let (_root, _storage, driver, access, _subscription) = fixture(); + driver.record_watcher_overflow(); + let covered = driver.live.lock().unwrap().epoch(); + driver.observed.lock().unwrap().stats.clear(); + let weak = Arc::downgrade(&driver); + *driver.read_hook.lock().unwrap() = Some(Box::new(move |_, _| { + weak.upgrade().unwrap().record_watcher_overflow() + })); + let (snapshot, revision) = driver + .verified_bind(&access, driver.installed_snapshot().generation().clone()) + .unwrap(); + assert_eq!(driver.take_reconcile_work().walks, 1); + assert_eq!(snapshot.reconciled_epoch(), Some(covered)); + assert!(driver.live.lock().unwrap().epoch() > covered); + assert!(driver.install(&access, &snapshot, revision).is_err()); + assert_eq!( + driver.live.lock().unwrap().watcher(), + super::super::snapshot::WatcherState::Overflowed + ); +} diff --git a/crates/aft/src/views/first_load/publication_fence_tests.rs b/crates/aft/src/views/first_load/publication_fence_tests.rs new file mode 100644 index 0000000000..afa3f5d7ba --- /dev/null +++ b/crates/aft/src/views/first_load/publication_fence_tests.rs @@ -0,0 +1,191 @@ +use super::*; +use std::path::PathBuf; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::Mutex; + +struct LeaseFence { + live: Mutex, + calls: AtomicUsize, + policy_change: Mutex>, +} +impl LeaseFence { + fn live() -> Arc { + Arc::new(Self { + live: Mutex::new(true), + calls: AtomicUsize::new(0), + policy_change: Mutex::new(None), + }) + } + fn revoke(&self) { + *self.live.lock().unwrap() = false; + } +} +impl PublishFence for LeaseFence { + fn publish( + &self, + commit: &mut dyn FnMut() -> Result<(), PlaneError>, + ) -> Result<(), PlaneError> { + let live = self.live.lock().unwrap(); + self.calls.fetch_add(1, Ordering::SeqCst); + if !*live { + return Err(SiblingLoader::error("driver ownership retired")); + } + if let Some(path) = self.policy_change.lock().unwrap().take() { + std::fs::write(path, b"*.rs text\n").unwrap(); + } + commit() + } +} + +struct Fixture { + root: tempfile::TempDir, + storage: tempfile::TempDir, + driver: CheckoutDriver, + access: ViewAccess, +} +impl Fixture { + fn new() -> Self { + let root = tempfile::tempdir().unwrap(); + let storage = tempfile::tempdir().unwrap(); + std::fs::write(root.path().join("file.rs"), b"fn before() {}\n").unwrap(); + let policy = crate::blob_store::v2::TrigramPolicy { + max_file_size: 1 << 20, + }; + let driver = CheckoutDriver::new_v3( + storage.path(), + "family", + "worker", + root.path(), + Producers { + trigram: policy.fingerprint_hex(), + semantic: None, + callgraph: "unregistered".into(), + }, + Arc::new(ConfiguredMembershipWalker), + vec![Arc::new(TrigramBridge::new( + storage.path().to_owned(), + policy, + ))], + ) + .unwrap(); + let access = driver.access(); + Self { + root, + storage, + driver, + access, + } + } + fn bootstrap(&self) { + let (snapshot, revision) = self + .driver + .verified_bind( + &self.access, + self.driver.installed_snapshot().generation().clone(), + ) + .unwrap(); + self.driver + .install(&self.access, &snapshot, revision) + .unwrap(); + } + fn pointer(&self) -> Option { + super::super::ViewStore::existing_dir(self.driver.owner.v3_route().member_dir()) + .map(|store| store.current_generation_read_only().unwrap()) + .flatten() + } + fn validated_change(&self) -> Snapshot { + std::fs::write( + self.root.path().join("file.rs"), + b"fn after() { let changed = true; }\n", + ) + .unwrap(); + self.driver + .record_change(RelPath::new(b"file.rs".to_vec()).unwrap()); + let (snapshot, revision) = self + .driver + .verified_bind( + &self.access, + self.driver.installed_snapshot().generation().clone(), + ) + .unwrap(); + self.driver + .validate_cut(&self.access, &snapshot, revision) + .unwrap(); + snapshot + } +} + +#[test] +fn v3_cas_fence_rejects_retirement_after_validation_and_allows_a_live_driver() { + let _serial = crate::blob_store::v3::serial_for_test(); + for retired in [true, false] { + let fixture = Fixture::new(); + fixture.bootstrap(); + let previous = fixture.pointer(); + let fence = LeaseFence::live(); + fixture.driver.set_publish_fence(fence.clone()); + let snapshot = fixture.validated_change(); + if retired { + fence.revoke(); + } + let result = fixture + .driver + .build_own_generation(&fixture.access, &snapshot, false); + assert_eq!(fence.calls.load(Ordering::SeqCst), 1); + if retired { + assert!(result.is_err()); + assert_eq!(fixture.pointer(), previous); + } else { + let generation = result.unwrap(); + assert_ne!(fixture.pointer(), previous); + assert_eq!(fixture.pointer().as_deref(), Some(generation.name())); + } + } +} + +#[test] +fn v3_final_metadata_validation_runs_inside_the_ownership_fence() { + let _serial = crate::blob_store::v3::serial_for_test(); + let fixture = Fixture::new(); + fixture.bootstrap(); + let previous = fixture.pointer(); + let fence = LeaseFence::live(); + fixture.driver.set_publish_fence(fence.clone()); + let snapshot = fixture.validated_change(); + *fence.policy_change.lock().unwrap() = Some(fixture.root.path().join(".gitattributes")); + assert!(fixture + .driver + .build_own_generation(&fixture.access, &snapshot, false) + .is_err()); + assert_eq!(fence.calls.load(Ordering::SeqCst), 1); + assert_eq!(fixture.pointer(), previous); +} + +#[test] +fn refused_fresh_v3_publication_releases_the_completed_lexical_claim() { + let _serial = crate::blob_store::v3::serial_for_test(); + let fixture = Fixture::new(); + let fence = LeaseFence::live(); + fence.revoke(); + fixture.driver.set_publish_fence(fence); + assert!(fixture + .driver + .verified_bind( + &fixture.access, + fixture.driver.installed_snapshot().generation().clone() + ) + .is_err()); + assert!(fixture.pointer().is_none()); + let registry = + crate::blob_store::v3::LedgerReader::open_existing(fixture.storage.path(), "family") + .unwrap(); + assert_eq!( + registry + .read(|c| Ok( + c.query_row("SELECT COUNT(*) FROM base_builds", [], |row| row + .get::<_, u64>(0))? + )) + .unwrap(), + 0 + ); +} diff --git a/crates/aft/src/views/generation.rs b/crates/aft/src/views/generation.rs index 0520263bf2..f4fd8c6a9e 100644 --- a/crates/aft/src/views/generation.rs +++ b/crates/aft/src/views/generation.rs @@ -19,6 +19,10 @@ use serde::{ use super::{Result, ViewError, ViewStore}; +#[cfg(test)] +#[path = "generation/stat_evidence_tests.rs"] +mod stat_evidence_tests; + // Protection scans must finish within one common allowance before any unlink. pub(super) const GENERATION_DIRECTORY_LIMIT: usize = 65_536; const MAX_CLAIM_FILES: usize = 16; @@ -94,6 +98,48 @@ fn unlink_generation_file(path: &Path) -> Option { Some(bytes) } +fn stat_evidence_namespace(view_dir: &Path) -> bool { + fn named(path: &Path, name: &str) -> bool { + path.file_name() + .and_then(|n| n.to_str()) + .is_some_and(|actual| { + if cfg!(windows) { + actual.eq_ignore_ascii_case(name) + } else { + actual == name + } + }) + } + let Some(views) = view_dir.parent() else { + return false; + }; + let Some(family) = views.parent() else { + return false; + }; + let Some(v3) = family.parent() else { + return false; + }; + let Some(blobs) = v3.parent() else { + return false; + }; + named(views, "views") && named(v3, "v3") && named(blobs, "blobs") +} + +fn stat_evidence_generation(name: &str) -> Option<&str> { + name.strip_prefix("stat-evidence-") + .and_then(|name| { + [".sqlite", ".sqlite-wal", ".sqlite-shm", ".sqlite-journal"] + .iter() + .find_map(|suffix| name.strip_suffix(suffix)) + }) + .or_else(|| { + name.strip_prefix(".stat-evidence-") + .and_then(|name| name.split_once(".sqlite.tmp.")) + .filter(|(_, nonce)| !nonce.is_empty()) + .map(|(generation, _)| generation) + }) +} + // A view has at most one cleanup worker. Publications arriving during a sweep // request another lap rather than starting competing pointer transactions. static GENERATION_SWEEPS: OnceLock>> = OnceLock::new(); @@ -780,6 +826,7 @@ impl ViewStore { fn has_superseded_artifacts(&self) -> Result { let current = self.pointer_census()?.generation; + let evidence = stat_evidence_namespace(self.view_dir()); let (entries, reached_end) = bounded_directory_entries(self.view_dir(), 0, GENERATION_DIRECTORY_LIMIT)?; if !reached_end { @@ -798,6 +845,7 @@ impl ViewStore { ".sqlite-wal", ".sqlite-shm", ".sqlite-journal", + ".sqlite-seal", ".ref", ] .iter() @@ -820,7 +868,8 @@ impl ViewStore { name.split_once(".json.tmp.") .map(|(generation, _)| generation) }) - }); + }) + .or_else(|| evidence.then(|| stat_evidence_generation(name)).flatten()); if generation.is_some_and(|generation| current.as_deref() != Some(generation)) { return Ok(true); } @@ -855,6 +904,7 @@ impl ViewStore { let mut generations = std::collections::BTreeSet::new(); let mut derived_owners = std::collections::BTreeSet::new(); let mut temporaries: HashMap> = HashMap::new(); + let evidence = stat_evidence_namespace(self.view_dir()); // A directory over the limit is examined one window per pass, so the // leak it holds can still shrink. Each pass moves the window on. let windows = OVERSIZED_WINDOWS.get_or_init(Default::default); @@ -893,9 +943,15 @@ impl ViewStore { let generation = name .strip_prefix("derived-") .and_then(|s| { - [".sqlite", ".sqlite-wal", ".sqlite-shm", ".sqlite-journal"] - .iter() - .find_map(|suffix| s.strip_suffix(suffix)) + [ + ".sqlite", + ".sqlite-wal", + ".sqlite-shm", + ".sqlite-journal", + ".sqlite-seal", + ] + .iter() + .find_map(|suffix| s.strip_suffix(suffix)) }) .or_else(|| { name.strip_prefix("manifest-") @@ -912,9 +968,12 @@ impl ViewStore { .or_else(|| { name.strip_prefix(".manifest-") .and_then(|s| s.split_once(".json.tmp.").map(|(generation, _)| generation)) - }); + }) + .or_else(|| evidence.then(|| stat_evidence_generation(name)).flatten()); if let Some(generation) = generation { - if name.starts_with(".manifest-") { + if name.starts_with(".manifest-") + || (evidence && name.starts_with(".stat-evidence-")) + { temporaries .entry(generation.to_owned()) .or_default() @@ -1069,13 +1128,20 @@ impl ViewStore { // claim bound stops this pass partway, the manifest may be left // without its sidecar (blob GC then decodes the JSON), but the // sidecar is never left behind its manifest. - for path in [ + let mut artifacts = vec![ self.view_dir().join(format!("derived-{generation}.sqlite")), self.view_dir().join(format!("derived-{generation}.ref")), self.trigram_path(generation)?, sidecar.clone(), self.manifest_path(generation)?, - ] { + ]; + if stat_evidence_namespace(self.view_dir()) { + artifacts.insert( + 3, + super::verification::evidence_path(self.view_dir(), generation), + ); + } + for path in artifacts { let sqlite = path.extension().is_some_and(|ext| ext == "sqlite"); // A kept clone-baseline manifest keeps its sidecar too. if keep_derived @@ -1084,7 +1150,8 @@ impl ViewStore { continue; } let suffixes = if sqlite { - &["", "-wal", "-shm", "-journal"][..] + // A sealed donor's seal record goes with its database. + &["", "-wal", "-shm", "-journal", super::donor::SEAL_SUFFIX][..] } else { &[""][..] }; diff --git a/crates/aft/src/views/generation/stat_evidence_tests.rs b/crates/aft/src/views/generation/stat_evidence_tests.rs new file mode 100644 index 0000000000..9ec8bbbde5 --- /dev/null +++ b/crates/aft/src/views/generation/stat_evidence_tests.rs @@ -0,0 +1,159 @@ +use super::*; +use crate::views::manifest_v2::{GenerationName, ManifestHeader, ManifestV2, Producers, PublishV2}; + +fn fixture() -> (tempfile::TempDir, ViewStore) { + let storage = tempfile::tempdir().unwrap(); + let directory = crate::blob_store::v3::view_dir(storage.path(), "family", "worker").unwrap(); + crate::reader_floor::publish_v3_with_floor(storage.path(), 3, 1, || { + std::fs::create_dir_all(&directory).unwrap(); + }) + .unwrap(); + let view = ViewStore::open_dir(directory).unwrap(); + (storage, view) +} + +fn manifest(tag: &str) -> ManifestV2 { + ManifestV2::new_v3( + ManifestHeader { + producers: Producers { + trigram: "trigram".into(), + semantic: None, + callgraph: "graph".into(), + }, + head_tree: None, + ignore_fingerprint: Some(tag.into()), + segment: None, + }, + None, + Default::default(), + ) +} + +fn publish(view: &ViewStore, tag: &str) -> String { + let manifest = manifest(tag); + let generation = GenerationName::for_manifest(&manifest).unwrap(); + let previous = view.current_generation_read_only().unwrap(); + let prepared = view + .prepare_v2(&generation, previous.as_deref(), &manifest, None) + .unwrap(); + assert_eq!( + view.commit_v2(prepared, None).unwrap(), + PublishV2::Published + ); + wait_for_generation_sweep_for_test(view.view_dir()); + generation.to_string() +} + +fn stage(view: &ViewStore, generation: &str, nonce: &str) -> PathBuf { + view.view_dir() + .join(format!(".stat-evidence-{generation}.sqlite.tmp.{nonce}")) +} + +#[test] +fn stat_evidence_retires_with_its_generation_but_survives_current_and_read_pins() { + let (_storage, view) = fixture(); + let old = publish(&view, "old"); + let old_evidence = crate::views::verification::evidence_path(view.view_dir(), &old); + fs::write(&old_evidence, vec![7; 32_768]).unwrap(); + let old_stage = stage(&view, &old, "abandoned"); + fs::write(&old_stage, b"private evidence").unwrap(); + let marker = crate::root_cache::ReadMarker::create(view.view_dir(), &old).unwrap(); + let current = publish(&view, "current"); + let current_evidence = crate::views::verification::evidence_path(view.view_dir(), ¤t); + fs::write(¤t_evidence, b"current evidence").unwrap(); + let current_stage = stage(&view, ¤t, "still-current"); + fs::write(¤t_stage, b"current staging").unwrap(); + view.sweep_generations().unwrap(); + assert!(old_evidence.exists()); + assert!(old_stage.exists()); + view.sweep_generations().unwrap(); + assert!(old_evidence.exists()); + assert!(current_evidence.exists()); + assert!(current_stage.exists()); + drop(marker); + let swept = view.sweep_generations_with_bytes().unwrap(); + assert!(!old_evidence.exists()); + assert!(!old_stage.exists()); + assert!(swept.removed_generations > 0); + assert!(current_evidence.exists()); + assert!(current_stage.exists()); +} + +#[test] +fn stat_evidence_staging_never_ages_out_under_a_prepared_generation_pin() { + let (_storage, view) = fixture(); + publish(&view, "current"); + let manifest = manifest("unpublished"); + let generation = GenerationName::for_manifest(&manifest).unwrap(); + let previous = view.current_generation_read_only().unwrap(); + let prepared = view + .prepare_v2(&generation, previous.as_deref(), &manifest, None) + .unwrap(); + let staging = stage(&view, &generation.to_string(), "crashed-writer"); + fs::write(&staging, b"partly written database").unwrap(); + fs::OpenOptions::new() + .write(true) + .open(&staging) + .unwrap() + .set_times( + fs::FileTimes::new().set_modified(std::time::UNIX_EPOCH + Duration::from_secs(60)), + ) + .unwrap(); + view.sweep_generations().unwrap(); + assert!(staging.exists()); + assert!(view + .manifest_path(&generation.to_string()) + .unwrap() + .exists()); + drop(prepared); + view.sweep_generations().unwrap(); + assert!(!staging.exists()); + assert!(!view + .manifest_path(&generation.to_string()) + .unwrap() + .exists()); +} + +#[test] +fn stat_evidence_orphans_and_sqlite_temporaries_use_existing_removed_byte_accounting() { + let (_storage, view) = fixture(); + publish(&view, "current"); + let database = crate::views::verification::evidence_path(view.view_dir(), "orphan"); + let journal = database.with_file_name("stat-evidence-orphan.sqlite-journal"); + let temporary = stage(&view, "orphan", "dead-writer"); + let temporary_journal = temporary.with_file_name(format!( + "{}-journal", + temporary.file_name().unwrap().to_str().unwrap() + )); + for (path, size) in [ + (&database, 65_536), + (&journal, 4_096), + (&temporary, 8_192), + (&temporary_journal, 512), + ] { + fs::write(path, vec![3; size]).unwrap(); + } + assert!(view.has_superseded_artifacts().unwrap()); + let swept = view.sweep_generations_with_bytes().unwrap(); + assert_eq!(swept.removed_bytes, 65_536 + 4_096 + 8_192 + 512); + assert_eq!(swept.removed_generations, 1); + for path in [&database, &journal, &temporary, &temporary_journal] { + assert!(!path.exists()); + } +} + +#[test] +fn stat_evidence_discovery_is_confined_to_v3_member_directories() { + let storage = tempfile::tempdir().unwrap(); + let registry = crate::views::registry::FamilyRegistry::open(storage.path(), "family").unwrap(); + let owner = registry + .register_view_with_storage(registry.storage(), "legacy", storage.path()) + .unwrap(); + let legacy = owner.view_store().unwrap(); + let evidence = crate::views::verification::evidence_path(legacy.view_dir(), "orphan"); + fs::write(&evidence, b"not a legacy-owned artifact").unwrap(); + assert!(!stat_evidence_namespace(legacy.view_dir())); + assert!(!legacy.has_superseded_artifacts().unwrap()); + legacy.sweep_generations().unwrap(); + assert_eq!(fs::read(evidence).unwrap(), b"not a legacy-owned artifact"); +} diff --git a/crates/aft/src/views/io.rs b/crates/aft/src/views/io.rs index 0846fb354c..d63b65c250 100644 --- a/crates/aft/src/views/io.rs +++ b/crates/aft/src/views/io.rs @@ -13,6 +13,7 @@ pub(crate) struct WorkCounters { pub content_reads: AtomicU64, pub content_read_bytes: AtomicU64, pub full_walks: AtomicU64, + pub git_subprocesses: AtomicU64, pub applicability_walks: AtomicU64, pub logical_aggregates: AtomicU64, pub own_generation_folds: AtomicU64, @@ -35,6 +36,7 @@ pub(crate) static WORK: WorkCounters = WorkCounters { content_reads: AtomicU64::new(0), content_read_bytes: AtomicU64::new(0), full_walks: AtomicU64::new(0), + git_subprocesses: AtomicU64::new(0), applicability_walks: AtomicU64::new(0), logical_aggregates: AtomicU64::new(0), own_generation_folds: AtomicU64::new(0), @@ -74,6 +76,7 @@ pub(crate) fn work_snapshot() -> serde_json::Value { "content_reads": WORK.content_reads.load(Ordering::Relaxed), "content_read_bytes": WORK.content_read_bytes.load(Ordering::Relaxed), "full_walks": WORK.full_walks.load(Ordering::Relaxed), + "git_subprocesses": WORK.git_subprocesses.load(Ordering::Relaxed), "applicability_walks": WORK.applicability_walks.load(Ordering::Relaxed), "logical_aggregates": WORK.logical_aggregates.load(Ordering::Relaxed), "own_generation_folds": WORK.own_generation_folds.load(Ordering::Relaxed), diff --git a/crates/aft/src/views/manifest_v2.rs b/crates/aft/src/views/manifest_v2.rs index 09bcf46e44..52351b652b 100644 --- a/crates/aft/src/views/manifest_v2.rs +++ b/crates/aft/src/views/manifest_v2.rs @@ -55,6 +55,8 @@ impl Producers { FamilyPlane::Trigram => Some(&self.trigram), FamilyPlane::Semantic => self.semantic.as_deref(), FamilyPlane::Callgraph => Some(&self.callgraph), + // Symbols are not a manifest plane: no producer is recorded here. + FamilyPlane::Symbol => None, } } } @@ -118,6 +120,7 @@ impl EntryPlanes { FamilyPlane::Trigram => self.trigram.as_ref(), FamilyPlane::Semantic => self.semantic.as_ref(), FamilyPlane::Callgraph => self.callgraph.as_ref(), + FamilyPlane::Symbol => None, } } @@ -126,6 +129,12 @@ impl EntryPlanes { FamilyPlane::Trigram => &mut self.trigram, FamilyPlane::Semantic => &mut self.semantic, FamilyPlane::Callgraph => &mut self.callgraph, + // Unreachable while FamilyPlane::ALL excludes Symbol (pinned by + // `blob_store::v2` test `symbol_is_not_a_manifest_plane`). A write + // here could not be persisted, so it fails loudly instead. + FamilyPlane::Symbol => { + panic!("symbol readiness is not a manifest plane; EntryPlanes has no symbol state") + } } } } @@ -232,6 +241,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/materialization.rs b/crates/aft/src/views/materialization.rs index 5beb91763d..bc5af83c3d 100644 --- a/crates/aft/src/views/materialization.rs +++ b/crates/aft/src/views/materialization.rs @@ -276,6 +276,28 @@ pub fn materialize_from_blob_reader( materialize_reading(database_path, manifest, None, reader, None).map(|_| ()) } +/// [`apply_manifest_diff`] over an immutable blob reader, for a private clone +/// of a sealed donor built from `base_manifest`. Without the legacy blob +/// table there are no per-fact resolution inputs, so callers that may read a +/// changed fact are re-resolved; rows are still rewritten only where they +/// change. A clone whose recorded fingerprint is not `base_manifest`'s is +/// refused unchanged (see [`is_base_fingerprint_mismatch`]). +pub(crate) fn apply_manifest_diff_from_blob_reader( + database_path: &Path, + base_manifest: &crate::views::Manifest, + new_manifest: &crate::views::Manifest, + reader: &impl join::ManifestBlobReader, +) -> Result { + materialize_reading( + database_path, + new_manifest, + Some(base_manifest), + reader, + None, + ) + .map(|(stats, _)| stats) +} + fn materialize_reading( database_path: &Path, manifest: &crate::views::Manifest, diff --git a/crates/aft/src/views/mod.rs b/crates/aft/src/views/mod.rs index 70b21a2af5..ea1d3e378f 100644 --- a/crates/aft/src/views/mod.rs +++ b/crates/aft/src/views/mod.rs @@ -11,12 +11,13 @@ 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; +pub(crate) mod donor; mod generation; #[cfg(test)] -pub(crate) use generation::{ - checkpoint_test_hooks, clone_derived_for_test, wait_for_derived_checkpoint_for_test, - CheckpointTestHooks, -}; +pub(crate) use generation::{clone_derived_for_test, wait_for_derived_checkpoint_for_test}; pub(crate) mod io; #[cfg(aft_views_lazy_benchmark)] pub mod lazy_read_benchmark; @@ -46,6 +47,7 @@ pub mod readiness; pub mod registry; pub mod segment_store; pub mod snapshot; +pub mod symbol_store; // Plane and runtime modules of the per-checkout views. Each starts as a // documented empty module that its plane fills in. @@ -59,12 +61,16 @@ pub mod query_wait; pub mod semantic; pub mod semantic_arena; pub mod semantic_runtime; +mod tree_base; pub mod trigram; +mod verification; #[cfg(test)] 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/readiness.rs b/crates/aft/src/views/readiness.rs index 682aeeab10..479f565e30 100644 --- a/crates/aft/src/views/readiness.rs +++ b/crates/aft/src/views/readiness.rs @@ -18,6 +18,17 @@ use super::manifest_v2::{EntryV2, ManifestV2, Producers}; use super::snapshot::{ComposedGeneration, DiskState}; use super::{RelPath, Result, ViewError}; +/// Whether a live delta's bytes were stably observed, independently of whether +/// its plane artifacts have finished materializing. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub enum ReconciledDelta { + #[default] + Unreconciled, + Reconciled, + /// Repeated unstable reads supplied no bytes safe to insert. + Pending, +} + /// The state of one plane for one manifest entry. #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] #[serde(tag = "state", rename_all = "snake_case")] diff --git a/crates/aft/src/views/registry.rs b/crates/aft/src/views/registry.rs index 5d4fcf4c5d..2ab2581a60 100644 --- a/crates/aft/src/views/registry.rs +++ b/crates/aft/src/views/registry.rs @@ -236,7 +236,7 @@ struct RegistryInner { durable_storage: Mutex>, family: String, path: PathBuf, - connection: Mutex, + connection: Option>, /// The disk limits this process applies to the family; see /// `views::eviction`. Shared by every handle on this registry file. budget: Mutex, @@ -299,6 +299,30 @@ impl RegistryLocation { } impl FamilyRegistry { + /// Identity and barrier access for a v3-only owner. No legacy connection is + /// opened, including during session cleanup. + fn open_v3(storage: &Path, family: &str) -> RegistryResult { + let path = crate::blob_store::v3::family_dir(storage, family) + .map_err(token_store_error)? + .join("members.sqlite"); + Ok(Self { + inner: Arc::new(RegistryInner { + storage: storage.to_path_buf(), + durable_storage: Mutex::new(None), + family: family.to_owned(), + path, + connection: None, + budget: Mutex::new(Default::default()), + size_caches: Mutex::default(), + ended_sessions: Mutex::new(Vec::new()), + }), + }) + } + + pub fn is_v3_only(&self) -> bool { + self.inner.connection.is_none() + } + /// Opens the registry, creating it when absent. pub fn open(storage: &Path, family: &str) -> RegistryResult { let dir = family_dir(storage, family)?; @@ -371,7 +395,7 @@ impl FamilyRegistry { durable_storage: Mutex::new(None), family: family.to_owned(), path, - connection: Mutex::new(connection), + connection: Some(Mutex::new(connection)), budget: Mutex::new(super::eviction::DiskBudget::default()), size_caches: Mutex::default(), ended_sessions: Mutex::new(Vec::new()), @@ -408,13 +432,18 @@ impl FamilyRegistry { durable_storage: Mutex::new(None), family: family.to_owned(), path: path.clone(), - connection: Mutex::new(connection), + connection: Some(Mutex::new(connection)), budget: Mutex::new(super::eviction::DiskBudget::default()), size_caches: Mutex::default(), ended_sessions: Mutex::new(Vec::new()), }); { - let mut connection = lock(&inner.connection); + let mut connection = lock( + inner + .connection + .as_ref() + .expect("legacy registry connection"), + ); if create { crate::blob_store::retry_while_busy( Duration::from_millis(crate::blob_store::BUSY_TIMEOUT_MS), @@ -446,21 +475,23 @@ impl FamilyRegistry { /// The registry connection, after writing any session ends queued while /// it was busy, so that nothing read through it sees a session that has /// already ended in this process. - fn connection(&self) -> MutexGuard<'_, TrackedConnection> { + fn connection(&self) -> RegistryResult> { + let mutex = self.inner.connection.as_ref().ok_or_else(|| { + RegistryError::Incompatible( + "legacy registry operation on a v3-only registration".into(), + ) + })?; #[cfg(test)] CONNECTION_BLOCKED.with(|hook| { - if matches!( - self.inner.connection.try_lock(), - Err(std::sync::TryLockError::WouldBlock) - ) { + if matches!(mutex.try_lock(), Err(std::sync::TryLockError::WouldBlock)) { if let Some(blocked) = hook.borrow_mut().take() { blocked.send(()).unwrap(); } } }); - let connection = lock(&self.inner.connection); + let connection = lock(mutex); flush_ended_sessions(&self.inner, &connection); - connection + Ok(connection) } pub fn storage(&self) -> &Path { @@ -534,6 +565,11 @@ impl FamilyRegistry { scope: &str, root: &Path, ) -> RegistryResult { + if self.is_v3_only() { + return Err(RegistryError::Incompatible( + "use ViewRegistration::register_v3 for v3-only owners".into(), + )); + } self.bind_durable_storage(durable_storage)?; if matches!(root.try_exists(), Ok(true)) { let paths = crate::root_cache::StoragePaths::new( @@ -554,7 +590,7 @@ impl FamilyRegistry { owner.start_time, SESSION_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed) ); - let mut connection = self.connection(); + let mut connection = self.connection()?; let tx = connection.transaction_with_behavior(TransactionBehavior::Immediate)?; tx.execute_batch(RUNTIME_SCHEMA)?; let previous: Option = tx @@ -633,7 +669,7 @@ impl FamilyRegistry { /// Every recorded view session, live or not. pub fn sessions(&self) -> RegistryResult> { - let connection = self.connection(); + let connection = self.connection()?; read_sessions(&connection, None) } @@ -647,7 +683,7 @@ impl FamilyRegistry { .filter(|session| !crate::pins::owner_is_live(&session.owner)) .map(|session| session.session_id) .collect::>(); - let connection = self.connection(); + let connection = self.connection()?; for session_id in &dead { connection.execute( "DELETE FROM view_sessions WHERE session_id = ?1", @@ -668,7 +704,7 @@ impl FamilyRegistry { owner.start_time, READER_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed) ); - self.connection().execute( + self.connection()?.execute( "INSERT INTO readers (reader_id, label, owner_pid, owner_start, registered_at_ms) VALUES (?1, ?2, ?3, ?4, ?5)", params![ @@ -687,11 +723,11 @@ impl FamilyRegistry { } pub fn members(&self) -> RegistryResult> { - read_members(&self.connection()) + read_members(&*self.connection()?) } pub(crate) fn members_bounded(&self, limit: usize) -> RegistryResult> { - let connection = self.connection(); + let connection = self.connection()?; let members = read_members_limited(&connection, limit.saturating_add(1))?; if members.len() > limit { return Err(RegistryError::Incompatible(format!( @@ -702,13 +738,13 @@ impl FamilyRegistry { } pub fn member(&self, scope: &str) -> RegistryResult> { - Ok(read_members(&self.connection())? + Ok(read_members(&*self.connection()?)? .into_iter() .find(|member| member.scope == scope)) } pub fn readers(&self) -> RegistryResult> { - let connection = self.connection(); + let connection = self.connection()?; let mut statement = connection.prepare( "SELECT reader_id, label, owner_pid, owner_start, registered_at_ms FROM readers ORDER BY reader_id", @@ -728,7 +764,7 @@ impl FamilyRegistry { } pub fn gc_epoch(&self) -> RegistryResult { - let epoch: i64 = self.connection().query_row( + let epoch: i64 = self.connection()?.query_row( "SELECT gc_epoch FROM registry_meta WHERE singleton = 1", [], |row| row.get(0), @@ -738,7 +774,7 @@ impl FamilyRegistry { /// Records a successful publication of `scope`. pub fn note_publish(&self, scope: &str) -> RegistryResult<()> { - self.connection().execute( + self.connection()?.execute( "UPDATE members SET last_publish_ms = ?2 WHERE scope = ?1", params![scope, now_ms() as i64], )?; @@ -759,7 +795,7 @@ impl FamilyRegistry { /// A live owner keeps the lease; a dead owner's lease is taken over. pub(crate) fn acquire_sweep(&self) -> RegistryResult> { let owner = current_owner(); - let mut connection = self.connection(); + let mut connection = self.connection()?; let tx = connection.transaction_with_behavior(TransactionBehavior::Immediate)?; let holder: Option<(i64, i64)> = tx .query_row( @@ -812,7 +848,18 @@ impl FamilyRegistry { &self, body: impl FnOnce(&rusqlite::Connection) -> RegistryResult, ) -> RegistryResult { - body(&self.connection()) + if self.is_v3_only() { + return crate::blob_store::v3::LedgerReader::open_existing( + self.storage(), + self.family(), + ) + .map_err(token_store_error)? + .read(|c| { + body(c).map_err(|e| crate::blob_store::v3::Error::Io(std::io::Error::other(e))) + }) + .map_err(token_store_error); + } + body(&*self.connection()?) } /// Runs `body` inside one IMMEDIATE transaction on the registry. This is @@ -822,7 +869,14 @@ impl FamilyRegistry { &self, body: impl FnOnce(&rusqlite::Transaction<'_>) -> RegistryResult, ) -> RegistryResult { - let mut connection = self.connection(); + if self.is_v3_only() { + return super::base_refs::with_base_barrier(self.storage(), self.family(), |tx| { + flush_ended_sessions(&self.inner, tx); + body(tx).map_err(|e| super::base_refs::BaseRefError::Io(std::io::Error::other(e))) + }) + .map_err(|e| RegistryError::Io(std::io::Error::other(e))); + } + let mut connection = self.connection()?; let tx = connection.transaction_with_behavior(TransactionBehavior::Immediate)?; let value = body(&tx)?; tx.commit()?; @@ -851,7 +905,7 @@ impl FamilyRegistry { .filter(|reader| !crate::pins::owner_is_live(&reader.owner)) .map(|reader| reader.reader_id) .collect::>(); - let connection = self.connection(); + let connection = self.connection()?; for reader_id in &dead { connection.execute( "DELETE FROM readers WHERE reader_id = ?1", @@ -900,7 +954,7 @@ impl SweepLease { /// A forced admission pass ignores it. Failed or partial passes do not /// postpone a retry. pub(crate) fn due(&self, force: bool) -> RegistryResult { - let connection = self.registry.connection(); + let connection = self.registry.connection()?; connection.execute_batch("CREATE TABLE IF NOT EXISTS sweep_schedule (singleton INTEGER PRIMARY KEY CHECK(singleton = 1), completed_at_ms INTEGER NOT NULL)")?; let last: Option = connection .query_row( @@ -917,7 +971,7 @@ impl SweepLease { } pub(crate) fn completed(&self) -> RegistryResult<()> { - self.registry.connection().execute("INSERT INTO sweep_schedule VALUES (1, ?1) ON CONFLICT(singleton) DO UPDATE SET completed_at_ms = excluded.completed_at_ms", params![now_ms() as i64])?; + self.registry.connection()?.execute("INSERT INTO sweep_schedule VALUES (1, ?1) ON CONFLICT(singleton) DO UPDATE SET completed_at_ms = excluded.completed_at_ms", params![now_ms() as i64])?; Ok(()) } @@ -934,10 +988,12 @@ impl SweepLease { return; } self.released = true; - let _ = self.registry.connection().execute( + if let Ok(connection) = self.registry.connection() { + let _ = connection.execute( "DELETE FROM sweep_lease WHERE singleton = 1 AND owner_pid = ?1 AND owner_start = ?2", params![i64::from(self.owner.pid), self.owner.start_time as i64], ); + } } } @@ -993,7 +1049,16 @@ impl Drop for ViewSession { ended_at_ms: now_ms(), }; lock(&self.registry.inner.ended_sessions).push(ended); - match self.registry.inner.connection.try_lock() { + let Some(connection) = &self.registry.inner.connection else { + let registry = self.registry.clone(); + let _ = std::thread::Builder::new() + .name("aft-v3-view-session-end".into()) + .spawn(move || { + let _ = registry.with_barrier(|_| Ok(())); + }); + return; + }; + match connection.try_lock() { Ok(connection) => flush_ended_sessions(&self.registry.inner, &connection), Err(std::sync::TryLockError::Poisoned(poisoned)) => { flush_ended_sessions(&self.registry.inner, &poisoned.into_inner()) @@ -1039,6 +1104,36 @@ pub struct SessionRecord { } impl ViewRegistration { + /// Registers only in blobs/v3. It grants no legacy store-write capability. + pub fn register_v3( + storage: &Path, + family: &str, + scope: &str, + root: &Path, + ) -> RegistryResult { + let registry = FamilyRegistry::open_v3(storage, family)?; + let root = std::fs::canonicalize(root)?; + let owner = current_owner(); + let session_id = super::base_refs::fresh_nonce(); + let view_dir = registry.with_barrier(|tx| { + let dir = super::base_refs::register_view_in(tx, storage, family, scope, &root) + .map_err(|e| RegistryError::Io(std::io::Error::other(e)))?; + tx.execute("INSERT INTO view_sessions(session_id,scope,owner_pid,owner_start,opened_at_ms) VALUES(?1,?2,?3,?4,?5)", + params![session_id, scope, owner.pid, owner.start_time as i64, now_ms()])?; + Ok(dir) + })?; + Ok(Self { + registry: registry.clone(), + scope: scope.to_owned(), + root, + view_dir, + session: Arc::new(ViewSession { + registry, + session_id, + scope: scope.to_owned(), + }), + }) + } pub fn registry(&self) -> &FamilyRegistry { &self.registry } @@ -1065,6 +1160,9 @@ impl ViewRegistration { } pub fn write_access(&self) -> StoreWriteAccess { + if self.registry.is_v3_only() { + return StoreWriteAccess::v3_only(self.registry.storage(), self.family()); + } StoreWriteAccess::for_registered_view(self.registry.storage(), self.registry.family()) } @@ -1072,8 +1170,21 @@ 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 { + if self.registry.is_v3_only() { + return Ok(super::ViewStore::open_dir(self.view_dir.clone())?); + } let view = super::ViewStore::open_dir(self.view_dir.clone())? .with_sweep_registry(self.registry.clone()); view.recover_abandoned_derived(Some(self.registry())); @@ -1201,10 +1312,12 @@ impl ReaderRegistration { impl Drop for ReaderRegistration { fn drop(&mut self) { - let _ = self.registry.connection().execute( - "DELETE FROM readers WHERE reader_id = ?1", - params![self.reader_id], - ); + if let Ok(connection) = self.registry.connection() { + let _ = connection.execute( + "DELETE FROM readers WHERE reader_id = ?1", + params![self.reader_id], + ); + } } } @@ -1400,6 +1513,191 @@ fn now_ms() -> u64 { crate::pins::now_ms() } +pub const VIEW_TOKEN_TTL_MS: u64 = 600_000; + +#[derive(Clone, Debug, serde::Serialize)] +pub struct ViewToken { + pub token: String, + pub expires_ms: u64, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum TokenUse { + Accepted { expected_tree: String }, + Forged, + Expired, + WrongRoot, + WrongTree, + IdentityMismatch, + Replayed, +} + +/// Canonicalize an existing parent without requiring the future checkout to +/// exist. A later symlink substitution changes the canonical consumption root. +pub fn canonical_token_root(path: &Path) -> std::io::Result { + if !path.is_absolute() + || path + .components() + .any(|c| matches!(c, std::path::Component::ParentDir)) + { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "token root must be absolute without parent components", + )); + } + let mut parent = path; + let mut suffix = Vec::new(); + while !parent.try_exists()? { + suffix.push( + parent + .file_name() + .ok_or_else(|| std::io::Error::other("no existing root ancestor"))? + .to_os_string(), + ); + parent = parent + .parent() + .ok_or_else(|| std::io::Error::other("no existing root ancestor"))?; + } + let mut result = std::fs::canonicalize(parent)?; + for component in suffix.into_iter().rev() { + result.push(component); + } + Ok(result) +} + +fn token_store_error(error: crate::blob_store::v3::Error) -> RegistryError { + RegistryError::Io(std::io::Error::other(error)) +} + +/// Minting is advisory and creates only the registered v3 token row. No base, +/// source bytes, view pointer or legacy row is written. +pub fn mint_view_token( + storage: &Path, + family: &str, + root: &Path, + tree: &str, + now_ms: u64, +) -> RegistryResult { + if !matches!(tree.len(), 40 | 64) + || !tree + .bytes() + .all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase()) + { + return Err(RegistryError::InvalidScope( + "expected tree must be a lowercase Git object ID".into(), + )); + } + let root = canonical_token_root(root)?; + let root_bytes = encode_root(&root).ok_or_else(|| { + RegistryError::InvalidScope("token root cannot be represented exactly".into()) + })?; + let mut nonce = [0u8; 32]; + getrandom::fill(&mut nonce) + .map_err(|error| RegistryError::Io(std::io::Error::other(error.to_string())))?; + let token = crate::blob_store::v2::to_hex(&nonce); + let expires_ms = now_ms + .checked_add(VIEW_TOKEN_TTL_MS) + .ok_or_else(|| RegistryError::InvalidScope("token clock overflow".into()))?; + let lock = + crate::blob_store::v3::ReservationLock::acquire(storage).map_err(token_store_error)?; + let mut registry = crate::blob_store::v3::open_verification_registry(storage, family, &lock) + .map_err(token_store_error)?; + registry.with_barrier(|tx| { + tx.execute("DELETE FROM view_tokens WHERE expires_ms<=?1", [now_ms])?; + tx.execute("INSERT INTO view_tokens(nonce,root,expected_tree,minted_ms,expires_ms) VALUES(?1,?2,?3,?4,?5)", params![token, root_bytes, tree, now_ms, expires_ms])?; + Ok(()) + }).map_err(token_store_error)?; + Ok(ViewToken { token, expires_ms }) +} + +/// A token never supplies a freshness proof. Even an accepted token only +/// suggests which tree to pre-warm; callers must still reconcile the checkout. +pub fn consume_view_token( + storage: &Path, + family: &str, + token: &str, + root: &Path, + now_ms: u64, +) -> RegistryResult { + let root = canonical_token_root(root)?; + let directory = match super::verification::gitdir(&root) { + Ok(directory) => directory, + Err(_) => return Ok(TokenUse::IdentityMismatch), + }; + let identities = match ( + super::verification::NativeStat::strict_read(&root), + super::verification::NativeStat::strict_read(&directory), + ) { + (Ok(root), Ok(gitdir)) => (root.identity, (directory, gitdir.identity)), + _ => return Ok(TokenUse::IdentityMismatch), + }; + let queries = + crate::git_index_reader::GitQueries::new(root.clone(), std::time::Duration::from_secs(2)); + let tree = queries.head_tree()?; + if !tree.status.success() { + return Ok(TokenUse::WrongTree); + } + let tree = String::from_utf8_lossy(&tree.stdout).trim().to_owned(); + let bound_root = serde_json::to_vec(&identities.0) + .map_err(|e| RegistryError::Io(std::io::Error::other(e)))?; + let bound_gitdir = serde_json::to_vec(&identities.1) + .map_err(|e| RegistryError::Io(std::io::Error::other(e)))?; + consume_token_identity( + storage, + family, + token, + &root, + &tree, + &bound_root, + &bound_gitdir, + now_ms, + ) +} + +#[allow(clippy::too_many_arguments)] +fn consume_token_identity( + storage: &Path, + family: &str, + token: &str, + root: &Path, + tree: &str, + root_identity: &[u8], + gitdir_identity: &[u8], + now_ms: u64, +) -> RegistryResult { + // A forged token does not create a family or advance its reader floor. + if !crate::blob_store::v3::family_dir(storage, family) + .map_err(token_store_error)? + .join("members.sqlite") + .try_exists()? + { + return Ok(TokenUse::Forged); + } + let lock = + crate::blob_store::v3::ReservationLock::acquire(storage).map_err(token_store_error)?; + let mut registry = + crate::blob_store::v3::RegistryWriter::open_for_barrier(storage, family, &lock) + .map_err(token_store_error)?; + if registry.registry_version().map_err(token_store_error)? + < crate::blob_store::v3::REGISTRY_VERIFICATION_VERSION + { + return Ok(TokenUse::Forged); + } + registry.with_barrier(|tx| { + type Row = (Vec, String, u64, u64, Option>, Option>, Option); + let row: Option = tx.query_row("SELECT root,expected_tree,minted_ms,expires_ms,bound_root,bound_gitdir,consumed_ms FROM view_tokens WHERE nonce=?1", [token], |r| Ok((r.get(0)?,r.get(1)?,r.get(2)?,r.get(3)?,r.get(4)?,r.get(5)?,r.get(6)?))).optional()?; + let Some((expected_root, expected_tree, minted, expires, bound_root, bound_gitdir, consumed)) = row else { return Ok(TokenUse::Forged); }; + if now_ms < minted || now_ms >= expires { return Ok(TokenUse::Expired); } + if Some(expected_root) != encode_root(root) { return Ok(TokenUse::WrongRoot); } + if expected_tree != tree { return Ok(TokenUse::WrongTree); } + if consumed.is_some() { + return Ok(if bound_root.as_deref() == Some(root_identity) && bound_gitdir.as_deref() == Some(gitdir_identity) { TokenUse::Replayed } else { TokenUse::IdentityMismatch }); + } + let changed = tx.execute("UPDATE view_tokens SET bound_root=?1,bound_gitdir=?2,consumed_ms=?3 WHERE nonce=?4 AND consumed_ms IS NULL", params![root_identity, gitdir_identity, now_ms, token])?; + Ok(if changed == 1 { TokenUse::Accepted { expected_tree } } else { TokenUse::Replayed }) + }).map_err(token_store_error) +} + #[cfg(test)] mod tests { use super::*; @@ -1528,7 +1826,7 @@ mod tests { super::super::generation::schedule_generation_sweep(view.clone()); entered_rx.recv_timeout(Duration::from_secs(10)).unwrap(); let held = { - let mut connection = registry.connection(); + let mut connection = registry.connection().unwrap(); connection.busy_timeout(Duration::ZERO).unwrap(); let blocked = connection .transaction_with_behavior(TransactionBehavior::Immediate) @@ -1678,3 +1976,257 @@ mod tests { ); } } + +#[cfg(test)] +mod view_token_tests { + use super::*; + const TREE: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + + #[test] + fn view_token_upgrade_preserves_existing_registry_two_members_and_sessions() { + let _serial = crate::blob_store::v3::serial_for_test(); + let storage = tempfile::tempdir().unwrap(); + let root = tempfile::tempdir().unwrap(); + super::super::base_refs::with_base_barrier(storage.path(), "family", |tx| { + super::super::base_refs::register_view_in( + tx, + storage.path(), + "family", + "before", + root.path(), + )?; + tx.execute( + "INSERT INTO view_sessions VALUES('before-session','before',123,456,789)", + [], + )?; + Ok(()) + }) + .unwrap(); + let reader = + crate::blob_store::v3::LedgerReader::open_existing(storage.path(), "family").unwrap(); + assert_eq!( + reader + .read(|c| Ok(c + .query_row("SELECT format_version FROM format_meta", [], |r| r + .get::<_, u32>(0))?)) + .unwrap(), + 2 + ); + mint_view_token( + storage.path(), + "family", + &root.path().join("future"), + TREE, + 1, + ) + .unwrap(); + assert_eq!( + reader + .read(|c| Ok(c + .query_row("SELECT format_version FROM format_meta", [], |r| r + .get::<_, u32>(0))?)) + .unwrap(), + 3 + ); + assert_eq!( + reader + .read(|c| Ok(c.query_row( + "SELECT COUNT(*) FROM members WHERE scope='before'", + [], + |r| r.get::<_, u32>(0) + )?)) + .unwrap(), + 1 + ); + assert_eq!(reader.read(|c| Ok(c.query_row("SELECT owner_pid,owner_start,opened_at_ms FROM view_sessions WHERE session_id='before-session'", [], |r| Ok((r.get::<_, u32>(0)?,r.get::<_, u32>(1)?,r.get::<_, u32>(2)?)))?)).unwrap(), (123,456,789)); + } + + #[test] + fn view_token_mints_before_root_exists_and_binds_both_identities_once() { + let _serial = crate::blob_store::v3::serial_for_test(); + let storage = tempfile::tempdir().unwrap(); + let parent = tempfile::tempdir().unwrap(); + let root = std::fs::canonicalize(parent.path()).unwrap().join("future"); + let minted = mint_view_token(storage.path(), "family", &root, TREE, 10).unwrap(); + assert!(!root.exists()); + let ledger = + crate::blob_store::v3::LedgerReader::open_existing(storage.path(), "family").unwrap(); + assert!(ledger.read(|c| Ok(c.query_row("SELECT bound_root IS NULL AND bound_gitdir IS NULL AND consumed_ms IS NULL FROM view_tokens", [], |r| r.get::<_, bool>(0))?)).unwrap()); + assert_eq!( + consume_token_identity( + storage.path(), + "family", + &minted.token, + &root, + TREE, + b"root-id", + b"gitdir-id", + 11 + ) + .unwrap(), + TokenUse::Accepted { + expected_tree: TREE.into() + } + ); + assert_eq!( + consume_token_identity( + storage.path(), + "family", + &minted.token, + &root, + TREE, + b"root-id", + b"gitdir-id", + 12 + ) + .unwrap(), + TokenUse::Replayed + ); + for (root_id, gitdir_id) in [ + (b"replacement".as_slice(), b"gitdir-id".as_slice()), + (b"root-id".as_slice(), b"replacement".as_slice()), + ] { + assert_eq!( + consume_token_identity( + storage.path(), + "family", + &minted.token, + &root, + TREE, + root_id, + gitdir_id, + 12 + ) + .unwrap(), + TokenUse::IdentityMismatch + ); + } + let floor = crate::reader_floor::read(storage.path()).unwrap().unwrap(); + assert_eq!(floor.stores["views_v3_registry"], 3); + assert_eq!(floor.stores["views_v3_store"], 1); + assert!(!storage.path().join("blobs/family").exists()); + assert!(!storage.path().join("blobs/v2").exists()); + } + + #[test] + fn view_token_concurrent_first_consumption_has_exactly_one_winner() { + let _serial = crate::blob_store::v3::serial_for_test(); + let storage = tempfile::tempdir().unwrap(); + let parent = tempfile::tempdir().unwrap(); + let root = std::fs::canonicalize(parent.path()).unwrap().join("future"); + let token = mint_view_token(storage.path(), "family", &root, TREE, 1).unwrap(); + let start = std::sync::Arc::new(std::sync::Barrier::new(2)); + let outcomes = std::thread::scope(|scope| { + let jobs: Vec<_> = (0..2) + .map(|_| { + let start = &start; + let token = &token.token; + let root = &root; + let storage = storage.path(); + scope.spawn(move || { + start.wait(); + consume_token_identity( + storage, "family", token, root, TREE, b"root", b"gitdir", 2, + ) + .unwrap() + }) + }) + .collect(); + jobs.into_iter() + .map(|j| j.join().unwrap()) + .collect::>() + }); + assert_eq!( + outcomes + .iter() + .filter(|r| matches!(r, TokenUse::Accepted { .. })) + .count(), + 1 + ); + assert_eq!( + outcomes + .iter() + .filter(|r| **r == TokenUse::Replayed) + .count(), + 1 + ); + } + + #[test] + fn view_token_forged_expired_wrong_root_and_wrong_tree_never_consume() { + let _serial = crate::blob_store::v3::serial_for_test(); + let storage = tempfile::tempdir().unwrap(); + let parent = tempfile::tempdir().unwrap(); + let root = std::fs::canonicalize(parent.path()).unwrap().join("future"); + assert_eq!( + consume_token_identity( + storage.path(), + "family", + "forged", + &root, + TREE, + b"r", + b"g", + 1 + ) + .unwrap(), + TokenUse::Forged + ); + assert!(!storage.path().join("blobs/v3").exists()); + let token = mint_view_token(storage.path(), "family", &root, TREE, 10).unwrap(); + for (nonce, path, tree, now, expected) in [ + ("forged", root.clone(), TREE, 11, TokenUse::Forged), + ( + &token.token, + root.join("wrong"), + TREE, + 11, + TokenUse::WrongRoot, + ), + ( + &token.token, + root.clone(), + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + 11, + TokenUse::WrongTree, + ), + ( + &token.token, + root.clone(), + TREE, + token.expires_ms, + TokenUse::Expired, + ), + (&token.token, root.clone(), TREE, 9, TokenUse::Expired), + ] { + assert_eq!( + consume_token_identity( + storage.path(), + "family", + nonce, + &path, + tree, + b"r", + b"g", + now + ) + .unwrap(), + expected + ); + } + assert!(matches!( + consume_token_identity( + storage.path(), + "family", + &token.token, + &root, + TREE, + b"r", + b"g", + 11 + ) + .unwrap(), + TokenUse::Accepted { .. } + )); + } +} 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..ae753850d9 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, })); @@ -2310,6 +2613,22 @@ impl super::first_load::CompositePlane for SemanticPlane { fn applies_to(&self, rel_path: &RelPath) -> bool { applies_to(rel_path) } + fn attachment_key(&self, attachment: &super::snapshot::PlaneAttachment) -> Option { + attachment + .downcast_ref::() + .map(|a| a.key) + } + fn cached_attachment( + &self, + content: ContentHash, + key: FamilyKey, + ) -> super::snapshot::PlaneAttachment { + Arc::new(SemanticAttachment { + content, + producer: self.producer.id(), + key, + }) + } fn attachment( &self, @@ -2344,88 +2663,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 +2817,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 +2864,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 +4084,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 +4172,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 +4988,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/semantic_runtime.rs b/crates/aft/src/views/semantic_runtime.rs index 9baa87f782..290bd149f8 100644 --- a/crates/aft/src/views/semantic_runtime.rs +++ b/crates/aft/src/views/semantic_runtime.rs @@ -728,9 +728,7 @@ pub struct CheckoutSemanticMemory { /// before its vectors can be brought up to date. The initial snapshot (before /// any generation was published) always needs one. fn needs_reload(snapshot: &Snapshot) -> bool { - snapshot.generation().name() == "empty" - || snapshot.pending_intent().next().is_some() - || snapshot.live_entries().next().is_some() + snapshot.needs_reconcile() } /// Whether reconciling `snapshot` needs a cold-build slot. A reconcile walks @@ -750,6 +748,11 @@ fn needs_reload(snapshot: &Snapshot) -> bool { /// regular file counts as large. A changed path that no longer exists counts /// as zero bytes, because the reconcile has nothing of it to read. fn needs_cold_reconcile(snapshot: &Snapshot, root: &Path) -> bool { + // Reconciled bytes may still have pending planes. Their fill admission + // does not require probing source files for another reconciliation. + if !needs_reload(snapshot) { + return false; + } if snapshot.generation().name() == "empty" { return true; } diff --git a/crates/aft/src/views/semantic_runtime_tests.rs b/crates/aft/src/views/semantic_runtime_tests.rs index 858e815fc2..cc03105d59 100644 --- a/crates/aft/src/views/semantic_runtime_tests.rs +++ b/crates/aft/src/views/semantic_runtime_tests.rs @@ -12,6 +12,39 @@ use std::sync::atomic::AtomicUsize; /// row, and the marker row saying the run's vector matches that chunk row. const ROWS_PER_ONE_CHUNK_FILE: u64 = 3; +#[test] +fn reconciled_delta_does_not_request_another_source_walk() { + let manifest = + super::super::manifest_v2::ManifestV2::new(super::super::manifest_v2::ManifestHeader { + producers: Producers { + trigram: "unused".into(), + semantic: None, + callgraph: "unused".into(), + }, + head_tree: None, + ignore_fingerprint: None, + segment: None, + }); + let mut delta = LiveDelta::new(Arc::new(ComposedGeneration::new( + "published", + manifest, + None, + ))); + let path = super::super::RelPath::new(b"file.rs".to_vec()).unwrap(); + let mut entry = super::super::snapshot::LiveEntry::new(DiskState::of_bytes(b"new"), 1); + delta.apply(path.clone(), entry.clone()); + assert!(super::needs_reload(&delta.snapshot())); + entry.reconciliation = super::super::readiness::ReconciledDelta::Reconciled; + delta.apply(path.clone(), entry); + assert!(!super::needs_reload(&delta.snapshot())); + let root = tempfile::tempdir().unwrap(); + std::fs::create_dir(root.path().join("file.rs")).unwrap(); + assert!(!super::needs_cold_reconcile(&delta.snapshot(), root.path())); + delta.record_intent(path); + assert!(super::needs_reload(&delta.snapshot())); + assert!(super::needs_cold_reconcile(&delta.snapshot(), root.path())); +} + /// The test prints public setup, query answers, a v3 file inventory and exact /// reader-floor bytes. Query expectations come from an independent cold index, /// not the view. diff --git a/crates/aft/src/views/snapshot.rs b/crates/aft/src/views/snapshot.rs index a33014f5d0..a932bf713c 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 } @@ -503,6 +535,7 @@ pub struct LiveEntry { /// The watcher or intent sequence that produced this state. pub seq: u64, pub attachments: BTreeMap, + pub reconciliation: super::readiness::ReconciledDelta, } impl fmt::Debug for LiveEntry { @@ -521,6 +554,7 @@ impl LiveEntry { disk, seq, attachments: BTreeMap::new(), + reconciliation: Default::default(), } } } @@ -787,6 +821,12 @@ impl LiveDelta { reconciled_epoch: None, publication_base: None, readers: None, + needs_reconcile: self.base.name() == "empty" + || !self.intent.is_empty() + || self.watcher != WatcherState::Healthy + || self.entries.values().any(|entry| { + entry.reconciliation != super::readiness::ReconciledDelta::Reconciled + }), }), } } @@ -1280,6 +1320,7 @@ mod mutation_tests { epoch: live.epoch(), version: live.version(), intent_version: 0, + verification: None, }; let changed = RelPath::new(b"changed.rs".to_vec()).unwrap(); let transient = RelPath::new(b"transient.rs".to_vec()).unwrap(); @@ -1342,6 +1383,7 @@ mod mutation_tests { epoch: live.epoch(), version: live.version(), intent_version: 0, + verification: None, }; let mut walked = LiveDelta::new(live.base().clone()); walked.apply(file.clone(), LiveEntry::new(DiskState::of_bytes(b"B"), 2)); @@ -1376,6 +1418,7 @@ mod mutation_tests { epoch: live.epoch(), version: live.version(), intent_version: 0, + verification: None, }; let mut walked = LiveDelta::new(live.base().clone()); walked.apply(file.clone(), LiveEntry::new(DiskState::of_bytes(b"B"), 2)); @@ -1628,6 +1671,7 @@ pub fn carry_disk_state_only(path: &RelPath, generation: &ComposedGeneration) -> #[derive(Clone, Debug)] struct SnapshotInner { + needs_reconcile: bool, reconciled_epoch: Option, publication_base: Option>, generation: Arc, @@ -1650,6 +1694,7 @@ pub struct InstallCut { pub epoch: u64, pub version: u64, pub intent_version: u64, + pub(crate) verification: Option>, } /// A lease on resident adapters. Query snapshots retain this lease, so removing @@ -1953,6 +1998,12 @@ pub struct Snapshot { } impl Snapshot { + /// Checks the cached reconciliation flag. Work is summarized when the + /// immutable snapshot is created, not by scanning the delta on each query. + pub fn needs_reconcile(&self) -> bool { + self.inner.needs_reconcile + } + pub(crate) fn with_publication_base(mut self, base: Option) -> Self { Arc::make_mut(&mut self.inner).publication_base = Some(base); self diff --git a/crates/aft/src/views/symbol_store.rs b/crates/aft/src/views/symbol_store.rs new file mode 100644 index 0000000000..8861586044 --- /dev/null +++ b/crates/aft/src/views/symbol_store.rs @@ -0,0 +1,280 @@ +//! The v3 symbol plane: parsed symbols of one file, shared by every checkout +//! of a family and keyed by what decides them, never by path. +//! +//! Storage is the ordinary v3 plane store `blobs/v3//symbol.sqlite` +//! (`FamilyPlane::Symbol`, format in docs/design/views-v3-format.md, *Import +//! ledger and symbol plane*). This module owns the plane's key, payload, +//! residency and the policy parts of its GC and retention: +//! +//! - **Key.** Content hash, language and producer (the extraction schema, +//! `symbol_cache_disk::SCHEMA_VERSION`). A parser or schema change changes +//! the producer, so its lookups miss instead of returning stale symbols. +//! - **Writes** go through `blob_store::v3::publish` only: the v3 reader floor +//! is raised first and every batch holds its own reservation. +//! - **GC.** No manifest names a symbol row, so the v3 plane-row sweep keeps +//! a row until it has gone [`SYMBOL_RETENTION_SWEEPS`] sweep epochs without +//! a put or [`touch`]. Callers that rely on a row touch it. +//! - **Retention.** Rows count in the family's v3 namespace inventory and +//! budget like every plane; nothing else deletes them. +//! - **Residency.** Decoded symbol lists are shared as `Arc>` +//! through one process-wide cache bounded by [`RESIDENT_BYTES`]; evicting +//! from it never touches stored rows. + +use std::collections::{HashMap, VecDeque}; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex, OnceLock}; + +use crate::blob_store::v2::{ContentHash, FamilyKey, FamilyPlane}; +use crate::blob_store::v3; +use crate::symbols::Symbol; + +pub use crate::blob_store::v3::SYMBOL_RETENTION_SWEEPS; + +/// Layout byte of a symbol payload. +const PAYLOAD_LAYOUT: u8 = 1; +const KEY_TAG: &[u8] = b"aft/views-v3/symbol/v1"; +/// Estimated bytes of decoded symbol lists kept resident per process. +pub const RESIDENT_BYTES: u64 = 64 << 20; +/// Payload bytes written by one v3 publication. +const PUT_BATCH_BYTES: u64 = 32 << 20; + +/// What produced a symbol list: the extraction schema, which is bumped on +/// any grammar, query or extractor change. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)] +pub struct SymbolProducer { + pub extraction_schema: u32, +} + +impl SymbolProducer { + /// This build's producer. + pub fn current() -> Self { + Self { + extraction_schema: crate::symbol_cache_disk::SCHEMA_VERSION, + } + } + + pub fn id(&self) -> String { + format!("aft-symbols/extraction-{}", self.extraction_schema) + } +} + +/// The language id stored in symbol keys and payloads: the parser's +/// language name. +pub fn language_id(language: crate::parser::LangId) -> String { + format!("{language:?}") +} + +/// The language a path's symbols are extracted with, if any. +pub fn language_of(path: &Path) -> Option { + crate::parser::detect_language(path).map(language_id) +} + +/// Everything that decides a symbol list. +#[derive(Clone, Debug, Eq, PartialEq, Hash)] +pub struct SymbolKey { + pub content: ContentHash, + pub language: String, + pub producer: SymbolProducer, +} + +impl SymbolKey { + pub fn family_key(&self) -> FamilyKey { + let mut hasher = blake3::Hasher::new(); + hasher.update(KEY_TAG); + for field in [ + &self.content.as_bytes()[..], + self.language.as_bytes(), + self.producer.id().as_bytes(), + ] { + hasher.update(&(field.len() as u64).to_be_bytes()); + hasher.update(field); + } + FamilyKey::new(FamilyPlane::Symbol, *hasher.finalize().as_bytes()) + } +} + +fn push_field(output: &mut Vec, bytes: &[u8]) { + output.extend_from_slice(&(bytes.len() as u32).to_le_bytes()); + output.extend_from_slice(bytes); +} + +/// The canonical payload of `symbols`: the layout byte, then the producer +/// id, the language id and the symbols as JSON, each length-prefixed. +pub fn encode_payload(producer: &SymbolProducer, language: &str, symbols: &[Symbol]) -> Vec { + let json = serde_json::to_vec(symbols).expect("symbols serialize"); + let mut payload = vec![PAYLOAD_LAYOUT]; + push_field(&mut payload, producer.id().as_bytes()); + push_field(&mut payload, language.as_bytes()); + push_field(&mut payload, &json); + payload +} + +/// Decodes a payload stored for `producer` and `language`; a payload of any +/// other layout, producer or language is refused, never reinterpreted. +pub fn decode_payload( + payload: &[u8], + producer: &SymbolProducer, + language: &str, +) -> Result, String> { + let mut rest = payload + .strip_prefix(&[PAYLOAD_LAYOUT]) + .ok_or("symbol payload has another layout")?; + let mut field = || -> Result<&[u8], String> { + if rest.len() < 4 { + return Err("symbol payload is truncated".to_owned()); + } + let len = u32::from_le_bytes(rest[..4].try_into().expect("4")) as usize; + let value = rest.get(4..4 + len).ok_or("symbol payload is truncated")?; + rest = &rest[4 + len..]; + Ok(value) + }; + if field()? != producer.id().as_bytes() { + return Err("symbol payload has another producer".to_owned()); + } + if field()? != language.as_bytes() { + return Err("symbol payload has another language".to_owned()); + } + let json = field()?; + if !rest.is_empty() { + return Err("symbol payload has trailing bytes".to_owned()); + } + serde_json::from_slice(json).map_err(|error| format!("symbol payload JSON: {error}")) +} + +/// Stores `(key, payload)` rows, in batches of at most 32 MiB of payload, +/// each one v3 publication under its own reservation. Returns how many rows +/// were inserted (the rest were equal rows, only touched). +pub fn put(storage: &Path, family: &str, rows: &[(FamilyKey, Vec)]) -> Result { + let schema = FamilyPlane::Symbol.payload_schema(); + let mut inserted = 0; + let mut start = 0; + while start < rows.len() { + let mut end = start; + let mut bytes = 0_u64; + while end < rows.len() && (end == start || bytes < PUT_BATCH_BYTES) { + bytes += rows[end].1.len() as u64; + end += 1; + } + let request = bytes.saturating_mul(2).saturating_add(1 << 20); + inserted += v3::publish(storage, family, request, None, |publication| { + let mut inserted = 0; + for (key, payload) in &rows[start..end] { + if publication.put_or_touch(key, payload, schema)? { + inserted += 1; + } + } + Ok(inserted) + })?; + start = end; + } + Ok(inserted) +} + +/// Restamps existing rows with the current sweep epoch, so the plane-row +/// sweep keeps them another [`SYMBOL_RETENTION_SWEEPS`] epochs. Returns the +/// keys that have no row. +pub fn touch( + storage: &Path, + family: &str, + keys: &[FamilyKey], +) -> Result, v3::Error> { + if keys.is_empty() { + return Ok(Vec::new()); + } + let request = (keys.len() as u64).saturating_mul(64 * 1024).max(1 << 20); + v3::publish(storage, family, request, None, |publication| { + publication.touch(keys) + }) +} + +/// Resident lists are keyed by the plane store they were read from as well +/// as the key, so a lookup in one storage root or family never answers from +/// rows another one stored. +type ResidentKey = (PathBuf, [u8; 32]); + +struct Residency { + entries: HashMap>, u64)>, + order: VecDeque, + bytes: u64, +} + +fn residency() -> &'static Mutex { + static RESIDENT: OnceLock> = OnceLock::new(); + RESIDENT.get_or_init(|| { + Mutex::new(Residency { + entries: HashMap::new(), + order: VecDeque::new(), + bytes: 0, + }) + }) +} + +fn resident(key: &ResidentKey) -> Option>> { + residency() + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .entries + .get(key) + .map(|(symbols, _)| Arc::clone(symbols)) +} + +fn make_resident(key: ResidentKey, symbols: Arc>, bytes: u64) { + let mut cache = residency() + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if cache.entries.contains_key(&key) || bytes > RESIDENT_BYTES { + return; + } + while cache.bytes + bytes > RESIDENT_BYTES { + let Some(oldest) = cache.order.pop_front() else { + break; + }; + if let Some((_, size)) = cache.entries.remove(&oldest) { + cache.bytes -= size; + } + } + cache.entries.insert(key.clone(), (symbols, bytes)); + cache.order.push_back(key); + cache.bytes += bytes; +} + +/// The stored symbols of the file at `path` whose bytes hash to `content`, +/// under this build's producer, or `None` when the plane has no such row. +/// Never parses: a miss is the caller's to fill. +pub fn lookup( + storage: &Path, + family: &str, + content: ContentHash, + path: &Path, +) -> Result>>, String> { + let Some(language) = language_of(path) else { + return Ok(None); + }; + let producer = SymbolProducer::current(); + let key = SymbolKey { + content, + language: language.clone(), + producer, + } + .family_key(); + let plane = v3::plane_path(storage, family, FamilyPlane::Symbol).map_err(|e| e.to_string())?; + let resident_key = (plane.clone(), *key.as_bytes()); + if let Some(symbols) = resident(&resident_key) { + return Ok(Some(symbols)); + } + if !plane.exists() { + return Ok(None); + } + let reader = v3::PlaneReader::open_existing(storage, family, FamilyPlane::Symbol) + .map_err(|error| error.to_string())?; + let Some(payload) = reader.get(&key).map_err(|error| error.to_string())? else { + return Ok(None); + }; + let symbols = Arc::new(decode_payload(&payload, &producer, &language)?); + make_resident(resident_key, Arc::clone(&symbols), payload.len() as u64); + Ok(Some(symbols)) +} + +#[cfg(test)] +#[path = "symbol_store_tests.rs"] +mod tests; diff --git a/crates/aft/src/views/symbol_store_tests.rs b/crates/aft/src/views/symbol_store_tests.rs new file mode 100644 index 0000000000..ae8beef026 --- /dev/null +++ b/crates/aft/src/views/symbol_store_tests.rs @@ -0,0 +1,186 @@ +use std::collections::HashSet; + +use super::*; +use crate::symbols::{Range, SymbolKind}; + +const FAMILY: &str = "family"; + +fn symbol(name: &str) -> Symbol { + Symbol { + name: name.to_owned(), + kind: SymbolKind::Function, + range: Range { + start_line: 1, + start_col: 0, + end_line: 3, + end_col: 1, + }, + signature: Some(format!("fn {name}()")), + scope_chain: Vec::new(), + exported: true, + parent: None, + } +} + +fn key(content: &[u8]) -> SymbolKey { + SymbolKey { + content: ContentHash::of(content), + language: "Rust".to_owned(), + producer: SymbolProducer::current(), + } +} + +#[test] +fn a_payload_decodes_only_for_its_producer_and_language() { + let producer = SymbolProducer::current(); + let symbols = vec![symbol("alpha"), symbol("beta")]; + let payload = encode_payload(&producer, "Rust", &symbols); + assert_eq!( + decode_payload(&payload, &producer, "Rust").unwrap(), + symbols + ); + let other = SymbolProducer { + extraction_schema: producer.extraction_schema + 1, + }; + assert!(decode_payload(&payload, &other, "Rust") + .unwrap_err() + .contains("producer")); + assert!(decode_payload(&payload, &producer, "Go") + .unwrap_err() + .contains("language")); + assert!(decode_payload(&payload[..payload.len() - 1], &producer, "Rust").is_err()); + // Every input of the key separates keys. + let base = key(b"fn alpha() {}"); + assert_ne!(base.family_key(), key(b"fn beta() {}").family_key()); + assert_ne!( + base.family_key(), + SymbolKey { + language: "Go".to_owned(), + ..base.clone() + } + .family_key() + ); + assert_ne!( + base.family_key(), + SymbolKey { + producer: other, + ..base.clone() + } + .family_key() + ); + assert_eq!(base.family_key().plane(), FamilyPlane::Symbol); +} + +fn sweep(storage: &Path, epoch: i64) -> u64 { + let lock = v3::ReservationLock::acquire(storage).unwrap(); + let mut registry = v3::RegistryWriter::open_existing(storage, FAMILY, &lock).unwrap(); + let mut cursor = v3::PlaneRowCursor::default(); + let swept = registry + .sweep_plane_rows(epoch, &HashSet::new(), &mut cursor, 1_000, None) + .unwrap(); + assert!(swept.complete); + swept.deleted +} + +fn has(storage: &Path, plane: FamilyPlane, key: &FamilyKey) -> bool { + v3::PlaneReader::open_existing(storage, FAMILY, plane) + .unwrap() + .contains(key) + .unwrap() +} + +/// No manifest marks a symbol row, so the plane-row sweep ages symbol rows +/// out by epoch: an untouched row survives `SYMBOL_RETENTION_SWEEPS` sweeps, +/// a touch restarts its count, and an unmarked row of a manifest plane is +/// still collected at the first sweep. +#[test] +fn the_sweep_keeps_symbol_rows_for_their_retention_epochs() { + let _serial = v3::serial_for_test(); + let storage = tempfile::tempdir().unwrap(); + let storage = storage.path(); + let producer = SymbolProducer::current(); + let kept = key(b"fn kept() {}").family_key(); + let touched = key(b"fn touched() {}").family_key(); + let rows = [kept, touched] + .iter() + .map(|key| (*key, encode_payload(&producer, "Rust", &[symbol("row")]))) + .collect::>(); + assert_eq!(put(storage, FAMILY, &rows).unwrap(), 2); + assert_eq!( + put(storage, FAMILY, &rows).unwrap(), + 0, + "a second put reuses" + ); + let trigram = FamilyKey::new(FamilyPlane::Trigram, [3; 32]); + v3::publish(storage, FAMILY, 1 << 20, None, |publication| { + publication.put(&trigram, b"unmarked", 1) + }) + .unwrap(); + + sweep(storage, 1); + assert!(!has(storage, FamilyPlane::Trigram, &trigram)); + for epoch in 2..=SYMBOL_RETENTION_SWEEPS { + if epoch == 4 { + assert!(touch(storage, FAMILY, &[touched]).unwrap().is_empty()); + } + assert_eq!(sweep(storage, epoch), 0, "epoch {epoch}"); + } + assert!(has(storage, FamilyPlane::Symbol, &kept)); + // The untouched row was stamped at epoch 0 and is now one sweep too old. + assert_eq!(sweep(storage, SYMBOL_RETENTION_SWEEPS + 1), 1); + assert!(!has(storage, FamilyPlane::Symbol, &kept)); + assert!(has(storage, FamilyPlane::Symbol, &touched)); + // The touch at epoch 3's stamp keeps the other row until epoch 3 + 8 + 1. + for epoch in SYMBOL_RETENTION_SWEEPS + 2..=3 + SYMBOL_RETENTION_SWEEPS { + assert_eq!(sweep(storage, epoch), 0, "epoch {epoch}"); + } + assert_eq!(sweep(storage, 4 + SYMBOL_RETENTION_SWEEPS), 1); + assert_eq!( + v3::v3_format_report(&v3::plane_path(storage, FAMILY, FamilyPlane::Symbol).unwrap()) + .unwrap() + .layout_tag, + v3::LAYOUT_TAG + ); +} + +#[test] +fn a_lookup_reads_a_stored_row_and_misses_without_parsing() { + let _serial = v3::serial_for_test(); + let storage = tempfile::tempdir().unwrap(); + let storage = storage.path(); + let source = b"pub fn stored() {}\n"; + let path = Path::new("src/stored.rs"); + assert!(lookup(storage, FAMILY, ContentHash::of(source), path) + .unwrap() + .is_none()); + let producer = SymbolProducer::current(); + let language = language_of(path).unwrap(); + let key = SymbolKey { + content: ContentHash::of(source), + language: language.clone(), + producer, + }; + let symbols = vec![symbol("stored")]; + put( + storage, + FAMILY, + &[( + key.family_key(), + encode_payload(&producer, &language, &symbols), + )], + ) + .unwrap(); + let found = lookup(storage, FAMILY, ContentHash::of(source), path) + .unwrap() + .unwrap(); + assert_eq!(*found, symbols); + // The same content under another language has another key. + assert!(lookup( + storage, + FAMILY, + ContentHash::of(source), + Path::new("src/stored.go") + ) + .unwrap() + .is_none()); +} diff --git a/crates/aft/src/views/tree_base.rs b/crates/aft/src/views/tree_base.rs new file mode 100644 index 0000000000..06005f026f --- /dev/null +++ b/crates/aft/src/views/tree_base.rs @@ -0,0 +1,382 @@ +//! Tree-keyed bases and their blob receipts +//! (docs/design/views-v3-format.md, "Tree-keyed bases and blob receipts"). +//! +//! A fresh checkout gets a tree-keyed base only when Git itself proves the +//! claim: the coherent index computes HEAD's tree, and every member's bytes, +//! read once under a stable native stat, hash to that member's index blob. +//! Each such read leaves a receipt in the base's proof file. A later bind of +//! the same checkout can then skip rereading a member whose receipt still +//! holds. Anything short of that proof keeps today's membership-keyed base and +//! its full check. + +use std::collections::{BTreeMap, BTreeSet}; +use std::path::Path; +use std::sync::Mutex; + +use crate::blob_store::v2::{to_hex, FamilyPlane, TrigramPolicy}; +use crate::blob_store::v3; +use crate::git_index_reader::{index_tree, read_index, IndexRead, ObjectFormat}; + +use super::base_builder::{claim_base, initial_reservation, BuildClaim, ClaimOutcome}; +use super::base_refs::{ + resolve_base, with_base_barrier, AuthenticatedBase, BaseIdentity, BaseKind, +}; +use super::manifest_v2::{EntryPlanes, EntryV2, ManifestHeader, ManifestV2, Producers}; +use super::readiness::PlaneState; +use super::snapshot::{ComposedGeneration, V3Route}; +use super::verification::{BindGuard, BlobReceipt, Evidence, NativeStat, TreeProof}; +use super::RelPath; + +/// What a fresh checkout needs to build a tree-keyed base. +pub(crate) struct TreeBaseInput<'a> { + pub route: &'a V3Route, + pub root: &'a Path, + /// Walker-admitted paths of the checkout; only tracked regular files + /// among them become members. + pub paths: &'a [RelPath], + pub policy: TrigramPolicy, + pub producers: &'a Producers, + pub ignore_fingerprint: Option, +} + +/// A base manifest whose every member was read and matched to the index, +/// with the bytes read and their receipts. +struct ProvenMembers { + manifest: ManifestV2, + bytes: BTreeMap>, + receipts: BTreeMap, + format: ObjectFormat, +} + +/// Why a fresh checkout gets no tree-keyed base. +pub(crate) type NoTreeProof = &'static str; + +/// Reads and proves the members, or names the first check that failed. +fn prove(input: &TreeBaseInput<'_>, guard: &BindGuard) -> Result { + if !guard.policy.reusable { + return Err("conversion_policy_not_reusable"); + } + let head_tree = guard + .head_tree + .as_deref() + .and_then(|tree| std::str::from_utf8(tree.trim_ascii()).ok()) + .ok_or("no_head_tree")?; + let format = ObjectFormat::from_hex_len(head_tree.len()).ok_or("unknown_object_format")?; + let index_path = guard.gitdir.as_ref().ok_or("no_gitdir")?.0.join("index"); + let IndexRead::Snapshot(snapshot) = read_index(&index_path, format, Default::default()) else { + return Err("index_not_coherent"); + }; + if NativeStat::strict_read(&index_path).ok().as_ref() + != guard.index.as_ref().map(|(stat, _)| stat) + { + return Err("index_changed_since_guard"); + } + if to_hex(&index_tree(&snapshot).map_err(|_| "index_tree_refused")?) != head_tree { + return Err("index_tree_differs_from_head"); + } + let index: BTreeMap<&[u8], _> = snapshot + .entries + .iter() + .map(|entry| (entry.path.as_slice(), entry)) + .collect(); + let header = ManifestHeader { + producers: input.producers.clone(), + head_tree: Some(head_tree.to_owned()), + ignore_fingerprint: input.ignore_fingerprint.clone(), + segment: None, + }; + let mut manifest = ManifestV2::new_v3(header, None, Default::default()); + let mut bytes = BTreeMap::new(); + let mut receipts = BTreeMap::new(); + for path in input.paths { + // Untracked files and tracked symlinks or gitlinks stay out of the + // base; the worker's own delta carries whatever the walker admits. + let Some(entry) = index.get(path.as_bytes()) else { + continue; + }; + if !matches!(entry.stat.mode, 0o100644 | 0o100755) { + continue; + } + let absolute = input + .root + .join(super::segment_store::rel_path_to_os(path).map_err(|_| "unrepresentable_path")?); + let observed_ns = super::verification::now_ns(); + let unreadable = |_| "member_unreadable"; + let before = NativeStat::read(&absolute).map_err(unreadable)?; + if !std::fs::symlink_metadata(&absolute) + .map_err(unreadable)? + .is_file() + { + return Err("member_not_a_regular_file"); + } + let source = std::fs::read(&absolute).map_err(unreadable)?; + let after = NativeStat::read(&absolute).map_err(unreadable)?; + if before != after || before.size != source.len() as u64 { + return Err("member_changed_during_read"); + } + let oid = format.blob_oid(&source); + if oid != entry.oid { + return Err("member_differs_from_index_blob"); + } + #[cfg(unix)] + if (before.mode & 0o111 != 0) != (entry.stat.mode == 0o100755) { + return Err("member_mode_differs_from_index"); + } + let content = crate::blob_store::v2::ContentHash::of(&source); + let planes = EntryPlanes { + trigram: Some(PlaneState::pending("lexical plane not built")), + semantic: input + .producers + .semantic + .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(), + EntryV2::Regular { + mode: entry.stat.mode, + content, + size: source.len() as u64, + git_oid: Some(to_hex(&oid)), + resolution_input: false, + planes, + }, + ) + .map_err(|_| "invalid_manifest_entry")?; + receipts.insert( + path.clone(), + BlobReceipt { + version: 1, + mode: entry.stat.mode, + size: source.len() as u64, + content: content.to_hex(), + git_oid: to_hex(&oid), + stat: before, + observed_ns, + policy: guard.policy.digest.clone(), + delivery_seq: 0, + }, + ); + bytes.insert(path.clone(), source); + } + if manifest.is_empty() { + return Err("no_tracked_members"); + } + Ok(ProvenMembers { + manifest, + bytes, + receipts, + format, + }) +} + +/// Resolves or builds the tree-keyed base of a fresh checkout. `None` means no +/// tree-keyed base is available now (no proof, a busy or capped claim, a +/// refused admission or a failed build); the caller then takes its +/// membership-keyed path unchanged. A claim this call keeps is left in +/// `claim_slot` for the caller to retire, as for membership-keyed builds. +pub(crate) fn bootstrap( + input: TreeBaseInput<'_>, + claim_slot: &Mutex>, +) -> Option { + try_bootstrap(input, claim_slot).ok() +} + +/// [`bootstrap`] with the reason a tree-keyed base is unavailable. +fn try_bootstrap( + input: TreeBaseInput<'_>, + claim_slot: &Mutex>, +) -> Result { + let guard = BindGuard::capture(input.root, None).map_err(|e| e.to_string())?; + let mut proven = prove(&input, &guard)?; + let route = input.route; + let identity = BaseIdentity::of(&route.family, &proven.manifest).map_err(|e| e.to_string())?; + if let Ok(base) = resolve_base(&route.storage, &route.family, &identity.scope) { + return Ok(base); + } + let ClaimOutcome::Claimed(claim) = + claim_base(&route.storage, &route.family, &identity.scope, None) + .map_err(|e| e.to_string())? + else { + return Err("tree_base_claim_unavailable".into()); + }; + match build(route, &identity, &guard, &mut proven, input.policy, &claim) { + Ok(()) => { + let base = resolve_base(&route.storage, &route.family, &identity.scope) + .map_err(|e| e.to_string()); + *claim_slot + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(claim); + base + } + Err(error) => { + let _ = claim.release(); + Err(error) + } + } +} + +/// The base a fresh checkout binds: `tree` when [`bootstrap`] produced one, +/// otherwise the membership-keyed base `scope`, resolved as before. +pub(crate) fn resolve_preferring( + tree: Option, + route: &V3Route, + scope: &str, +) -> super::base_refs::Result { + match tree { + Some(base) => Ok(base), + None => resolve_base(&route.storage, &route.family, scope), + } +} + +/// The claimed build: admission, member registration, the proof file, the +/// lexical plane and the first publication, in that order. +fn build( + route: &V3Route, + identity: &BaseIdentity, + guard: &BindGuard, + proven: &mut ProvenMembers, + policy: TrigramPolicy, + claim: &BuildClaim, +) -> Result<(), String> { + let storage = route.storage.as_path(); + let family = route.family.as_str(); + let error = |e: &dyn std::fmt::Display| e.to_string(); + let source_bytes = proven.bytes.values().map(|b| b.len() as u64).sum(); + let request = initial_reservation(proven.bytes.len() as u64, source_bytes, None); + let reservation = { + let lock = v3::ReservationLock::acquire(storage).map_err(|e| error(&e))?; + let mut registry = + v3::open_base_refs_registry(storage, family, &lock, None).map_err(|e| error(&e))?; + super::eviction::DiskBudget::default() + .admit_v3(&mut registry, request) + .map_err(|e| error(&e))? + .reservation + }; + let built = (|| -> Result<(), String> { + let (dir, _) = with_base_barrier(storage, family, |tx| { + super::base_builder::register_base_in(tx, storage, family, &identity.scope) + }) + .map_err(|e| error(&e))?; + // A base that is already published keeps its proof unchanged: a + // proof is written only before a scope's first publication. Opening + // the store also creates the member directory the proof lives in. + if super::ViewStore::open_dir(dir.clone()) + .and_then(|store| store.current_generation()) + .map_err(|e| error(&e))? + .is_some() + { + return Ok(()); + } + super::verification::write_tree_proof( + storage, + family, + &dir, + &TreeProof { + base_scope: identity.scope.clone(), + identity_digest: to_hex(&identity.identity), + membership_digest: to_hex(&identity.membership), + object_format: proven.format.name().to_owned(), + head_tree: proven + .manifest + .header() + .head_tree + .clone() + .unwrap_or_default(), + root: guard.root.clone(), + policy: guard.policy.digest.clone(), + receipts: proven.receipts.clone(), + }, + ) + .map_err(|e| error(&e))?; + let mut live = crate::pins::LivePin::create_v3(storage, family, &identity.scope) + .map_err(|e| error(&e))?; + super::trigram::materialize_v3( + storage, + family, + &mut proven.manifest, + &proven.bytes, + &mut live, + policy, + ) + .map_err(|e| e.reason)?; + if !claim.verify_held().map_err(|e| error(&e))? { + return Err("the build claim was taken over".into()); + } + match super::base_builder::publish_base(storage, family, &proven.manifest, None) + .map_err(|e| error(&e))? + { + super::base_builder::BasePublication::Published { .. } => Ok(()), + super::base_builder::BasePublication::Lost { .. } => { + Err("another publisher moved the base pointer".into()) + } + } + })(); + let released = (|| -> Result<(), String> { + let lock = v3::ReservationLock::acquire(storage).map_err(|e| error(&e))?; + let mut registry = + v3::RegistryWriter::open_for_barrier(storage, family, &lock).map_err(|e| error(&e))?; + registry.release(reservation).map_err(|e| error(&e)) + })(); + built?; + released?; + claim.mark_serving().map_err(|e| error(&e)) +} + +/// Adds the receipts of `durable`'s tree-keyed base to the checkout's own +/// generation evidence, as candidates for the same stat-reuse checks. A +/// receipt replaces an evidence record only when it records the same stat +/// (the same observation) or the path has no record. Returns the merged +/// candidates and the paths whose candidate is a receipt. A membership-keyed +/// base, or a tree-keyed base whose proof does not hold now, adds nothing. +pub(crate) fn with_receipts( + mut evidence: BTreeMap, + durable: Option<&ComposedGeneration>, + guard: Option<&BindGuard>, + route: &V3Route, +) -> (BTreeMap, BTreeSet) { + let mut from_receipts = BTreeSet::new(); + let (Some(durable), Some(guard)) = (durable, guard) else { + return (evidence, from_receipts); + }; + let Some(header) = durable.base_header() else { + return (evidence, from_receipts); + }; + if super::base_refs::base_kind(&header.scope) != Some(BaseKind::Tree) { + return (evidence, from_receipts); + } + let Some(base) = resolve_base(&route.storage, &route.family, &header.scope) + .ok() + .filter(|base| base.check_header(header).is_ok()) + else { + return (evidence, from_receipts); + }; + let Some(receipts) = super::verification::tree_receipt_evidence(&base, guard) else { + return (evidence, from_receipts); + }; + for (path, mut record) in receipts { + if evidence + .get(&path) + .is_some_and(|existing| existing.stat != record.stat) + { + continue; + } + // Plane keys come from the checkout's own readiness; the reuse check + // still requires each to be ready, expected and present. + for plane in FamilyPlane::ALL { + if let Some(PlaneState::Ready { key }) = durable.plane_readiness(&path, plane) { + record.keys.insert(format!("{plane:?}"), key.clone()); + } + } + evidence.insert(path.clone(), record); + from_receipts.insert(path); + } + (evidence, from_receipts) +} + +#[cfg(test)] +#[path = "tree_base_tests.rs"] +mod tests; diff --git a/crates/aft/src/views/tree_base_tests.rs b/crates/aft/src/views/tree_base_tests.rs new file mode 100644 index 0000000000..6e8898f971 --- /dev/null +++ b/crates/aft/src/views/tree_base_tests.rs @@ -0,0 +1,445 @@ +//! Tree-keyed bases through the checkout driver: which binds may skip a +//! member's read on its blob receipt, and which must take the full check. + +use std::path::Path; +use std::process::Command; +use std::sync::Arc; + +use crate::blob_store::v2::{FamilyPlane, TrigramPolicy}; +use crate::views::base_refs::{base_kind, open_foreign_v3, BaseKind}; +use crate::views::first_load::{ + CheckoutDriver, ConfiguredMembershipWalker, FirstLoadDriver, ReconcileWork, TrigramBridge, +}; +use crate::views::manifest_v2::Producers; +use crate::views::snapshot::{DiskState, Snapshot}; +use crate::views::verification::{ + evidence_path, known_attribute_sources_for_test, read_tree_proof, write_tree_proof, + BlobReceipt, FileIdentity, NativeStat, TreeProof, TREE_PROOF_APP, TREE_PROOF_FILE, +}; +use crate::views::RelPath; + +const FAMILY: &str = "family"; +const WORKER: &str = "worker"; +const FILES: [(&str, &[u8]); 2] = [ + ("file.rs", b"fn tree_member() { let marker = 41; }\n"), + ("lib.rs", b"pub fn other_member() -> u8 { 7 }\n"), +]; + +fn git(root: &Path, args: &[&str]) { + let mut command = Command::new("git"); + command.current_dir(root).args(args); + crate::test_env::apply_hermetic_git_env(&mut command); + let output = command.output().unwrap(); + assert!( + output.status.success(), + "git {args:?}: {}", + String::from_utf8_lossy(&output.stderr) + ); +} + +/// Writes `bytes` with an mtime well before any observation second, so a +/// stable read of it is reusable evidence. +fn write_old(path: &Path, bytes: &[u8]) { + std::fs::write(path, bytes).unwrap(); + std::fs::OpenOptions::new() + .write(true) + .open(path) + .unwrap() + .set_times( + std::fs::FileTimes::new() + .set_modified(std::time::UNIX_EPOCH + std::time::Duration::from_secs(60)), + ) + .unwrap(); +} + +/// A checkout of `FILES`; committed (clean) when `commit` is set, otherwise +/// a plain directory without Git. +fn checkout(commit: bool) -> tempfile::TempDir { + let root = tempfile::tempdir().unwrap(); + for (name, bytes) in FILES { + write_old(&root.path().join(name), bytes); + } + if commit { + git(root.path(), &["init", "-q", "--object-format=sha1"]); + git( + root.path(), + &["config", "user.email", "tree@example.invalid"], + ); + git(root.path(), &["config", "user.name", "Tree Base"]); + git(root.path(), &["config", "core.autocrlf", "false"]); + git(root.path(), &["add", "."]); + git( + root.path(), + &[ + "-c", + "commit.gpgsign=false", + "commit", + "-q", + "-m", + "fixture", + ], + ); + } + root +} + +fn policy() -> TrigramPolicy { + TrigramPolicy { + max_file_size: 1 << 20, + } +} + +fn producers() -> Producers { + Producers { + trigram: policy().fingerprint_hex(), + semantic: None, + callgraph: "unregistered".into(), + } +} + +fn driver(root: &Path, storage: &Path) -> CheckoutDriver { + let plane = Arc::new(TrigramBridge::new(storage.to_owned(), policy())); + CheckoutDriver::new_v3( + storage, + FAMILY, + WORKER, + root, + producers(), + Arc::new(ConfiguredMembershipWalker), + vec![plane.clone()], + ) + .unwrap() + .with_adapters(vec![plane.adapter.clone()]) +} + +/// The first bind of a fresh checkout: bootstraps a base and publishes the +/// worker over it. Returns the base scope. +fn bootstrap(root: &Path, storage: &Path) -> String { + let first = driver(root, storage); + let access = first.access(); + let empty = first.installed_snapshot().generation().clone(); + let (snapshot, revision) = first.verified_bind(&access, empty).unwrap(); + first.install(&access, &snapshot, revision).unwrap(); + snapshot.generation().base_header().unwrap().scope.clone() +} + +/// A later bind of the same checkout by a fresh driver (as after a restart). +fn rebind(root: &Path, storage: &Path) -> (Snapshot, ReconcileWork) { + let next = driver(root, storage); + let published = open_foreign_v3(storage, FAMILY, WORKER, &producers()) + .unwrap() + .unwrap(); + let (snapshot, _) = next.verified_bind(&next.access(), published).unwrap(); + (snapshot, next.take_reconcile_work()) +} + +fn member_dir(storage: &Path, scope: &str) -> std::path::PathBuf { + crate::blob_store::v3::view_dir(storage, FAMILY, scope).unwrap() +} + +/// Removes the worker generation's own stat evidence, so only a base's +/// receipts could avoid a read. +fn remove_worker_evidence(storage: &Path) { + let dir = member_dir(storage, WORKER); + let generation = crate::views::ViewStore::existing_dir(dir.clone()) + .unwrap() + .current_generation_read_only() + .unwrap() + .unwrap(); + std::fs::remove_file(evidence_path(&dir, &generation)).unwrap(); +} + +fn rel(name: &str) -> RelPath { + RelPath::new(name.as_bytes().to_vec()).unwrap() +} + +fn assert_current_bytes(snapshot: &Snapshot, root: &Path) { + for (name, _) in FILES { + assert_eq!( + snapshot.disk_state(&rel(name)), + DiskState::of_bytes(&std::fs::read(root.join(name)).unwrap()), + "{name}" + ); + assert!(matches!( + snapshot.plane_readiness(&rel(name), FamilyPlane::Trigram), + Some(crate::views::readiness::PlaneState::Ready { .. }) + )); + } +} + +#[test] +fn a_proven_tree_base_takes_the_receipt_fast_path() { + let _serial = crate::blob_store::v3::serial_for_test(); + let root = checkout(true); + let storage = tempfile::tempdir().unwrap(); + let _attributes = known_attribute_sources_for_test(root.path()); + let scope = bootstrap(root.path(), storage.path()); + assert_eq!(base_kind(&scope), Some(BaseKind::Tree)); + let proof = read_tree_proof(&member_dir(storage.path(), &scope)).unwrap(); + assert_eq!(proof.receipts.len(), FILES.len()); + for (name, bytes) in FILES { + let receipt = &proof.receipts[&rel(name)]; + assert_eq!( + receipt.git_oid, + crate::blob_store::v2::to_hex( + &crate::git_index_reader::ObjectFormat::Sha1.blob_oid(bytes) + ) + ); + } + + let (snapshot, work) = rebind(root.path(), storage.path()); + assert_eq!(work.tree_receipts, FILES.len(), "{work:?}"); + assert_eq!(work.reads, 0, "{work:?}"); + assert_current_bytes(&snapshot, root.path()); + + // The receipts alone suffice: the worker's own evidence is not needed. + remove_worker_evidence(storage.path()); + let (snapshot, work) = rebind(root.path(), storage.path()); + assert_eq!(work.tree_receipts, FILES.len(), "{work:?}"); + assert_eq!(work.reads, 0, "{work:?}"); + assert_current_bytes(&snapshot, root.path()); +} + +#[test] +fn a_membership_base_never_takes_the_receipt_fast_path() { + let _serial = crate::blob_store::v3::serial_for_test(); + for commit in [false, true] { + let root = checkout(commit); + let storage = tempfile::tempdir().unwrap(); + let _attributes = known_attribute_sources_for_test(root.path()); + if commit { + // A dirty tracked file: the index no longer proves the bytes. + write_old(&root.path().join("file.rs"), b"fn edited() {}\n"); + } + let scope = bootstrap(root.path(), storage.path()); + assert_eq!(base_kind(&scope), Some(BaseKind::Membership), "{commit}"); + assert!(!member_dir(storage.path(), &scope) + .join(TREE_PROOF_FILE) + .exists()); + + let (_, work) = rebind(root.path(), storage.path()); + assert_eq!(work.tree_receipts, 0, "{work:?}"); + remove_worker_evidence(storage.path()); + let (snapshot, work) = rebind(root.path(), storage.path()); + assert_eq!(work.tree_receipts, 0, "{work:?}"); + assert_eq!(work.reads, FILES.len(), "full check: {work:?}"); + assert_current_bytes(&snapshot, root.path()); + } +} + +#[test] +fn a_tree_base_without_a_holding_proof_falls_back_to_the_full_check() { + let _serial = crate::blob_store::v3::serial_for_test(); + let root = checkout(true); + let storage = tempfile::tempdir().unwrap(); + let _attributes = known_attribute_sources_for_test(root.path()); + let scope = bootstrap(root.path(), storage.path()); + assert_eq!(base_kind(&scope), Some(BaseKind::Tree)); + remove_worker_evidence(storage.path()); + let proof_path = member_dir(storage.path(), &scope).join(TREE_PROOF_FILE); + let original = std::fs::read(&proof_path).unwrap(); + let full_check = |label: &str| { + let (snapshot, work) = rebind(root.path(), storage.path()); + assert_eq!(work.tree_receipts, 0, "{label}: {work:?}"); + assert_eq!(work.reads, FILES.len(), "{label}: {work:?}"); + assert_current_bytes(&snapshot, root.path()); + }; + + // No proof file at all. + std::fs::remove_file(&proof_path).unwrap(); + full_check("missing proof"); + + // A receipt edited after the proof was written. + std::fs::write(&proof_path, &original).unwrap(); + { + let db = rusqlite::Connection::open(&proof_path).unwrap(); + let record: Vec = db + .query_row( + "SELECT record FROM blob_receipts WHERE path=?1", + [b"lib.rs".as_slice()], + |r| r.get(0), + ) + .unwrap(); + let mut receipt: BlobReceipt = serde_json::from_slice(&record).unwrap(); + receipt.observed_ns += 1; + db.execute( + "UPDATE blob_receipts SET record=?1 WHERE path=?2", + rusqlite::params![serde_json::to_vec(&receipt).unwrap(), b"lib.rs".as_slice()], + ) + .unwrap(); + } + full_check("receipt digest mismatch"); + + // A proof from a reader of another format. + std::fs::write(&proof_path, &original).unwrap(); + rusqlite::Connection::open(&proof_path) + .unwrap() + .pragma_update(None, "user_version", 2) + .unwrap(); + full_check("newer proof version"); + + // An intact proof, but Git no longer proves the full index. + std::fs::write(&proof_path, &original).unwrap(); + git( + root.path(), + &["update-index", "--assume-unchanged", "lib.rs"], + ); + full_check("assume-unchanged entry"); + git( + root.path(), + &["update-index", "--no-assume-unchanged", "lib.rs"], + ); + + // Restoring everything restores the fast path. + let (_, work) = rebind(root.path(), storage.path()); + assert_eq!(work.tree_receipts, FILES.len(), "{work:?}"); + assert_eq!(work.reads, 0, "{work:?}"); +} + +#[test] +fn a_file_whose_bytes_no_longer_match_its_receipt_takes_the_full_check() { + let _serial = crate::blob_store::v3::serial_for_test(); + let root = checkout(true); + let storage = tempfile::tempdir().unwrap(); + let _attributes = known_attribute_sources_for_test(root.path()); + let scope = bootstrap(root.path(), storage.path()); + assert_eq!(base_kind(&scope), Some(BaseKind::Tree)); + remove_worker_evidence(storage.path()); + // Same length and the same mtime as the receipt recorded: only the change + // time and the bytes differ, and the index still names the old blob. + let edited = FILES[0].1.to_vec(); + let edited: Vec = edited + .into_iter() + .map(|b| if b == b'1' { b'2' } else { b }) + .collect(); + assert_ne!(edited, FILES[0].1); + write_old(&root.path().join(FILES[0].0), &edited); + + let (snapshot, work) = rebind(root.path(), storage.path()); + assert_eq!(work.reads, 1, "{work:?}"); + assert_eq!(work.tree_receipts, 1, "{work:?}"); + assert_eq!( + snapshot.disk_state(&rel(FILES[0].0)), + DiskState::of_bytes(&edited) + ); + // The unchanged member keeps its ready base entry; the edited one waits + // for its own lexical entry like any other changed file. + assert_eq!( + snapshot.disk_state(&rel(FILES[1].0)), + DiskState::of_bytes(FILES[1].1) + ); + assert!(matches!( + snapshot.plane_readiness(&rel(FILES[1].0), FamilyPlane::Trigram), + Some(crate::views::readiness::PlaneState::Ready { .. }) + )); +} + +#[test] +fn the_tree_proof_names_the_first_check_a_checkout_fails() { + let _serial = crate::blob_store::v3::serial_for_test(); + let storage = tempfile::tempdir().unwrap(); + let route = crate::views::snapshot::V3Route { + storage: storage.path().to_owned(), + family: FAMILY.into(), + scope: WORKER.into(), + }; + let paths: Vec = FILES.iter().map(|(name, _)| rel(name)).collect(); + let producers = producers(); + let outcome = |root: &Path| { + let input = super::TreeBaseInput { + route: &route, + root, + paths: &paths, + policy: policy(), + producers: &producers, + ignore_fingerprint: None, + }; + let guard = crate::views::verification::BindGuard::capture(root, None).unwrap(); + super::prove(&input, &guard).map(|proven| proven.receipts.len()) + }; + let plain = checkout(false); + let _plain_attributes = known_attribute_sources_for_test(plain.path()); + assert_eq!(outcome(plain.path()), Err("no_head_tree")); + let root = checkout(true); + let _attributes = known_attribute_sources_for_test(root.path()); + assert_eq!(outcome(root.path()), Ok(FILES.len())); + let slot = std::sync::Mutex::new(None); + let built = super::try_bootstrap( + super::TreeBaseInput { + route: &route, + root: root.path(), + paths: &paths, + policy: policy(), + producers: &producers, + ignore_fingerprint: None, + }, + &slot, + ); + assert_eq!( + built.as_ref().err(), + None, + "a clean checkout builds a tree-keyed base" + ); + assert_eq!(built.unwrap().identity().kind(), BaseKind::Tree); + slot.lock().unwrap().take().unwrap().release().unwrap(); + git( + root.path(), + &["update-index", "--assume-unchanged", "lib.rs"], + ); + assert_eq!(outcome(root.path()), Err("index_tree_refused")); + git( + root.path(), + &["update-index", "--no-assume-unchanged", "lib.rs"], + ); + write_old(&root.path().join("lib.rs"), b"pub fn edited() {}\n"); + assert_eq!(outcome(root.path()), Err("member_differs_from_index_blob")); + git(root.path(), &["add", "lib.rs"]); + assert_eq!(outcome(root.path()), Err("index_tree_differs_from_head")); +} + +#[test] +fn a_tree_proof_round_trips_and_refuses_another_application() { + let _serial = crate::blob_store::v3::serial_for_test(); + let storage = tempfile::tempdir().unwrap(); + let dir = storage.path().join("member"); + std::fs::create_dir_all(&dir).unwrap(); + let file = dir.join("source"); + std::fs::write(&file, b"bytes").unwrap(); + let receipt = BlobReceipt { + version: 1, + mode: 0o100644, + size: 5, + content: crate::blob_store::v2::ContentHash::of(b"bytes").to_hex(), + git_oid: "1".repeat(40), + stat: NativeStat::read(&file).unwrap(), + observed_ns: 7, + policy: "policy".into(), + delivery_seq: 0, + }; + let proof = TreeProof { + base_scope: "base-t1-00".into(), + identity_digest: "aa".into(), + membership_digest: "bb".into(), + object_format: "sha1".into(), + head_tree: "2".repeat(40), + root: FileIdentity::StrictOnly { + volume: 1, + index: 2, + birth: None, + }, + policy: "policy".into(), + receipts: [(rel("a.rs"), receipt)].into_iter().collect(), + }; + write_tree_proof(storage.path(), FAMILY, &dir, &proof).unwrap(); + assert_eq!(read_tree_proof(&dir), Some(proof)); + assert!(std::fs::read_dir(&dir).unwrap().all(|entry| !entry + .unwrap() + .file_name() + .to_string_lossy() + .contains(".tmp."))); + let db = rusqlite::Connection::open(dir.join(TREE_PROOF_FILE)).unwrap(); + db.pragma_update(None, "application_id", TREE_PROOF_APP + 1) + .unwrap(); + drop(db); + assert_eq!(read_tree_proof(&dir), None); +} diff --git a/crates/aft/src/views/trigram.rs b/crates/aft/src/views/trigram.rs index 4dbbab59e0..46df429586 100644 --- a/crates/aft/src/views/trigram.rs +++ b/crates/aft/src/views/trigram.rs @@ -1,10 +1,11 @@ //! Opt-in trigram views: shared segment, generation overlay, and live delta. //! -//! No adapter is registered in `contracts::process_hooks` by this module; -//! existing search commands keep using `SearchIndex` by default. +//! Public lexical queries select a checkout-local v3 reader only with views on. +//! Views off retains the legacy `SearchIndex` route. //! -//! Runtime call sequence: obtain current bytes with `live_delta::strict_walk`, -//! project those entries into the complete v2 manifest, call `materialize` +//! `LexicalView` acquires v3 snapshots through `CheckoutDriver::verified_bind`. +//! The lower-level segment API also supports v2 fixtures: obtain current bytes +//! with `live_delta::strict_walk`, project a complete v2 manifest, call `materialize` //! while keeping its returned pin alive, materialize the other planes, and //! publish with the core generation CAS. Open the verified pinned generation //! with `TrigramAdapter::open_generation`. Supply `TrigramDriver` with a @@ -26,6 +27,423 @@ use super::segment_store::{rel_path_to_os, SegmentReader, TrigramFlag, TrigramPa use super::snapshot::{DiskState, Snapshot, Source, WatcherState}; use super::RelPath; +/// `root` joined with a checkout-relative path in the platform's own +/// separators. `RelPath` stores `/`; on Windows a `/` left inside the result +/// gives a mixed spelling unlike the legacy index's paths, and under a `\\?\` +/// root (the canonical form of checkout roots there) Windows reads it +/// literally, so the file is not found. Each component is pushed separately. +fn absolute_member_path( + root: &Path, + path: &RelPath, +) -> super::segment_store::SegmentResult { + let relative = rel_path_to_os(path)?; + #[cfg(windows)] + if let Some(text) = relative.to_str().filter(|text| text.contains('/')) { + let mut absolute = root.to_path_buf(); + absolute.extend(text.split('/')); + return Ok(absolute); + } + Ok(root.join(relative)) +} +/// Checkout-local lexical authority, independent of the semantic worker's +/// lifetime and model availability. Queries serialize reconciliation so an +/// installed membership and its payloads always come from the same snapshot. +pub(crate) struct LexicalView { + pub(crate) root: PathBuf, + storage: crate::root_cache::StoragePaths, + family: String, + policy: TrigramPolicy, + source: Arc, + query: std::sync::Mutex<()>, + ready: std::sync::atomic::AtomicBool, +} + +/// Supplies a verified checkout snapshot without exposing its storage layout +/// to the lexical query engine. Implementations must never publish v1/v2 data. +pub(crate) trait LexicalSnapshotSource: Send + Sync { + fn acquire(&self) -> Result; + fn record_change(&self, path: &Path); + fn record_overflow(&self) {} + fn record_cookie(&self, _: &Path) {} +} + +struct CheckoutLexicalSource { + driver: Arc, + access: super::contracts::ViewAccess, + storage: PathBuf, + family: String, + scope: String, + producers: super::manifest_v2::Producers, +} + +impl LexicalSnapshotSource for CheckoutLexicalSource { + fn acquire(&self) -> Result { + use super::first_load::FirstLoadDriver; + let installed = self.driver.installed_snapshot(); + let seed = if installed.generation().name() == "empty" { + match super::base_refs::open_foreign_v3( + &self.storage, + &self.family, + &self.scope, + &self.producers, + ) { + Ok(Some(generation)) => generation, + Ok(None) => installed.generation().clone(), + Err(error) => { + crate::slog_debug!( + "lexical v3 seed unavailable; reconciling current tree: {}", + error + ); + installed.generation().clone() + } + } + } else { + installed.generation().clone() + }; + let (snapshot, revision) = self + .driver + .verified_bind(&self.access, seed) + .map_err(|error| error.to_string())?; + self.driver + .install(&self.access, &snapshot, revision) + .map_err(|error| error.to_string())?; + // Read the installed cut directly, as the call-graph source does. + // `installed_snapshot` marks a checkout without a steady watcher + // subscription (no watcher, an unproved or incomplete one, or an + // unsupported filesystem) overflowed at once. That mark only decides + // that the next bind must walk the whole checkout again; this cut was + // just verified against the disk, so it is safe to serve. + let (snapshot, _) = self.driver.installed_cut(); + Ok(snapshot) + } + + fn record_change(&self, path: &Path) { + self.driver.record_absolute_change(path); + } + + fn record_overflow(&self) { + self.driver.record_watcher_overflow(); + } + fn record_cookie(&self, path: &Path) { + self.driver.record_watcher_cookie(path); + } +} + +impl LexicalView { + pub(crate) fn matches( + &self, + root: &Path, + storage: &crate::root_cache::StoragePaths, + family: &str, + policy: TrigramPolicy, + ) -> bool { + self.root == root + && self.storage == *storage + && self.family == family + && self.policy == policy + } + + pub(crate) fn new( + root: PathBuf, + storage: crate::root_cache::StoragePaths, + family: String, + policy: TrigramPolicy, + ) -> Result { + let scope = crate::path_identity::project_scope_key(&root); + let producers = super::manifest_v2::Producers { + trigram: policy.fingerprint_hex(), + semantic: None, + callgraph: super::semantic_runtime::UNREGISTERED_PRODUCER.into(), + }; + let bridge = Arc::new(super::first_load::TrigramBridge::new( + storage.artifacts.clone(), + policy, + )); + let adapter: Arc = bridge.adapter.clone(); + let driver = Arc::new( + super::first_load::CheckoutDriver::new_v3( + &storage.artifacts, + &family, + &scope, + &root, + producers.clone(), + Arc::new(super::first_load::ConfiguredMembershipWalker), + vec![bridge], + ) + .map_err(|error| error.to_string())? + .with_adapters(vec![adapter]), + ); + driver.register_write_intent(); + let source = Arc::new(CheckoutLexicalSource { + access: driver.access(), + driver, + storage: storage.artifacts.clone(), + family: family.clone(), + scope, + producers, + }); + Ok(Self::with_source(root, storage, family, policy, source)) + } + + pub(crate) fn with_source( + root: PathBuf, + storage: crate::root_cache::StoragePaths, + family: String, + policy: TrigramPolicy, + source: Arc, + ) -> Self { + Self { + root, + storage, + family, + policy, + source, + query: std::sync::Mutex::new(()), + ready: std::sync::atomic::AtomicBool::new(false), + } + } + + pub(crate) fn record_change(&self, path: &Path) { + self.ready + .store(false, std::sync::atomic::Ordering::Release); + self.source.record_change(path); + } + + pub(crate) fn record_overflow(&self) { + self.ready + .store(false, std::sync::atomic::Ordering::Release); + self.source.record_overflow(); + } + + pub(crate) fn record_cookie(&self, path: &Path) { + self.source.record_cookie(path); + } + + pub(crate) fn is_ready(&self) -> bool { + self.ready.load(std::sync::atomic::Ordering::Acquire) + } + + #[cfg(test)] + pub(crate) fn source_for_test(&self) -> Arc { + self.source.clone() + } + + /// Keep acquisition separate from the read projection so a source that + /// verifies a saved generation can supply the same query snapshot. + fn acquire(&self) -> Result { + self.source.acquire() + } + + pub(crate) fn snapshot(&self) -> Result { + let result = self.project(); + self.ready + .store(result.is_ok(), std::sync::atomic::Ordering::Release); + result + } + + /// A watcher event or an AFT write can land between a bind's install and + /// the read of its cut, leaving a path in the fresh cut unverified. Bind + /// again, a bounded number of times: each bind re-reads such paths from + /// disk. Only a checkout that keeps changing through every attempt is + /// refused. + fn verified_snapshot(&self) -> Result { + const ATTEMPTS: usize = 3; + for _ in 0..ATTEMPTS { + let snapshot = self.acquire()?; + if snapshot.pending_intent().next().is_none() + && snapshot.watcher() == WatcherState::Healthy + { + return Ok(snapshot); + } + } + Err("lexical view has unverified writes".into()) + } + + fn project(&self) -> Result { + use super::segment_store::PayloadSource; + let _query = self + .query + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let snapshot = self.verified_snapshot()?; + let mut source = None; + let mut rows = Vec::new(); + for (path, disk) in snapshot.membership() { + let DiskState::Present { content, size } = disk else { + continue; + }; + let absolute = + absolute_member_path(&self.root, &path).map_err(|error| error.to_string())?; + let key = crate::blob_store::v2::TrigramKey { + content, + policy: self.policy, + } + .family_key(); + let payload = match snapshot.source(&path) { + Source::Live(entry) => entry + .attachments + .get(&FamilyPlane::Trigram) + .and_then(|value| value.downcast_ref::()) + .filter(|attachment| { + attachment.content == content + && attachment.policy == self.policy.fingerprint() + }) + .map(|attachment| attachment.payload.clone()), + _ => None, + }; + let payload = match payload { + Some(payload) => payload, + None => { + if snapshot.plane_readiness(&path, FamilyPlane::Trigram) + != Some(&super::readiness::PlaneState::ready(&key)) + { + return Err(format!("lexical payload not ready: {}", absolute.display())); + } + if source.is_none() { + source = Some( + crate::blob_store::v3::PlaneReader::open_existing( + &self.storage.artifacts, + &self.family, + FamilyPlane::Trigram, + ) + .map_err(|error| error.to_string())?, + ); + } + let bytes = source + .as_ref() + .unwrap() + .payload(&key) + .map_err(|error| error.to_string())? + .ok_or_else(|| { + format!("lexical payload missing: {}", absolute.display()) + })?; + TrigramPayload::decode(&bytes).map_err(|error| error.to_string())? + } + }; + let metadata = fs::metadata(&absolute).map_err(|error| error.to_string())?; + rows.push(( + crate::search_index::FileEntry { + path: absolute, + size, + modified: metadata.modified().map_err(|error| error.to_string())?, + content_hash: blake3::Hash::from_bytes(*content.as_bytes()), + }, + (payload.flag == TrigramFlag::Indexed).then(|| { + payload + .records + .into_iter() + .map(|record| (record.trigram, record.next_mask, record.loc_mask)) + .collect() + }), + )); + } + Ok( + crate::search_index::SearchIndexSnapshot::from_verified_rows( + &self.root, + self.policy.max_file_size, + rows, + ), + ) + } +} + +#[cfg(test)] +pub(crate) struct BaseBuilderTestSource { + root: PathBuf, + storage: PathBuf, + family: String, + policy: TrigramPolicy, + snapshots: std::sync::Mutex>, +} + +#[cfg(test)] +impl BaseBuilderTestSource { + pub(crate) fn new( + root: &Path, + storage: &Path, + family: &str, + policy: TrigramPolicy, + ) -> Arc { + Arc::new(Self { + root: root.into(), + storage: storage.into(), + family: family.into(), + policy, + snapshots: Default::default(), + }) + } +} + +#[cfg(test)] +impl LexicalSnapshotSource for BaseBuilderTestSource { + fn acquire(&self) -> Result { + let walk = strict_walk(&self.root, &self.policy, 0); + if !walk.gaps.is_empty() { + return Err(format!("strict test walk gaps: {:?}", walk.gaps)); + } + let mut hash = blake3::Hasher::new(); + for (path, entry) in &walk.entries { + hash.update(&(path.as_bytes().len() as u64).to_be_bytes()); + hash.update(path.as_bytes()); + if let DiskState::Present { content, .. } = entry.disk { + hash.update(content.as_bytes()); + } + } + let key = *hash.finalize().as_bytes(); + let mut snapshots = self.snapshots.lock().unwrap(); + if let Some((snapshot, _)) = snapshots.get(&key) { + return Ok(snapshot.clone()); + } + let paths = walk.entries.into_keys().collect::>(); + let built = super::base_builder::build_base( + super::base_builder::BaseBuildInput { + storage: &self.storage, + family: &self.family, + root: &self.root, + paths: &paths, + policy: self.policy, + semantic_producer: None, + callgraph_producer: super::callgraph::PRODUCER.into(), + ignore_fingerprint: None, + history: None, + budget: super::eviction::DiskBudget::default(), + }, + None, + ) + .map_err(|error| error.to_string())?; + let super::base_builder::BaseBuild::Published { + identity, claim, .. + } = built + else { + return Err(format!("test base build refused: {built:?}")); + }; + let base = super::base_refs::resolve_base(&self.storage, &self.family, &identity.scope) + .map_err(|error| error.to_string())?; + let worker = super::manifest_v2::ManifestV2::new_v3( + base.manifest().header().clone(), + Some(base.header()), + super::manifest_v2::PublishedFrontier::default(), + ); + let composed = super::snapshot::ComposedGeneration::compose( + "lexical-test-worker", + worker, + base.into_generation(&self.storage, &self.family), + None, + ) + .map_err(|error| error.to_string())?; + let composed = composed.with_v3_route(super::snapshot::V3Route { + storage: self.storage.clone(), + family: self.family.clone(), + scope: "lexical-test-worker".into(), + }); + let snapshot = super::snapshot::LiveDelta::new(Arc::new(composed)).snapshot(); + snapshots.insert(key, (snapshot.clone(), claim)); + Ok(snapshot) + } + + fn record_change(&self, _: &Path) {} +} + /// Generation-local replacements and tombstones relative to the shared segment. /// Missing payloads are gaps, not empty postings. #[derive(Debug)] @@ -257,8 +675,8 @@ impl GenerationIndex { relative_gaps: Vec::new(), }; for path in candidates.indexed.union(&candidates.direct) { - let absolute = match rel_path_to_os(path) { - Ok(relative) => root.join(relative), + let absolute = match absolute_member_path(root, path) { + Ok(absolute) => absolute, Err(_) => { record_conversion_gap(&mut result, path); continue; @@ -545,7 +963,7 @@ impl TrigramDriver { let resident = self.resident.lock().unwrap_or_else(|e| e.into_inner()); let mut gaps = snapshot .pending_intent() - .filter_map(|path| rel_path_to_os(path).ok().map(|path| self.root.join(path))) + .filter_map(|path| absolute_member_path(&self.root, path).ok()) .collect::>(); if plane != FamilyPlane::Trigram || access.scope() != self.scope @@ -561,15 +979,15 @@ impl TrigramDriver { .candidates(&snapshot, &members, "") .direct .iter() - .filter_map(|path| rel_path_to_os(path).ok().map(|path| self.root.join(path))), + .filter_map(|path| absolute_member_path(&self.root, path).ok()), ); } for (path, entry) in snapshot.live_entries() { if entry.disk != DiskState::Absent && !entry.attachments.contains_key(&FamilyPlane::Trigram) { - if let Ok(path) = rel_path_to_os(path) { - gaps.push(self.root.join(path)); + if let Ok(path) = absolute_member_path(&self.root, path) { + gaps.push(path); } } } @@ -645,8 +1063,9 @@ pub fn materialize( let payload = if let Some(attachment) = attachment { attachment.payload.clone() } else { - 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()))?; + let absolute = + absolute_member_path(root, path).map_err(|e| plane_error(e.to_string()))?; + let bytes = fs::read(absolute).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")); } @@ -697,8 +1116,149 @@ 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 absolute = + absolute_member_path(root, path).map_err(|e| plane_error(e.to_string()))?; + let bytes = fs::read(absolute).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>; +struct V3Payloads(Option); + +impl super::segment_store::PayloadSource for V3Payloads { + fn payload( + &self, + key: &crate::blob_store::v2::FamilyKey, + ) -> Result>, crate::blob_store::v2::StoreError> { + match &self.0 { + Some(source) => super::segment_store::PayloadSource::payload(source, key), + None => Ok(None), + } + } +} + /// Resident immutable data shared across checkout opens. Register explicitly /// with `PlaneHooks::register_adapter`; construction alone changes no routing. pub struct TrigramAdapter { @@ -715,6 +1275,33 @@ thread_local! { } impl TrigramAdapter { + /// When the generation and its base have no content, a plane DB is optional. + /// Live-only additions still supply their own attachments to the reader. + fn v3_payloads( + &self, + route: &super::snapshot::V3Route, + snapshot: &Snapshot, + ) -> Result { + if !snapshot + .generation() + .entries() + .any(|(_, entry)| entry.content().is_some()) + && !snapshot + .generation() + .base() + .is_some_and(|base| base.entries().any(|(_, entry)| entry.content().is_some())) + { + return Ok(V3Payloads(None)); + } + crate::blob_store::v3::PlaneReader::open_existing( + &route.storage, + &route.family, + FamilyPlane::Trigram, + ) + .map(|source| V3Payloads(Some(source))) + .map_err(|error| plane_error(error.to_string())) + } + pub fn new(storage: PathBuf, policy: TrigramPolicy) -> Self { Self { storage, @@ -739,6 +1326,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 +1388,86 @@ 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 = self.v3_payloads(route, snapshot)?; + 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 @@ -868,6 +1538,203 @@ impl super::contracts::PlaneAdapter for TrigramAdapter { #[cfg(test)] mod tests { + struct FixedSource(Snapshot); + impl LexicalSnapshotSource for FixedSource { + fn acquire(&self) -> Result { + Ok(self.0.clone()) + } + fn record_change(&self, _: &Path) {} + } + + #[test] + fn lexical_views_projection_refuses_uncertain_or_wrong_policy_snapshots() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("project"); + let storage = temp.path().join("storage"); + fs::create_dir_all(&root).unwrap(); + fs::write(root.join("a.rs"), "needle\n").unwrap(); + let policy = TrigramPolicy { + max_file_size: 1024, + }; + let source = BaseBuilderTestSource::new(&root, &storage, "lexical-guards", policy); + let snapshot = source.acquire().unwrap(); + let make_view = |snapshot, policy| { + LexicalView::with_source( + root.clone(), + crate::root_cache::StoragePaths::same(&storage), + "lexical-guards".into(), + policy, + Arc::new(FixedSource(snapshot)), + ) + }; + for state in [ + WatcherState::Overflowed, + WatcherState::Stopped, + WatcherState::Reconciling, + ] { + let mut delta = super::super::snapshot::LiveDelta::new(snapshot.generation().clone()); + delta.set_watcher(state); + assert!(make_view(delta.snapshot(), policy) + .snapshot() + .unwrap_err() + .contains("unverified writes")); + } + let mut delta = super::super::snapshot::LiveDelta::new(snapshot.generation().clone()); + delta.record_intent(RelPath::new(b"a.rs".to_vec()).unwrap()); + assert!(make_view(delta.snapshot(), policy) + .snapshot() + .unwrap_err() + .contains("unverified writes")); + assert!(make_view( + snapshot, + TrigramPolicy { + max_file_size: 1025 + } + ) + .snapshot() + .unwrap_err() + .contains("not ready")); + } + + /// Windows reads a `\\?\` path literally, so a `/` left in a joined + /// member path names a file that does not exist; plain roots must also + /// come out in native separators to match the legacy index's spelling. + #[cfg(windows)] + #[test] + fn member_paths_use_native_separators_under_plain_and_verbatim_roots() { + let path = RelPath::new(b"src/nested/a.rs".to_vec()).unwrap(); + for (root, expected) in [ + (r"\\?\C:\checkout", r"\\?\C:\checkout\src\nested\a.rs"), + (r"C:\checkout", r"C:\checkout\src\nested\a.rs"), + ] { + assert_eq!( + absolute_member_path(Path::new(root), &path) + .unwrap() + .as_os_str(), + std::ffi::OsStr::new(expected) + ); + } + } + + /// A checkout with no steady watcher subscription (no watcher, an + /// unproved one, or an unsupported filesystem) is still served: every + /// query binds strictly against the disk and answers from that cut, + /// including edits no watcher reported. + #[test] + fn lexical_view_without_a_watcher_subscription_serves_each_strict_bind() { + let temp = tempfile::tempdir().unwrap(); + let base = fs::canonicalize(temp.path()).unwrap(); + let root = base.join("project"); + let storage = base.join("storage"); + fs::create_dir_all(&root).unwrap(); + fs::write(root.join("a.rs"), "fn first_needle() {}\n").unwrap(); + assert!(crate::watcher_backend::subscription::status(&root).is_none()); + let view = LexicalView::new( + root.clone(), + crate::root_cache::StoragePaths::same(&storage), + "lexical-unwatched".into(), + TrigramPolicy { + max_file_size: 1024 * 1024, + }, + ) + .unwrap(); + let first = view.snapshot().unwrap(); + assert_eq!( + first + .grep("first_needle", true, &[], &[], &root, 10) + .unwrap() + .matches + .len(), + 1 + ); + assert!(view.is_ready()); + // No event is recorded for this edit: only the strict bind sees it. + fs::write(root.join("b.rs"), "fn second_needle() {}\n").unwrap(); + let second = view.snapshot().unwrap(); + assert_eq!( + second + .grep("second_needle", true, &[], &[], &root, 10) + .unwrap() + .matches + .len(), + 1 + ); + } + + #[test] + fn lexical_views_projection_matches_legacy_masks_ranks_and_membership() { + let temp = tempfile::tempdir().unwrap(); + // Production views are opened on the canonical checkout root, which + // is also the root the legacy index reports paths under. A plain + // temporary path can spell that root differently: on macOS it goes + // through the `/var` -> `/private/var` symlink, and on Windows the + // canonical form is the `\\?\` verbatim path. + let root = fs::canonicalize(temp.path()).unwrap().join("project"); + let storage = temp.path().join("storage"); + fs::create_dir_all(root.join("src")).unwrap(); + fs::write(root.join("src/a.rs"), "fn needle_alpha() {}\nneedle beta\n").unwrap(); + fs::write(root.join("src/b.rs"), "fn needle_beta() {}\n").unwrap(); + fs::write(root.join("binary.dat"), [0, 1, 2, 3]).unwrap(); + fs::write(root.join(".gitignore"), "ignored.rs\n").unwrap(); + fs::write(root.join("ignored.rs"), "needle ignored\n").unwrap(); + let policy = TrigramPolicy { + max_file_size: 1024 * 1024, + }; + let source = BaseBuilderTestSource::new(&root, &storage, "lexical-parity", policy); + let view = LexicalView::with_source( + root.clone(), + crate::root_cache::StoragePaths::same(&storage), + "lexical-parity".into(), + policy, + source.clone(), + ); + let projected = view.snapshot().unwrap(); + let legacy = + crate::search_index::SearchIndex::build_with_limit_serial(&root, policy.max_file_size) + .snapshot(); + assert_eq!(projected.glob("**/*", &root), legacy.glob("**/*", &root)); + for pattern in [ + "needle", + "needle_(alpha|beta)", + "n.e.*beta", + "^fn", + "NEEDLE", + "a", + ] { + for insensitive in [false, true] { + assert_eq!( + projected + .grep(pattern, !insensitive, &[], &[], &root, 100) + .unwrap() + .matches, + legacy + .grep(pattern, !insensitive, &[], &[], &root, 100) + .unwrap() + .matches, + "{pattern} insensitive={insensitive}" + ); + } + } + for query in ["needle", "needle alpha", "beta", "absent"] { + let trigrams = crate::search_index::query_trigrams_from_tokens( + &query.split_whitespace().collect::>(), + ); + assert_eq!( + projected + .lexical_rank_at_depth(&trigrams, None, 20) + .unwrap() + .files, + legacy + .lexical_rank_at_depth(&trigrams, None, 20) + .unwrap() + .files + ); + } + assert!(source.acquire().unwrap().generation().v3_route().is_some()); + assert!(!storage.join("blobs/v2").exists()); + assert!(!storage.join("views/v3").exists()); + } + #[test] fn unrepresentable_query_gap_keeps_the_exact_relative_bytes() { let path = RelPath::new(b"a-\xff.rs".to_vec()).unwrap(); 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..a3df1dbde7 --- /dev/null +++ b/crates/aft/src/views/v3_plane_tests.rs @@ -0,0 +1,356 @@ +//! 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, + &mut || Ok(()), + ) + .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/src/views/verification.rs b/crates/aft/src/views/verification.rs new file mode 100644 index 0000000000..dab8142332 --- /dev/null +++ b/crates/aft/src/views/verification.rs @@ -0,0 +1,1368 @@ +//! Fail-closed metadata and policy evidence for checkout reconciliation. +//! Git index tuples alone never authorize reuse of working-tree bytes. + +use std::collections::BTreeMap; +use std::fs; +use std::io; +use std::path::{Path, PathBuf}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use serde::{Deserialize, Serialize}; + +use super::RelPath; + +#[cfg(test)] +thread_local! { + static ATTRIBUTE_SOURCES: std::cell::RefCell> = const { std::cell::RefCell::new(None) }; +} +#[cfg(test)] +pub(crate) struct AttributeSourcesGuard(Option<[PathBuf; 2]>); +#[cfg(test)] +impl Drop for AttributeSourcesGuard { + fn drop(&mut self) { + ATTRIBUTE_SOURCES.with(|paths| *paths.borrow_mut() = self.0.take()); + } +} +#[cfg(test)] +pub(crate) fn known_attribute_sources_for_test(root: &Path) -> AttributeSourcesGuard { + let sources = [ + root.join("absent-system-attributes"), + root.join("absent-global-attributes"), + ]; + assert!(sources.iter().all(|p| !p.exists())); + AttributeSourcesGuard(ATTRIBUTE_SOURCES.with(|paths| paths.replace(Some(sources)))) +} +use crate::blob_store::v2::{ContentHash, FamilyKey, FamilyPlane}; + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub(crate) enum FileIdentity { + Unix { + dev: u64, + ino: u64, + birth: Option, + }, + Windows { + volume: u64, + id: [u8; 16], + creation: i64, + }, + StrictOnly { + volume: u64, + index: u64, + birth: Option, + }, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub(crate) struct NativeStat { + pub identity: FileIdentity, + pub size: u64, + pub modified_ns: i128, + pub changed_ns: i128, + pub mode: u32, + pub reuse_supported: bool, +} + +impl NativeStat { + pub fn read(path: &Path) -> io::Result { + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + let m = fs::symlink_metadata(path)?; + Ok(Self { + identity: FileIdentity::Unix { + dev: m.dev(), + ino: m.ino(), + birth: m + .created() + .ok() + .and_then(|t| t.duration_since(UNIX_EPOCH).ok()) + .map(|d| d.as_nanos()), + }, + size: m.len(), + modified_ns: i128::from(m.mtime()) * 1_000_000_000 + i128::from(m.mtime_nsec()), + changed_ns: i128::from(m.ctime()) * 1_000_000_000 + i128::from(m.ctime_nsec()), + mode: m.mode(), + reuse_supported: local_precise_filesystem(path), + }) + } + #[cfg(windows)] + { + windows_stat(path) + } + #[cfg(not(any(unix, windows)))] + { + let _ = path; + Err(io::Error::new( + io::ErrorKind::Unsupported, + "stable file identity unavailable", + )) + } + } + pub fn reusable(&self, current: &Self, observed_ns: u128) -> bool { + self.reuse_supported + && self == current + && self.modified_ns >= 0 + && (self.modified_ns as u128) < observed_ns + } + + pub fn strict_read(path: &Path) -> io::Result { + match Self::read(path) { + Ok(stat) => Ok(stat), + Err(error) => { + let (metadata, identity) = crate::file_identity::symlink_metadata(path)?; + let crate::file_identity::FileIdentity::Known { volume, index } = identity else { + return Err(error); + }; + Ok(Self { + identity: FileIdentity::StrictOnly { + volume, + index, + birth: metadata + .created() + .ok() + .and_then(|t| t.duration_since(UNIX_EPOCH).ok()) + .map(|d| d.as_nanos()), + }, + size: metadata.len(), + modified_ns: metadata + .modified() + .ok() + .and_then(|t| t.duration_since(UNIX_EPOCH).ok()) + .map_or(-1, |d| d.as_nanos() as i128), + changed_ns: -1, + mode: 0, + reuse_supported: false, + }) + } + } + } +} + +#[cfg(unix)] +fn local_precise_filesystem(path: &Path) -> bool { + use std::os::unix::ffi::OsStrExt; + let Ok(path) = std::ffi::CString::new(path.as_os_str().as_bytes()) else { + return false; + }; + let mut stat: libc::statfs = unsafe { std::mem::zeroed() }; + if unsafe { libc::statfs(path.as_ptr(), &mut stat) } != 0 { + return false; + } + #[cfg(target_os = "linux")] + { + matches!( + stat.f_type as u64, + 0xef53 | 0x9123683e | 0x58465342 | 0x794c7630 | 0x01021994 | 0x2fc12fc1 + ) + } + #[cfg(target_os = "macos")] + { + unsafe { std::ffi::CStr::from_ptr(stat.f_fstypename.as_ptr()) }.to_bytes() == b"apfs" + } + #[cfg(not(any(target_os = "linux", target_os = "macos")))] + { + false + } +} + +#[cfg(windows)] +fn windows_stat(path: &Path) -> io::Result { + use std::os::windows::{fs::OpenOptionsExt, io::AsRawHandle}; + use windows_sys::Win32::Storage::FileSystem::{ + FileBasicInfo, FileIdInfo, GetFileInformationByHandleEx, GetVolumeInformationByHandleW, + FILE_BASIC_INFO, FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OPEN_REPARSE_POINT, FILE_ID_INFO, + FILE_READ_ATTRIBUTES, + }; + let file = fs::OpenOptions::new() + .read(true) + .access_mode(FILE_READ_ATTRIBUTES) + .share_mode(7) + .custom_flags(FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT) + .open(path)?; + let handle = file.as_raw_handle(); + let mut id: FILE_ID_INFO = unsafe { std::mem::zeroed() }; + let mut basic: FILE_BASIC_INFO = unsafe { std::mem::zeroed() }; + let mut filesystem = [0u16; 32]; + // All fields come from one metadata-only handle. A 64-bit file index is + // insufficient on ReFS, and creation time is not a substitute for ChangeTime. + let ok = unsafe { + GetFileInformationByHandleEx( + handle, + FileIdInfo, + &mut id as *mut _ as _, + std::mem::size_of::() as u32, + ) != 0 + && GetFileInformationByHandleEx( + handle, + FileBasicInfo, + &mut basic as *mut _ as _, + std::mem::size_of::() as u32, + ) != 0 + && GetVolumeInformationByHandleW( + handle, + std::ptr::null_mut(), + 0, + std::ptr::null_mut(), + std::ptr::null_mut(), + std::ptr::null_mut(), + filesystem.as_mut_ptr(), + filesystem.len() as u32, + ) != 0 + }; + if !ok { + return Err(io::Error::last_os_error()); + } + let end = filesystem + .iter() + .position(|c| *c == 0) + .unwrap_or(filesystem.len()); + let name = String::from_utf16_lossy(&filesystem[..end]); + if !matches!(name.as_str(), "NTFS" | "ReFS") { + return Err(io::Error::new( + io::ErrorKind::Unsupported, + "stat reuse requires NTFS or ReFS", + )); + } + let unix_ns = |ticks: i64| (i128::from(ticks) - 116_444_736_000_000_000i128) * 100; + Ok(NativeStat { + identity: FileIdentity::Windows { + volume: id.VolumeSerialNumber, + id: id.FileId.Identifier, + creation: basic.CreationTime, + }, + size: file.metadata()?.len(), + modified_ns: unix_ns(basic.LastWriteTime), + changed_ns: unix_ns(basic.ChangeTime), + mode: basic.FileAttributes, + reuse_supported: true, + }) +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub(crate) struct Evidence { + pub version: u32, + pub stat: NativeStat, + pub observed_ns: u128, + pub content: String, + pub size: u64, + pub git_oid: Option, + pub policy: String, + pub keys: BTreeMap, +} + +impl Evidence { + pub fn content(&self) -> Option { + ContentHash::from_hex(&self.content) + } + pub fn key(&self, plane: FamilyPlane) -> Option { + let name = format!("{plane:?}"); + Some(FamilyKey::new( + plane, + crate::blob_store::v2::parse_hex32(self.keys.get(&name)?)?, + )) + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct Policy { + pub digest: String, + /// Missing or unscopable conversion sources forbid stat-based byte reuse. + pub reusable: bool, +} + +fn metadata_source(path: &Path, hash: &mut blake3::Hasher) -> io::Result<()> { + let name = path.as_os_str().as_encoded_bytes(); + hash.update(&(name.len() as u64).to_le_bytes()); + hash.update(name); + match fs::read(path) { + Ok(bytes) => { + hash.update(&[1]); + hash.update(&(bytes.len() as u64).to_le_bytes()); + hash.update(&bytes); + } + Err(e) if e.kind() == io::ErrorKind::NotFound => { + hash.update(&[0]); + } + Err(e) => return Err(e), + } + Ok(()) +} + +impl Policy { + pub fn capture(root: &Path, metadata: &[PathBuf]) -> io::Result { + let queries = + crate::git_index_reader::GitQueries::new(root.to_path_buf(), Duration::from_secs(2)); + #[cfg(test)] + if let Some(paths) = ATTRIBUTE_SOURCES.with(|paths| paths.borrow().clone()) { + #[cfg(unix)] + use std::os::unix::process::ExitStatusExt; + #[cfg(windows)] + use std::os::windows::process::ExitStatusExt; + return Self::capture_sources( + root, + metadata, + queries.config(), + paths.map(|path| { + Ok(std::process::Output { + status: std::process::ExitStatus::from_raw(0), + stdout: path.as_os_str().as_encoded_bytes().to_vec(), + stderr: Vec::new(), + }) + }), + ); + } + Self::capture_sources( + root, + metadata, + queries.config(), + [ + queries.attribute_source(false), + queries.attribute_source(true), + ], + ) + } + + fn capture_sources( + root: &Path, + metadata: &[PathBuf], + output: io::Result, + sources: [io::Result; 2], + ) -> io::Result { + let mut hash = blake3::Hasher::new(); + hash.update(b"aft/views-v3/conversion-policy/1"); + let mut reusable = output.as_ref().is_ok_and(|output| output.status.success()); + hash.update(&[u8::from(reusable)]); + let config = output.map(|output| output.stdout).unwrap_or_default(); + hash.update(&config); + for output in sources { + match output { + Ok(output) if output.status.success() => { + let bytes = output.stdout.strip_suffix(b"\n").unwrap_or(&output.stdout); + let bytes = bytes.strip_suffix(b"\r").unwrap_or(bytes); + if bytes.is_empty() || bytes.contains(&b'\n') { + reusable = false; + continue; + } + let path = path_from_bytes(bytes)?; + let path = if path.is_absolute() { + path + } else { + root.join(path) + }; + metadata_source(&path, &mut hash)?; + reusable &= !path.exists(); + } + _ => reusable = false, + } + } + // Config --show-origin records include targets and worktree config. + // Hash their bytes as well as the effective values, including empty files. + for item in config.split(|b| *b == 0) { + if let Some(path) = item.strip_prefix(b"file:") { + let path = path_from_bytes(path)?; + metadata_source( + &if path.is_absolute() { + path + } else { + root.join(path) + }, + &mut hash, + )?; + } + let lower = String::from_utf8_lossy(item).to_lowercase(); + if lower.starts_with("filter.") + || lower.starts_with("core.autocrlf\n") && lower != "core.autocrlf\nfalse" + || lower == "core.eol\ncrlf" + { + reusable = false; + } + for (key, conversion) in [ + (b"core.attributesfile\n".as_slice(), true), + (b"core.excludesfile\n".as_slice(), false), + ] { + if let Some(path) = item.strip_prefix(key) { + let path = path_from_bytes(path)?; + let path = + if let Some(suffix) = path.to_str().and_then(|p| p.strip_prefix("~/")) { + std::env::var_os("HOME") + .or_else(|| std::env::var_os("USERPROFILE")) + .map(|home| PathBuf::from(home).join(suffix)) + .ok_or_else(|| { + io::Error::other("cannot resolve Git policy home directory") + })? + } else if path.is_absolute() { + path + } else { + root.join(path) + }; + metadata_source(&path, &mut hash)?; + reusable &= !conversion; + } + } + } + for path in metadata { + metadata_source(path, &mut hash)?; + if path.exists() + && path + .file_name() + .is_some_and(|name| name == ".gitattributes" || name == "attributes") + { + // Until path-scoped attribute provenance is available, every + // attribute source is conservatively an unscopable conversion. + reusable = false; + } + } + Ok(Self { + digest: hash.finalize().to_hex().to_string(), + reusable, + }) + } +} + +pub(crate) fn path_from_bytes(bytes: &[u8]) -> io::Result { + #[cfg(unix)] + { + use std::os::unix::ffi::OsStringExt; + Ok(std::ffi::OsString::from_vec(bytes.to_vec()).into()) + } + #[cfg(not(unix))] + { + std::str::from_utf8(bytes) + .map(PathBuf::from) + .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e)) + } +} + +pub(crate) fn now_ns() -> u128 { + u128::from( + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(), + ) * 1_000_000_000 +} + +/// Enumerate policy inputs with metadata only. Source files are never opened. +pub(crate) fn policy_inputs(root: &Path) -> io::Result> { + fn walk(root: &Path, out: &mut Vec) -> io::Result<()> { + for name in [".gitattributes", ".gitignore", ".ignore", ".aftignore"] { + out.push(root.join(name)); + } + for entry in fs::read_dir(root)? { + let entry = entry?; + let kind = entry.file_type()?; + let name = entry.file_name(); + if name == ".git" { + continue; + } + if kind.is_dir() { + walk(&entry.path(), out)?; + } + } + Ok(()) + } + let mut paths = Vec::new(); + walk(root, &mut paths)?; + let marker = root.join(".git"); + let gitdir = if marker.is_dir() { + Some(marker) + } else { + fs::read(&marker) + .ok() + .and_then(|bytes| { + bytes + .strip_prefix(b"gitdir: ") + .map(|p| p.trim_ascii().to_vec()) + }) + .and_then(|p| path_from_bytes(&p).ok()) + .map(|p| if p.is_absolute() { p } else { root.join(p) }) + }; + if let Some(gitdir) = gitdir { + paths.push(gitdir.join("info/attributes")); + paths.push(gitdir.join("info/exclude")); + let common_marker = gitdir.join("commondir"); + match fs::read(&common_marker) { + Ok(bytes) => { + let common = path_from_bytes(bytes.trim_ascii())?; + let common = if common.is_absolute() { + common + } else { + gitdir.join(common) + }; + paths.push(common.join("info/attributes")); + paths.push(common.join("info/exclude")); + } + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(error) => return Err(error), + } + paths.push(common_marker); + } + paths.sort(); + Ok(paths) +} + +pub(crate) fn gitdir(root: &Path) -> io::Result { + let marker = root.join(".git"); + if marker.is_dir() { + return fs::canonicalize(marker); + } + let bytes = fs::read(marker)?; + let path = bytes + .strip_prefix(b"gitdir: ") + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "invalid gitdir marker"))?; + let path = path_from_bytes(path.trim_ascii())?; + fs::canonicalize(if path.is_absolute() { + path + } else { + root.join(path) + }) +} + +/// Captured even for strict loads: admission waits cannot transfer an old +/// root, Git state or policy observation to a new pointer publication. +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct BindGuard { + pub root: FileIdentity, + pub gitdir: Option<(PathBuf, FileIdentity)>, + pub head_tree: Option>, + pub index: Option<(NativeStat, [u8; 32])>, + pub policy: Policy, + pub inputs: Vec, + pub subscription: Option, + pub initial_coverage: Option, + /// Stable local identities and a native backend allow event-based refresh, + /// but do not prove that all writes before a cookie reached the driver. + pub steady_supported: bool, +} + +impl BindGuard { + pub fn capture(root: &Path, known_inputs: Option<&[PathBuf]>) -> io::Result { + let inputs = match known_inputs { + Some(inputs) => inputs.to_vec(), + None => policy_inputs(root)?, + }; + let mut policy = Policy::capture(root, &inputs)?; + let root_stat = NativeStat::strict_read(root)?; + let registration = crate::watcher_backend::subscription::status(root); + let steady_supported = root_stat.reuse_supported + && cfg!(any(target_os = "linux", target_os = "macos", windows)) + && registration + .as_ref() + .is_some_and(|status| status.coverage.is_complete()); + let root_identity = root_stat.identity; + let directory = match gitdir(root) { + Ok(path) => Some((path.clone(), NativeStat::strict_read(&path)?.identity)), + Err(e) if e.kind() == io::ErrorKind::NotFound => None, + Err(e) => return Err(e), + }; + let mut head_tree = None; + let mut index = None; + if let Some((directory, _)) = &directory { + let queries = crate::git_index_reader::GitQueries::new( + root.to_path_buf(), + Duration::from_secs(2), + ); + let head = queries.head_tree()?; + if head.status.success() { + head_tree = Some(head.stdout); + } + let path = directory.join("index"); + match NativeStat::strict_read(&path) { + Ok(before) => { + let bytes = fs::read(&path)?; + let after = NativeStat::strict_read(&path)?; + if before != after { + return Err(io::Error::other("Git index changed during verification")); + } + index = Some((before, *blake3::hash(&bytes).as_bytes())); + let format = if head_tree + .as_ref() + .is_some_and(|tree| tree.trim_ascii().len() == 64) + { + crate::git_index_reader::ObjectFormat::Sha256 + } else { + crate::git_index_reader::ObjectFormat::Sha1 + }; + match crate::git_index_reader::read_index(&path, format, Default::default()) { + crate::git_index_reader::IndexRead::Snapshot(snapshot) => { + let mut digest = blake3::Hasher::new(); + digest.update(policy.digest.as_bytes()); + for entry in snapshot.entries.iter().filter(|entry| { + entry.path.rsplit(|b| *b == b'/').next() == Some(b".gitattributes") + }) { + policy.reusable = false; + digest.update(&(entry.path.len() as u64).to_le_bytes()); + digest.update(&entry.path); + digest.update(&entry.oid); + } + policy.digest = digest.finalize().to_hex().to_string(); + } + crate::git_index_reader::IndexRead::Strict(_) => { + policy.reusable = false; + } + } + } + Err(e) if e.kind() == io::ErrorKind::NotFound => {} + Err(e) => return Err(e), + } + } + Ok(Self { + root: root_identity, + gitdir: directory, + head_tree, + index, + policy, + inputs, + subscription: registration.as_ref().map(|status| status.continuity), + initial_coverage: registration.map(|status| status.coverage), + steady_supported, + }) + } + pub fn validate(&self, root: &Path) -> io::Result<()> { + let current = Self::capture(root, None)?; + if ¤t != self { + return Err(io::Error::other( + "checkout Git, root identity or conversion policy changed", + )); + } + Ok(()) + } +} + +const EVIDENCE_APP: i64 = 0x41465445; +const EVIDENCE_SCHEMA: &str = "CREATE TABLE evidence_meta(singleton INTEGER PRIMARY KEY CHECK(singleton=1), format_version INTEGER NOT NULL, schema_version INTEGER NOT NULL, generation TEXT NOT NULL, root BLOB NOT NULL, policy TEXT NOT NULL); +CREATE TABLE stat_evidence(path BLOB PRIMARY KEY, record BLOB NOT NULL);"; + +pub(crate) fn evidence_path(dir: &Path, generation: &str) -> PathBuf { + dir.join(format!("stat-evidence-{generation}.sqlite")) +} + +/// Writes evidence while the caller holds the prepared-generation pin, before +/// the CAS. Generation and content digests are checked on read, so an +/// unpublished or obsolete sidecar supplies no proof. +pub(crate) fn write_evidence( + route: &super::snapshot::V3Route, + generation: &str, + guard: &BindGuard, + records: &BTreeMap, +) -> io::Result<()> { + let lock = crate::blob_store::v3::ReservationLock::acquire(&route.storage) + .map_err(io::Error::other)?; + let registry = + crate::blob_store::v3::open_verification_registry(&route.storage, &route.family, &lock) + .map_err(io::Error::other)?; + drop(registry); + drop(lock); + let root = serde_json::to_vec(&guard.root)?; + let dir = route.member_dir(); + let destination = evidence_path(&dir, generation); + let staging = dir.join(format!( + ".stat-evidence-{generation}.sqlite.tmp.{}", + super::base_refs::fresh_nonce() + )); + let result = (|| -> Result<(), Box> { + // Generation names make this path pass Windows' MAX_PATH. + let mut db = rusqlite::Connection::open(crate::db::lifecycle::sqlite_open_path(&staging))?; + db.pragma_update(None, "application_id", EVIDENCE_APP)?; + db.pragma_update(None, "user_version", 1)?; + db.pragma_update(None, "journal_mode", "DELETE")?; + db.pragma_update(None, "synchronous", "FULL")?; + db.execute_batch(EVIDENCE_SCHEMA)?; + let tx = db.transaction()?; + tx.execute( + "INSERT INTO evidence_meta VALUES(1,1,1,?1,?2,?3)", + rusqlite::params![generation, root, guard.policy.digest], + )?; + for (path, record) in records { + tx.execute( + "INSERT INTO stat_evidence VALUES(?1,?2)", + rusqlite::params![path.as_bytes(), serde_json::to_vec(record)?], + )?; + } + tx.commit()?; + drop(db); + // Windows refuses FlushFileBuffers on a read-only handle, so sync + // through the views helper, which opens the file for writing there. + super::sync_file(&staging)?; + if destination.exists() { + fs::remove_file(&staging)?; + } else { + fs::rename(&staging, &destination)?; + } + super::sync_directory(&dir)?; + Ok(()) + })(); + if let Err(error) = result { + let _ = fs::remove_file(&staging); + return Err(io::Error::other(error.to_string())); + } + Ok(()) +} + +pub(crate) fn read_evidence( + dir: &Path, + generation: &str, + guard: &BindGuard, +) -> Option> { + let db = rusqlite::Connection::open_with_flags( + crate::db::lifecycle::sqlite_open_path(&evidence_path(dir, generation)), + rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY, + ) + .ok()?; + if db + .pragma_query_value::(None, "application_id", |r| r.get(0)) + .ok()? + != EVIDENCE_APP + || db + .pragma_query_value::(None, "user_version", |r| r.get(0)) + .ok()? + != 1 + { + return None; + } + let (format, schema, observed_generation, root, policy): (u32, u32, String, Vec, String) = db.query_row( + "SELECT format_version,schema_version,generation,root,policy FROM evidence_meta WHERE singleton=1", [], + |r| Ok((r.get(0)?,r.get(1)?,r.get(2)?,r.get(3)?,r.get(4)?)), + ).ok()?; + if (format, schema) != (1, 1) + || observed_generation != generation + || root != serde_json::to_vec(&guard.root).ok()? + || policy != guard.policy.digest + { + return None; + } + let mut statement = db + .prepare("SELECT path,record FROM stat_evidence ORDER BY path") + .ok()?; + let rows = statement + .query_map([], |r| { + Ok((r.get::<_, Vec>(0)?, r.get::<_, Vec>(1)?)) + }) + .ok()?; + let mut result = BTreeMap::new(); + for row in rows { + let (path, bytes) = row.ok()?; + let record: Evidence = serde_json::from_slice(&bytes).ok()?; + if record.version != 1 || record.policy != guard.policy.digest { + return None; + } + result.insert(RelPath::new(path).ok()?, record); + } + Some(result) +} + +/// SQLite application ID of a tree-keyed base's proof file ("AFTT"). +pub(crate) const TREE_PROOF_APP: i64 = 0x41465454; +/// The proof file's name inside a tree-keyed base's member directory. +pub(crate) const TREE_PROOF_FILE: &str = "tree-proof.sqlite"; +const TREE_PROOF_SCHEMA: &str = "CREATE TABLE proof_meta(singleton INTEGER PRIMARY KEY CHECK(singleton=1), format_version INTEGER NOT NULL, schema_version INTEGER NOT NULL, base_scope TEXT NOT NULL, identity_digest TEXT NOT NULL, membership_digest TEXT NOT NULL, object_format TEXT NOT NULL, head_tree TEXT NOT NULL, root BLOB NOT NULL, policy TEXT NOT NULL, receipts_digest TEXT NOT NULL); +CREATE TABLE blob_receipts(path BLOB PRIMARY KEY, record BLOB NOT NULL);"; +const TREE_RECEIPTS_TAG: &[u8] = b"aft/views-v3/tree-receipts/1"; + +/// What one stable read of a tree-keyed base member observed: the bytes' +/// content digest and Git blob OID, the native stat taken around the read, +/// and the conversion policy in force at that read. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub(crate) struct BlobReceipt { + pub version: u32, + pub mode: u32, + pub size: u64, + pub content: String, + pub git_oid: String, + pub stat: NativeStat, + pub observed_ns: u128, + pub policy: String, + pub delivery_seq: u64, +} + +/// The durable proof of one tree-keyed base. +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct TreeProof { + pub base_scope: String, + pub identity_digest: String, + pub membership_digest: String, + pub object_format: String, + pub head_tree: String, + pub root: FileIdentity, + pub policy: String, + pub receipts: BTreeMap, +} + +fn receipts_digest<'a>(records: impl ExactSizeIterator) -> String { + let mut hash = blake3::Hasher::new(); + let field = |hash: &mut blake3::Hasher, bytes: &[u8]| { + hash.update(&(bytes.len() as u64).to_le_bytes()); + hash.update(bytes); + }; + field(&mut hash, TREE_RECEIPTS_TAG); + hash.update(&(records.len() as u64).to_le_bytes()); + for (path, record) in records { + field(&mut hash, path); + field(&mut hash, record); + } + hash.finalize().to_hex().to_string() +} + +/// Writes `proof` into `dir` (the base's member directory). Only the build +/// claim owner calls this, before the base's first publication; afterwards +/// the file is never rewritten. The registry floor is raised to the +/// verification version first, so readers without that support refuse. +pub(crate) fn write_tree_proof( + storage: &Path, + family: &str, + dir: &Path, + proof: &TreeProof, +) -> io::Result<()> { + let lock = + crate::blob_store::v3::ReservationLock::acquire(storage).map_err(io::Error::other)?; + drop( + crate::blob_store::v3::open_verification_registry(storage, family, &lock) + .map_err(io::Error::other)?, + ); + drop(lock); + let mut encoded = BTreeMap::new(); + for (path, receipt) in &proof.receipts { + encoded.insert(path.as_bytes().to_vec(), serde_json::to_vec(receipt)?); + } + let digest = receipts_digest( + encoded + .iter() + .map(|(path, record)| (path.as_slice(), record.as_slice())), + ); + let root = serde_json::to_vec(&proof.root)?; + let destination = dir.join(TREE_PROOF_FILE); + let staging = dir.join(format!( + ".{TREE_PROOF_FILE}.tmp.{}", + super::base_refs::fresh_nonce() + )); + let result = (|| -> Result<(), Box> { + let mut db = rusqlite::Connection::open(crate::db::lifecycle::sqlite_open_path(&staging))?; + db.pragma_update(None, "application_id", TREE_PROOF_APP)?; + db.pragma_update(None, "user_version", 1)?; + db.pragma_update(None, "journal_mode", "DELETE")?; + db.pragma_update(None, "synchronous", "FULL")?; + db.execute_batch(TREE_PROOF_SCHEMA)?; + let tx = db.transaction()?; + tx.execute( + "INSERT INTO proof_meta VALUES(1,1,1,?1,?2,?3,?4,?5,?6,?7,?8)", + rusqlite::params![ + proof.base_scope, + proof.identity_digest, + proof.membership_digest, + proof.object_format, + proof.head_tree, + root, + proof.policy, + digest + ], + )?; + for (path, record) in &encoded { + tx.execute( + "INSERT INTO blob_receipts VALUES(?1,?2)", + rusqlite::params![path, record], + )?; + } + tx.commit()?; + drop(db); + // Windows refuses FlushFileBuffers on a read-only handle, so sync + // through the views helper, which opens the file for writing there. + super::sync_file(&staging)?; + fs::rename(&staging, &destination)?; + super::sync_directory(dir)?; + Ok(()) + })(); + if let Err(error) = result { + let _ = fs::remove_file(&staging); + return Err(io::Error::other(error.to_string())); + } + Ok(()) +} + +/// Reads the proof in `dir`. `None` for a missing, foreign, newer, corrupt or +/// internally inconsistent file: the caller then takes the full check. +pub(crate) fn read_tree_proof(dir: &Path) -> Option { + let db = rusqlite::Connection::open_with_flags( + crate::db::lifecycle::sqlite_open_path(&dir.join(TREE_PROOF_FILE)), + rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY, + ) + .ok()?; + if db + .pragma_query_value::(None, "application_id", |r| r.get(0)) + .ok()? + != TREE_PROOF_APP + || db + .pragma_query_value::(None, "user_version", |r| r.get(0)) + .ok()? + != 1 + { + return None; + } + type Meta = ( + u32, + u32, + String, + String, + String, + String, + String, + Vec, + String, + String, + ); + let meta: Meta = db + .query_row( + "SELECT format_version,schema_version,base_scope,identity_digest,membership_digest,object_format,head_tree,root,policy,receipts_digest FROM proof_meta WHERE singleton=1", + [], + |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)?, r.get(8)?, r.get(9)?, + )) + }, + ) + .ok()?; + let ( + format, + schema, + base_scope, + identity_digest, + membership_digest, + object_format, + head_tree, + root, + policy, + digest, + ) = meta; + if (format, schema) != (1, 1) { + return None; + } + let mut statement = db + .prepare("SELECT path,record FROM blob_receipts ORDER BY path") + .ok()?; + let rows = statement + .query_map([], |r| { + Ok((r.get::<_, Vec>(0)?, r.get::<_, Vec>(1)?)) + }) + .ok()? + .collect::>>() + .ok()?; + if receipts_digest( + rows.iter() + .map(|(path, record)| (path.as_slice(), record.as_slice())), + ) != digest + { + return None; + } + let mut receipts = BTreeMap::new(); + for (path, record) in rows { + let receipt: BlobReceipt = serde_json::from_slice(&record).ok()?; + if receipt.version != 1 || receipt.policy != policy { + return None; + } + receipts.insert(RelPath::new(path).ok()?, receipt); + } + Some(TreeProof { + base_scope, + identity_digest, + membership_digest, + object_format, + head_tree, + root: serde_json::from_slice(&root).ok()?, + policy, + receipts, + }) +} + +/// Stat-evidence candidates from a tree-keyed base's blob receipts, or `None` +/// when the base is membership-keyed or its proof does not hold now (every +/// path then takes the full check). +/// +/// The proof must match the authenticated base, the current root and the +/// current reusable policy. Git must still agree: `rev-parse HEAD^{tree}` +/// (taken by the guard) names the base's tree, and a coherent index read of +/// the same index file the guard observed computes that tree. A receipt is +/// offered only for a path whose stage-0 index entry has no exception flag +/// and the receipt's mode and OID. Callers still apply the stat-evidence +/// reuse rules to every candidate before skipping a read. +pub(crate) fn tree_receipt_evidence( + base: &super::base_refs::AuthenticatedBase, + guard: &BindGuard, +) -> Option> { + use crate::blob_store::v2::to_hex; + use crate::git_index_reader::{index_tree, read_index, IndexRead, ObjectFormat}; + if base.identity().kind() != super::base_refs::BaseKind::Tree || !guard.policy.reusable { + return None; + } + let manifest = base.manifest(); + let head_tree = manifest.header().head_tree.as_deref()?; + let format = ObjectFormat::from_hex_len(head_tree.len())?; + let proof = read_tree_proof(base.view_dir())?; + if proof.base_scope != base.scope() + || proof.identity_digest != to_hex(&base.identity().identity) + || proof.membership_digest != to_hex(&base.identity().membership) + || proof.object_format != format.name() + || proof.head_tree != head_tree + || proof.root != guard.root + || proof.policy != guard.policy.digest + || proof.receipts.len() != manifest.len() + { + return None; + } + if guard.head_tree.as_deref()?.trim_ascii() != head_tree.as_bytes() { + return None; + } + let index_path = guard.gitdir.as_ref()?.0.join("index"); + let IndexRead::Snapshot(snapshot) = read_index(&index_path, format, Default::default()) else { + return None; + }; + // The snapshot must come from the index file the guard recorded, which + // final validation re-checks before publication. + if NativeStat::strict_read(&index_path).ok()? != guard.index.as_ref()?.0 + || to_hex(&index_tree(&snapshot).ok()?) != head_tree + { + return None; + } + let index: BTreeMap<&[u8], &crate::git_index_reader::IndexEntry> = snapshot + .entries + .iter() + .map(|entry| (entry.path.as_slice(), entry)) + .collect(); + let mut candidates = BTreeMap::new(); + for (path, entry) in manifest.entries() { + let super::manifest_v2::EntryV2::Regular { + mode, + content, + size, + git_oid: Some(git_oid), + .. + } = entry + else { + return None; + }; + let receipt = proof.receipts.get(path)?; + if receipt.mode != *mode + || receipt.size != *size + || receipt.content != content.to_hex() + || &receipt.git_oid != git_oid + { + return None; + } + let Some(current) = index.get(path.as_bytes()) else { + continue; + }; + if !current.exceptions().is_empty() + || current.stat.mode != *mode + || to_hex(¤t.oid) != *git_oid + { + continue; + } + candidates.insert( + path.clone(), + Evidence { + version: 1, + stat: receipt.stat.clone(), + observed_ns: receipt.observed_ns, + content: receipt.content.clone(), + size: receipt.size, + git_oid: Some(receipt.git_oid.clone()), + policy: receipt.policy.clone(), + keys: BTreeMap::new(), + }, + ); + } + Some(candidates) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn success(bytes: &[u8]) -> io::Result { + #[cfg(unix)] + use std::os::unix::process::ExitStatusExt; + #[cfg(windows)] + use std::os::windows::process::ExitStatusExt; + Ok(std::process::Output { + status: std::process::ExitStatus::from_raw(0), + stdout: bytes.to_vec(), + stderr: Vec::new(), + }) + } + + #[test] + fn conversion_or_unknown_attribute_sources_disable_stat_provenance() { + let root = tempfile::tempdir().unwrap(); + let sources = || { + [ + success( + root.path() + .join("system-attributes") + .as_os_str() + .as_encoded_bytes(), + ), + success( + root.path() + .join("global-attributes") + .as_os_str() + .as_encoded_bytes(), + ), + ] + }; + assert!( + Policy::capture_sources(root.path(), &[], success(b""), sources()) + .unwrap() + .reusable + ); + for config in [ + b"core.autocrlf\ntrue\0".as_slice(), + b"core.eol\ncrlf\0", + b"filter.custom.clean\ncustom-driver\0", + ] { + assert!( + !Policy::capture_sources(root.path(), &[], success(config), sources()) + .unwrap() + .reusable + ); + } + assert!( + !Policy::capture_sources( + root.path(), + &[], + success(b""), + [ + Err(io::Error::other("unsupported Git source query")), + success(b"unused") + ] + ) + .unwrap() + .reusable + ); + let attribute = root.path().join(".gitattributes"); + fs::write(&attribute, b"*.rs text\n").unwrap(); + assert!( + !Policy::capture_sources(root.path(), &[attribute], success(b""), sources()) + .unwrap() + .reusable + ); + } + + fn git(root: &Path, args: &[&str]) { + let output = std::process::Command::new("git") + .current_dir(root) + .args(args) + .output() + .unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + } + + #[test] + fn failed_config_disables_reuse_but_still_hashes_known_policy_sources() { + let root = tempfile::tempdir().unwrap(); + let source = root.path().join(".gitattributes"); + fs::write(&source, b"*.rs text\n").unwrap(); + let capture = || { + Policy::capture_sources( + root.path(), + std::slice::from_ref(&source), + Err(io::Error::other("config unavailable")), + [ + Err(io::Error::other("unknown system attributes")), + Err(io::Error::other("unknown global attributes")), + ], + ) + .unwrap() + }; + let before = capture(); + assert!(!before.reusable); + fs::write(&source, b"*.rs filter=custom\n").unwrap(); + let after = capture(); + assert!(!after.reusable); + assert_ne!(before.digest, after.digest); + } + + #[test] + fn final_policy_digest_covers_git_stat_settings_and_include_targets() { + let root = tempfile::tempdir().unwrap(); + git(root.path(), &["init", "-q"]); + // `git init` already writes some of these keys, with values that + // depend on the platform (Git for Windows records + // `core.ignorecase=true` and `core.fileMode=false`). Setting a key to + // the value it already has is no change at all, so each key is moved + // to whichever candidate differs from its current value. + for (key, values) in [ + ("core.autocrlf", ["true", "false"]), + ("core.eol", ["crlf", "lf"]), + ("core.symlinks", ["false", "true"]), + ("core.ignorecase", ["true", "false"]), + ("core.checkStat", ["minimal", "default"]), + ("core.trustctime", ["false", "true"]), + ("core.fileMode", ["false", "true"]), + ] { + let current = std::process::Command::new("git") + .current_dir(root.path()) + .args(["config", "--get", key]) + .output() + .unwrap(); + let current = String::from_utf8_lossy(¤t.stdout) + .trim() + .to_lowercase(); + let value = values.into_iter().find(|value| *value != current).unwrap(); + let before = BindGuard::capture(root.path(), None).unwrap(); + git(root.path(), &["config", key, value]); + assert!(before.validate(root.path()).is_err(), "{key}"); + } + let external = tempfile::tempdir().unwrap(); + let include = external.path().join("included.config"); + fs::write(&include, b"[core]\n eol = lf\n").unwrap(); + git( + root.path(), + &["config", "include.path", include.to_str().unwrap()], + ); + let before = BindGuard::capture(root.path(), None).unwrap(); + fs::write(&include, b"[core]\n eol = crlf\n").unwrap(); + assert!(before.validate(root.path()).is_err()); + } + + #[test] + fn stat_evidence_is_generation_root_policy_and_version_paired() { + let _serial = crate::blob_store::v3::serial_for_test(); + let storage = tempfile::tempdir().unwrap(); + let root = tempfile::tempdir().unwrap(); + let file = root.path().join("file.rs"); + fs::write(&file, b"stable").unwrap(); + let guard = BindGuard::capture(root.path(), None).unwrap(); + let route = super::super::snapshot::V3Route { + storage: storage.path().to_owned(), + family: "evidence".into(), + scope: "worker".into(), + }; + fs::create_dir_all(route.member_dir()).unwrap(); + let path = RelPath::new(b"file.rs".to_vec()).unwrap(); + let content = ContentHash::of(b"stable"); + let record = Evidence { + version: 1, + stat: NativeStat::read(&file).unwrap(), + observed_ns: now_ns(), + content: content.to_hex(), + size: 6, + git_oid: None, + policy: guard.policy.digest.clone(), + keys: BTreeMap::from([("Trigram".into(), "ab".repeat(32))]), + }; + write_evidence( + &route, + "g-one", + &guard, + &BTreeMap::from([(path.clone(), record.clone())]), + ) + .unwrap(); + let restored = read_evidence(&route.member_dir(), "g-one", &guard).unwrap(); + assert_eq!(restored[&path].content(), Some(content)); + assert_eq!( + restored[&path].key(FamilyPlane::Trigram).unwrap().to_hex(), + "ab".repeat(32) + ); + assert!(restored[&path].git_oid.is_none()); + assert_eq!( + crate::reader_floor::read(storage.path()) + .unwrap() + .unwrap() + .stores["views_v3_registry"], + 3 + ); + let mut wrong = guard.clone(); + wrong.policy.digest = "changed".into(); + assert!(read_evidence(&route.member_dir(), "g-one", &wrong).is_none()); + wrong = guard.clone(); + wrong.root = FileIdentity::Windows { + volume: 1, + id: [2; 16], + creation: 3, + }; + assert!(read_evidence(&route.member_dir(), "g-one", &wrong).is_none()); + fs::copy( + evidence_path(&route.member_dir(), "g-one"), + evidence_path(&route.member_dir(), "g-two"), + ) + .unwrap(); + assert!(read_evidence(&route.member_dir(), "g-two", &guard).is_none()); + let db = rusqlite::Connection::open(evidence_path(&route.member_dir(), "g-one")).unwrap(); + db.pragma_update(None, "user_version", 2).unwrap(); + assert!(read_evidence(&route.member_dir(), "g-one", &guard).is_none()); + db.pragma_update(None, "user_version", 1).unwrap(); + db.pragma_update(None, "application_id", 0x1234).unwrap(); + assert!(read_evidence(&route.member_dir(), "g-one", &guard).is_none()); + } + + #[test] + fn stat_evidence_discards_identity_change_and_racy_observations() { + let root = tempfile::tempdir().unwrap(); + let file = root.path().join("file.rs"); + fs::write(&file, b"one").unwrap(); + let stat = NativeStat::read(&file).unwrap(); + let observed = u128::try_from(stat.modified_ns).unwrap(); + assert!(!stat.reusable(&stat, observed)); + assert!(stat.reusable(&stat, observed + 1)); + let mut changed = stat.clone(); + changed.reuse_supported = false; + assert!(!changed.reusable(&changed, observed + 1)); + changed = stat.clone(); + changed.changed_ns += 1; + assert!(!stat.reusable(&changed, observed + 1)); + changed = stat.clone(); + changed.identity = FileIdentity::Windows { + volume: 42, + id: [3; 16], + creation: 0, + }; + assert!(!stat.reusable(&changed, observed + 1)); + } + + #[test] + fn final_revalidation_refuses_git_index_head_root_and_policy_changes() { + let root = tempfile::tempdir().unwrap(); + git(root.path(), &["init", "-q"]); + git(root.path(), &["config", "user.name", "test"]); + git( + root.path(), + &["config", "user.email", "test@example.invalid"], + ); + fs::write(root.path().join("file.rs"), b"initial").unwrap(); + git(root.path(), &["add", "."]); + git(root.path(), &["commit", "-qm", "first"]); + let guard = BindGuard::capture(root.path(), None).unwrap(); + guard.validate(root.path()).unwrap(); + fs::write(root.path().join("file.rs"), b"staged").unwrap(); + git(root.path(), &["add", "."]); + assert!(guard.validate(root.path()).is_err()); + let staged = BindGuard::capture(root.path(), None).unwrap(); + git(root.path(), &["commit", "-qm", "second"]); + assert!(staged.validate(root.path()).is_err()); + let committed = BindGuard::capture(root.path(), None).unwrap(); + git(root.path(), &["config", "core.autocrlf", "true"]); + assert!(committed.validate(root.path()).is_err()); + assert!( + !BindGuard::capture(root.path(), None) + .unwrap() + .policy + .reusable + ); + git(root.path(), &["config", "core.autocrlf", "false"]); + let before_attribute = BindGuard::capture(root.path(), None).unwrap(); + fs::write(root.path().join(".gitattributes"), b"*.rs text\n").unwrap(); + assert!(before_attribute.validate(root.path()).is_err()); + assert!( + !BindGuard::capture(root.path(), None) + .unwrap() + .policy + .reusable + ); + let replacement = tempfile::tempdir().unwrap(); + assert!(committed.validate(replacement.path()).is_err()); + } +} diff --git a/crates/aft/src/watcher_backend/cookie.rs b/crates/aft/src/watcher_backend/cookie.rs new file mode 100644 index 0000000000..3bc0e3ddfc --- /dev/null +++ b/crates/aft/src/watcher_backend/cookie.rs @@ -0,0 +1,151 @@ +//! In-band views delivery acknowledgments, distinct from command-report flushes. +use crate::views::snapshot::WatcherState; +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; +use std::sync::{atomic::AtomicU64, atomic::Ordering, mpsc, Arc, Mutex}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +const PREFIX: &str = ".aft-cookie-"; +static NEXT: AtomicU64 = AtomicU64::new(0); +type Pending = Arc>>>; + +/// A driver delivery cut, not a verified-bind or durable-publication certificate. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct CookieObservation { + pub revision: u64, + pub watcher: WatcherState, +} + +/// An exclusive root cookie. Dropping a timed-out ticket cleans up its file. +pub struct CookieTicket { + path: PathBuf, + pending: Pending, + rx: mpsc::Receiver, +} + +impl CookieTicket { + pub fn path(&self) -> &Path { + &self.path + } + + /// On timeout or an untrusted watcher, use strict reconciliation rather + /// than relying on the cookie acknowledgment. + pub fn wait_timeout( + &self, + timeout: Duration, + ) -> Result { + self.rx.recv_timeout(timeout) + } +} + +impl Drop for CookieTicket { + fn drop(&mut self) { + self.pending + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .remove(&self.path); + let _ = std::fs::remove_file(&self.path); + } +} + +#[derive(Default)] +pub(crate) struct CookieBarrier { + pending: Pending, +} + +impl CookieBarrier { + pub(crate) fn begin(&self, root: &Path) -> std::io::Result { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(); + let path = root.join(format!( + "{PREFIX}{}-{nonce:x}{:016x}", + std::process::id(), + NEXT.fetch_add(1, Ordering::Relaxed) + )); + let (tx, rx) = mpsc::channel(); + // Hold the registry lock across creation: even immediate delivery must + // find the waiter, and create_new cannot overwrite a checkout file. + let mut pending = self + .pending + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&path)?; + pending.insert(path.clone(), tx); + Ok(CookieTicket { + path, + pending: Arc::clone(&self.pending), + rx, + }) + } + + pub(crate) fn observe(&self, path: &Path, observation: CookieObservation) { + if let Some(tx) = self + .pending + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .remove(path) + { + let _ = tx.send(observation); + } + } +} + +pub(crate) fn is_cookie_path(path: &Path) -> bool { + path.file_name() + .and_then(|name| name.to_str()) + .and_then(|name| name.strip_prefix(PREFIX)) + .and_then(|suffix| suffix.split_once('-')) + .is_some_and(|(pid, nonce)| { + !pid.is_empty() + && pid.bytes().all(|byte| byte.is_ascii_digit()) + && !nonce.is_empty() + && nonce + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-') + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn watcher_cookie_tickets_match_exact_names_and_cleanup_on_timeout() { + let root = tempfile::tempdir().unwrap(); + let barrier = CookieBarrier::default(); + let ticket = barrier.begin(root.path()).unwrap(); + let path = ticket.path().to_path_buf(); + assert!(is_cookie_path(&path)); + let observation = CookieObservation { + revision: 7, + watcher: WatcherState::Healthy, + }; + barrier.observe(&root.path().join(".aft-cookie-1-unregistered"), observation); + assert_eq!( + ticket.wait_timeout(Duration::ZERO), + Err(mpsc::RecvTimeoutError::Timeout) + ); + barrier.observe(&path, observation); + assert_eq!(ticket.wait_timeout(Duration::ZERO), Ok(observation)); + drop(ticket); + assert!(!path.exists()); + let ticket = barrier.begin(root.path()).unwrap(); + let path = ticket.path().to_path_buf(); + drop(ticket); + assert!(!path.exists()); + assert!(barrier.pending.lock().unwrap().is_empty()); + for name in [ + ".aft-cookie", + ".aft-cookie--nonce", + ".aft-cookie-pid-nonce", + ".aft-cookie-1-", + ] { + assert!(!is_cookie_path(Path::new(name))); + } + } +} diff --git a/crates/aft/src/watcher_backend/cookie_tests.rs b/crates/aft/src/watcher_backend/cookie_tests.rs new file mode 100644 index 0000000000..05c1a8b882 --- /dev/null +++ b/crates/aft/src/watcher_backend/cookie_tests.rs @@ -0,0 +1,299 @@ +//! Backend delivery shim: the native producer is held while the real filter and +//! driver drain remain live. This tests application ordering, not kernel coverage. +use crate::config::Config; +use crate::context::{default_language_provider_factory, AppContext}; +use crate::runtime_drain::{drain_watcher_events_bounded, drain_watcher_to_completion_for_test}; +use crate::views::contracts::ViewAccess; +use crate::views::first_load::{CheckoutDriver, ConfiguredMembershipWalker, FirstLoadDriver}; +use crate::views::manifest_v2::Producers; +use crate::views::registry::FamilyRegistry; +use crate::views::snapshot::WatcherState; +use crate::views::RelPath; +use crate::watcher_filter::{ + run_watcher_thread, watcher_dispatch_channel, WatcherDispatchEvent, WatcherFilterConfig, +}; +use notify::{event::CreateKind, event::DataChange, event::ModifyKind, Event, EventKind}; +use std::path::Path; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::{mpsc, Arc, RwLock}; +use std::time::Duration; + +fn driver(root: &Path, storage: &Path) -> (Arc, ViewAccess) { + let registry = FamilyRegistry::open(storage, "family").unwrap(); + let owner = registry + .register_view_with_storage(registry.storage(), "cookie", root) + .unwrap(); + let access = ViewAccess::Owner(owner.clone()); + ( + Arc::new(CheckoutDriver::new( + owner, + Producers { + trigram: "unused".into(), + semantic: None, + callgraph: "unused".into(), + }, + None, + Arc::new(ConfiguredMembershipWalker), + vec![], + )), + access, + ) +} + +fn context(root: &Path, driver: &Arc) -> AppContext { + let ctx = AppContext::new(default_language_provider_factory(), Config::default()); + ctx.update_config(|config| config.project_root = Some(root.to_path_buf())); + ctx.set_canonical_cache_root(root.to_path_buf()); + ctx.mark_subc_bound(); + ctx.install_checkout_driver(Arc::clone(driver)); + ctx +} + +fn delayed_delivery_past_read() { + let temp = tempfile::tempdir().unwrap(); + let storage = tempfile::tempdir().unwrap(); + let root = temp.path().canonicalize().unwrap(); + let changed = root.join("source.rs"); + std::fs::write(&changed, b"old bytes").unwrap(); + let (driver, _) = driver(&root, storage.path()); + let ctx = context(&root, &driver); + let (dispatch_tx, dispatch_rx) = watcher_dispatch_channel(); + *ctx.watcher_rx().lock() = Some(dispatch_rx.clone()); + let shutdown = Arc::new(AtomicBool::new(false)); + let (attached_tx, attached_rx) = mpsc::channel(); + let (release_tx, release_rx) = mpsc::channel(); + let (script_tx, script_rx) = mpsc::channel::>(); + let config = WatcherFilterConfig::new(root.clone(), None); + let thread_shutdown = Arc::clone(&shutdown); + let filter = std::thread::spawn(move || { + run_watcher_thread( + config, + vec![], + Arc::new(RwLock::new(None)), + Arc::new(AtomicU64::new(0)), + dispatch_tx, + thread_shutdown, + move |_, _, raw_tx| { + // Holding at the backend handoff, not at the reader, reproduces + // a completed write whose invalidation arrives after the read. + let shim = std::thread::spawn(move || { + attached_tx.send(()).unwrap(); + release_rx.recv().unwrap(); + for event in script_rx.recv().unwrap() { + raw_tx.send(Ok(event)).unwrap(); + } + // Keep the raw stream connected until the filter stops. + raw_tx + }); + Ok::<_, String>(shim) + }, + ); + }); + attached_rx.recv_timeout(Duration::from_secs(5)).unwrap(); + let read = std::fs::read(&changed).unwrap(); + std::fs::write(&changed, b"new bytes").unwrap(); + assert_eq!(std::fs::read(&changed).unwrap(), b"new bytes"); + let ticket = driver.begin_watcher_cookie().unwrap(); + let cookie_path = ticket.path().to_path_buf(); + script_tx + .send(vec![ + Event::new(EventKind::Modify(ModifyKind::Data(DataChange::Content))) + .add_path(changed.clone()), + Event::new(EventKind::Create(CreateKind::File)).add_path(cookie_path.clone()), + ]) + .unwrap(); + drain_watcher_events_bounded(&ctx, 1); + assert_eq!(read, b"old bytes"); + assert_eq!( + ticket.wait_timeout(Duration::ZERO), + Err(mpsc::RecvTimeoutError::Timeout) + ); + assert!(driver + .installed_snapshot() + .pending_intent() + .next() + .is_none()); + release_tx.send(()).unwrap(); + + // Peek by consuming then restoring the complete prefix. This is a bounded + // handshake, not a timing assumption about how fast the worker is scheduled. + let first = dispatch_rx.recv_timeout(Duration::from_secs(5)).unwrap(); + let second = dispatch_rx.recv_timeout(Duration::from_secs(5)).unwrap(); + assert!( + matches!(&first, WatcherDispatchEvent::ContentOnlyPaths(paths) if paths == &vec![changed.clone()]) + ); + assert_eq!( + second, + WatcherDispatchEvent::CookieObserved(cookie_path.clone()) + ); + assert!( + !cookie_path.exists(), + "unlink must precede driver acknowledgment" + ); + assert_eq!( + ticket.wait_timeout(Duration::ZERO), + Err(mpsc::RecvTimeoutError::Timeout) + ); + let (tx, rx) = crossbeam_channel::unbounded(); + tx.send(first).unwrap(); + tx.send(second).unwrap(); + *ctx.watcher_rx().lock() = Some(rx); + drain_watcher_to_completion_for_test(&ctx, 1); + let observed = ticket.wait_timeout(Duration::ZERO).unwrap(); + assert!(observed.revision > 0); + assert!(driver + .installed_snapshot() + .pending_intent() + .any(|path| path == &RelPath::new("source.rs").unwrap())); + assert!(!driver + .installed_snapshot() + .membership() + .keys() + .any(|path| path.as_bytes().starts_with(b".aft-cookie-"))); + shutdown.store(true, Ordering::SeqCst); + filter.join().unwrap(); +} + +#[cfg(target_os = "linux")] +#[test] +fn watcher_cookie_delayed_delivery_past_read_inotify() { + delayed_delivery_past_read(); +} + +#[cfg(target_os = "macos")] +#[test] +fn watcher_cookie_delayed_delivery_past_read_fsevents() { + delayed_delivery_past_read(); +} + +#[cfg(windows)] +#[test] +fn watcher_cookie_delayed_delivery_past_read_windows() { + delayed_delivery_past_read(); +} + +#[test] +fn watcher_cookie_is_excluded_from_walk_membership_before_delivery() { + let temp = tempfile::tempdir().unwrap(); + let storage = tempfile::tempdir().unwrap(); + let root = temp.path().canonicalize().unwrap(); + std::fs::write(root.join("source.rs"), b"source").unwrap(); + let (driver, access) = driver(&root, storage.path()); + let ticket = driver.begin_watcher_cookie().unwrap(); + assert!(ticket.path().exists()); + let reconciled = driver.reconcile(&access).unwrap(); + assert_eq!(reconciled.entries.len(), 1); + assert!(reconciled + .entries + .contains_key(&RelPath::new("source.rs").unwrap())); + assert!( + ticket.path().exists(), + "only delivery or ticket cleanup unlinks the cookie" + ); + assert!(!super::flush::FlushControl::default().observe( + &Event::new(EventKind::Create(CreateKind::File)).add_path(ticket.path().to_path_buf()) + )); +} + +#[test] +fn watcher_cookie_rescan_dispatch_marks_driver_and_survives_later_paths() { + use crate::watcher_filter::RescanReason; + let temp = tempfile::tempdir().unwrap(); + let storage = tempfile::tempdir().unwrap(); + let root = temp.path().canonicalize().unwrap(); + let (driver, access) = driver(&root, storage.path()); + let ctx = context(&root, &driver); + let (tx, rx) = crossbeam_channel::unbounded(); + *ctx.watcher_rx().lock() = Some(rx); + for reason in [ + RescanReason::Unknown, + RescanReason::UserDropped, + RescanReason::KernelDropped, + RescanReason::BufferOverflow, + ] { + let before = driver.revision(&access); + let ticket = driver.begin_watcher_cookie().unwrap(); + tx.send(WatcherDispatchEvent::RescanRequired(reason)) + .unwrap(); + tx.send(WatcherDispatchEvent::CookieObserved( + ticket.path().to_path_buf(), + )) + .unwrap(); + drain_watcher_to_completion_for_test(&ctx, 1); + let observed = ticket.wait_timeout(Duration::ZERO).unwrap(); + assert_eq!(observed.watcher, WatcherState::Overflowed); + assert!(observed.revision > before); + driver.record_absolute_change(&root.join("later.rs")); + assert_eq!( + driver.installed_snapshot().watcher(), + WatcherState::Overflowed + ); + } +} + +#[test] +fn watcher_cookie_fences_paths_already_parked_in_an_apply_slice() { + use crate::context::{WatcherDrainApplyPhase, WatcherDrainPhase, WatcherDrainSliceState}; + let temp = tempfile::tempdir().unwrap(); + let storage = tempfile::tempdir().unwrap(); + let root = temp.path().canonicalize().unwrap(); + let (driver, _) = driver(&root, storage.path()); + let ctx = context(&root, &driver); + let mut state = WatcherDrainSliceState::new( + ctx.configure_generation(), + ctx.configure_content_generation(), + ); + state.phase = WatcherDrainPhase::Apply { + stage: WatcherDrainApplyPhase::PendingTier2, + paths: [root.join("parked.rs")].into(), + remaining: 1, + oversized_inline_batch: false, + }; + *ctx.watcher_drain_slice().lock() = Some(state); + let (tx, rx) = crossbeam_channel::unbounded(); + *ctx.watcher_rx().lock() = Some(rx); + let ticket = driver.begin_watcher_cookie().unwrap(); + tx.send(WatcherDispatchEvent::CookieObserved( + ticket.path().to_path_buf(), + )) + .unwrap(); + drain_watcher_to_completion_for_test(&ctx, 1); + assert!(ticket.wait_timeout(Duration::ZERO).unwrap().revision > 0); + assert!(driver + .installed_snapshot() + .pending_intent() + .any(|path| path == &RelPath::new("parked.rs").unwrap())); +} + +#[test] +fn watcher_cookie_coalesced_rescan_gap_still_invalidates_the_current_driver() { + use crate::watcher_filter::RescanReason; + let temp = tempfile::tempdir().unwrap(); + let storage = tempfile::tempdir().unwrap(); + let root = temp.path().canonicalize().unwrap(); + let (driver, access) = driver(&root, storage.path()); + let ctx = context(&root, &driver); + let (_tx, rx) = crossbeam_channel::unbounded(); + *ctx.watcher_rx().lock() = Some(rx); + ctx.watcher_counters().start_rescan(); + assert!(ctx + .watcher_counters() + .note_overflow(RescanReason::BufferOverflow, vec![])); + let before = driver.revision(&access); + drain_watcher_events_bounded(&ctx, 1); + assert!(driver.revision(&access) > before); + assert_eq!( + driver.installed_snapshot().watcher(), + WatcherState::Overflowed + ); + assert_eq!( + ctx.watcher_counters() + .snapshot() + .rescans_buffer_overflow_total, + 0 + ); + assert_eq!( + ctx.watcher_counters().finish_rescan_walk(), + Some(RescanReason::BufferOverflow) + ); +} diff --git a/crates/aft/src/watcher_backend/flush.rs b/crates/aft/src/watcher_backend/flush.rs index 8e67664552..2f0be61180 100644 --- a/crates/aft/src/watcher_backend/flush.rs +++ b/crates/aft/src/watcher_backend/flush.rs @@ -231,7 +231,7 @@ impl FlushControl { if event.need_rescan() { return false; } - let marker_event = event.paths.iter().any(|path| is_marker_path(path)); + let marker_event = event.paths.iter().any(|path| is_flush_marker_path(path)); if !marker_event { return false; } @@ -253,6 +253,12 @@ impl FlushControl { } pub(crate) fn is_marker_path(path: &Path) -> bool { + // Corpus admission excludes both protocols, but command-report intake must + // not consume a views cookie before it reaches the driver's ordered queue. + super::cookie::is_cookie_path(path) || is_flush_marker_path(path) +} + +fn is_flush_marker_path(path: &Path) -> bool { let Some(name) = path.file_name().and_then(|name| name.to_str()) else { return false; }; diff --git a/crates/aft/src/watcher_backend/fsevents.rs b/crates/aft/src/watcher_backend/fsevents.rs index 8d29737957..e28a5a50f0 100644 --- a/crates/aft/src/watcher_backend/fsevents.rs +++ b/crates/aft/src/watcher_backend/fsevents.rs @@ -1878,6 +1878,7 @@ mod tests { Ok( WatcherDispatchEvent::Paths(_) | WatcherDispatchEvent::ContentOnlyPaths(_) + | WatcherDispatchEvent::CookieObserved(_) | WatcherDispatchEvent::IgnoreRulesChanged { .. } | WatcherDispatchEvent::RootDeleted, ) => {} diff --git a/crates/aft/src/watcher_backend/inotify.rs b/crates/aft/src/watcher_backend/inotify.rs index e133f89270..f7e61dd69e 100644 --- a/crates/aft/src/watcher_backend/inotify.rs +++ b/crates/aft/src/watcher_backend/inotify.rs @@ -30,9 +30,13 @@ pub(crate) struct ProjectWatcher { shutdown: Arc, join: Option>, watched_directory_count: Arc, + initial_coverage: super::InitialCoverage, } impl ProjectWatcher { + pub(crate) fn initial_coverage(&self) -> super::InitialCoverage { + self.initial_coverage.clone() + } pub(crate) fn create( root: PathBuf, extra_watch_paths: Vec, @@ -114,38 +118,64 @@ impl ProjectWatcher { let (backend_tx, backend_rx) = mpsc::channel(); let mut watcher = notify::recommended_watcher(backend_tx)?; - let walked_directories = exclusion_state - .boundary() - .map_or_else(BTreeMap::new, |boundary| { - collect_watch_directories(boundary, &matcher, &exclusion_paths) - }); counters.set_backend_exclusions( observed_generation.unwrap_or(0), exclusion_paths.clone(), watcher_exclusion_paths(&plan.dropped), ); - let mut installed = BTreeSet::new(); - for directory in desired_watch_directories( - walked_directories, - &BTreeSet::new(), - &matcher, - &exclusion_paths, - &root, - ) { - if watch_directory(&mut watcher, &directory, &mut watch, &counters)? { - installed.insert(directory.clone()); - } else if directory == root { - return Err(notify::Error::path_not_found().add_path(root)); - } - } + let boundary = exclusion_state + .boundary() + .ok_or_else(|| notify::Error::path_not_found().add_path(root.clone()))?; + let InitialRegistration { + watched: installed, + mut gaps, + } = register_initial_tree(boundary, &matcher, &exclusion_paths, &mut |directory| { + watch_directory(&mut watcher, directory, &mut watch, &counters) + })?; let mut watched_directories = installed; let git_directory = root.join(".git"); let mut git_watched = watched_directories.contains(&git_directory); for path in extra_watch_paths { - if path.exists() { - let _ = watch_directory(&mut watcher, &path, &mut watch, &counters)?; + let exists = match path.try_exists() { + Ok(exists) => exists, + Err(error) => { + gaps.push(super::CoverageGap { + path: path.clone(), + kind: super::CoverageGapKind::MetadataFailed(error.kind()), + }); + if path == git_directory { + git_watched = false; + } + continue; + } + }; + if exists { + let watched = match watch_directory(&mut watcher, &path, &mut watch, &counters) { + Ok(watched) => watched, + Err(error) if fatal_watch_error(&error) => return Err(error), + Err(error) => { + let kind = match &error.kind { + ErrorKind::Io(error) => error.kind(), + _ => std::io::ErrorKind::Other, + }; + gaps.push(super::CoverageGap { + path: path.clone(), + kind: super::CoverageGapKind::WatchFailed(kind), + }); + if path == git_directory { + git_watched = false; + } + continue; + } + }; + if !watched { + gaps.push(super::CoverageGap { + path: path.clone(), + kind: super::CoverageGapKind::WatchLost, + }); + } if path == git_directory { - git_watched = true; + git_watched = watched; } } } @@ -335,12 +365,11 @@ impl ProjectWatcher { match backend_rx.recv_timeout(BACKEND_POLL_INTERVAL) { Ok(Ok(event)) => { - if counters.bash_changes.get().is_some() - && event - .paths - .iter() - .any(|path| super::flush::is_marker_path(path)) - { + if event.paths.iter().any(|path| { + super::cookie::is_cookie_path(path) + || (counters.bash_changes.get().is_some() + && super::flush::is_marker_path(path)) + }) { // New-directory recovery waits briefly for duplicate kernel // events. Publish its files before the ordered marker too. if !new_directory_files.files.is_empty() @@ -465,6 +494,11 @@ impl ProjectWatcher { shutdown, join: Some(join), watched_directory_count, + initial_coverage: if gaps.is_empty() { + super::InitialCoverage::Complete + } else { + super::InitialCoverage::Incomplete(gaps) + }, }) } @@ -507,6 +541,11 @@ impl Drop for ProjectWatcher { // A removed directory cannot contain live files to invalidate. A move out // produces a rename event for its old path; a move into the root is scanned // on Rename(To), including any files already present in its subtree. +fn fatal_watch_error(error: ¬ify::Error) -> bool { + matches!(error.kind, ErrorKind::MaxFilesWatch) + || matches!(&error.kind, ErrorKind::Io(io) if matches!(io.raw_os_error(), Some(libc::ENOSPC | libc::EMFILE | libc::ENFILE))) +} + fn watch_directory( watcher: &mut notify::RecommendedWatcher, path: &Path, @@ -521,8 +560,7 @@ where Err(error) => { // Limits and resource exhaustion are failures of the watcher, even // if the directory happens to disappear before the recheck. - let fatal = matches!(error.kind, ErrorKind::MaxFilesWatch) - || matches!(&error.kind, ErrorKind::Io(io) if matches!(io.raw_os_error(), Some(libc::ENOSPC | libc::EMFILE | libc::ENFILE))); + let fatal = fatal_watch_error(&error); if !fatal && (matches!(error.kind, ErrorKind::PathNotFound) || matches!(&error.kind, ErrorKind::Io(io) if io.kind() == std::io::ErrorKind::NotFound) @@ -735,6 +773,121 @@ impl DirectoryIdentity { } } +struct InitialRegistration { + watched: BTreeSet, + gaps: Vec, +} + +/// Arm each parent before listing it. Keep covered directories running when a +/// descendant fails, but retain every gap so indexing cannot trust full coverage. +fn register_initial_tree( + boundary: &crate::watcher_filter::WatcherWalkBoundary, + matcher: &SharedGitignore, + exclusions: &[PathBuf], + watch: &mut dyn FnMut(&Path) -> notify::Result, +) -> notify::Result { + let root = boundary.root(); + let mut stack = vec![root.to_path_buf()]; + let mut installed = BTreeSet::new(); + let mut gaps = Vec::new(); + while let Some(directory) = stack.pop() { + if exclusions + .iter() + .any(|excluded| directory.starts_with(excluded)) + || watcher_directory_is_ignored_by_matcher(matcher, &directory, root) + { + continue; + } + match std::fs::symlink_metadata(&directory) { + Ok(metadata) if metadata.is_dir() => {} + Ok(_) => { + gaps.push(super::CoverageGap { + path: directory, + kind: super::CoverageGapKind::WatchLost, + }); + continue; + } + Err(error) if directory == root => { + return Err(notify::Error::io(error).add_path(directory)) + } + Err(error) => { + gaps.push(super::CoverageGap { + path: directory, + kind: super::CoverageGapKind::MetadataFailed(error.kind()), + }); + continue; + } + } + if !boundary.allows_directory(&directory) { + continue; + } + match watch(&directory) { + Ok(true) => {} + Ok(false) => { + gaps.push(super::CoverageGap { + path: directory, + kind: super::CoverageGapKind::WatchLost, + }); + continue; + } + Err(error) if directory == root || fatal_watch_error(&error) => return Err(error), + Err(error) => { + let kind = match &error.kind { + ErrorKind::Io(error) => error.kind(), + _ => std::io::ErrorKind::Other, + }; + gaps.push(super::CoverageGap { + path: directory, + kind: super::CoverageGapKind::WatchFailed(kind), + }); + continue; + } + } + installed.insert(directory.clone()); + #[cfg(test)] + tests::block_watch_walk(&directory); + #[cfg(test)] + let entries = if tests::initial_listing_denied(&directory) { + Err(std::io::Error::from(std::io::ErrorKind::PermissionDenied)) + } else { + crate::watcher_filter::exclusion_read_dir(&directory) + }; + #[cfg(not(test))] + let entries = crate::watcher_filter::exclusion_read_dir(&directory); + match entries { + Ok(entries) => { + for entry in entries { + match entry { + Ok(entry) => match entry.file_type() { + Ok(kind) if kind.is_dir() => stack.push(entry.path()), + Ok(_) => {} + Err(error) => gaps.push(super::CoverageGap { + path: entry.path(), + kind: super::CoverageGapKind::MetadataFailed(error.kind()), + }), + }, + Err(error) => gaps.push(super::CoverageGap { + path: directory.clone(), + kind: super::CoverageGapKind::ListingFailed(error.kind()), + }), + } + } + } + Err(error) => gaps.push(super::CoverageGap { + path: directory, + kind: super::CoverageGapKind::ListingFailed(error.kind()), + }), + } + } + if !installed.contains(root) { + return Err(notify::Error::path_not_found().add_path(root.to_path_buf())); + } + Ok(InitialRegistration { + watched: installed, + gaps, + }) +} + fn collect_watch_directories( boundary: &crate::watcher_filter::WatcherWalkBoundary, matcher: &SharedGitignore, @@ -813,6 +966,224 @@ fn desired_watch_directories( #[cfg(test)] pub(super) mod tests { + thread_local! { + static INITIAL_LISTING_DENIED: std::cell::RefCell> = const { std::cell::RefCell::new(None) }; + } + pub(super) fn initial_listing_denied(path: &Path) -> bool { + INITIAL_LISTING_DENIED.with(|denied| denied.borrow().as_deref() == Some(path)) + } + struct InitialListingDenial(Option); + impl Drop for InitialListingDenial { + fn drop(&mut self) { + INITIAL_LISTING_DENIED.with(|denied| *denied.borrow_mut() = self.0.take()); + } + } + fn deny_initial_listing(path: PathBuf) -> InitialListingDenial { + InitialListingDenial(INITIAL_LISTING_DENIED.with(|denied| denied.replace(Some(path)))) + } + + #[test] + fn initial_subscription_arms_parent_before_listing_new_descendants() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().canonicalize().unwrap(); + let boundary = crate::watcher_filter::WatcherWalkBoundary::for_root(&root).unwrap(); + let mut order = Vec::new(); + let installed = register_initial_tree( + &boundary, + &Arc::new(RwLock::new(None)), + &[], + &mut |directory| { + order.push(directory.to_path_buf()); + if directory == root { + std::fs::create_dir(root.join("during")).unwrap(); + } + if directory == root.join("during") { + std::fs::create_dir(directory.join("nested")).unwrap(); + } + Ok(true) + }, + ) + .unwrap(); + assert_eq!( + order, + vec![ + root.clone(), + root.join("during"), + root.join("during/nested") + ] + ); + assert_eq!(installed.watched.len(), 3); + assert!(installed.gaps.is_empty()); + } + + #[test] + fn initial_subscription_reports_partial_watches_and_incomplete_listings() { + for remove_after_watch in [false, true] { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().canonicalize().unwrap(); + std::fs::create_dir(root.join("child")).unwrap(); + let boundary = crate::watcher_filter::WatcherWalkBoundary::for_root(&root).unwrap(); + let result = register_initial_tree( + &boundary, + &Arc::new(RwLock::new(None)), + &[], + &mut |directory| { + if directory == root { + return Ok(true); + } + if remove_after_watch { + std::fs::remove_dir(directory).unwrap(); + Ok(true) + } else { + Ok(false) + } + }, + ); + let registration = result.unwrap(); + assert!(registration.watched.contains(&root)); + assert!(registration + .gaps + .iter() + .any(|gap| gap.path == root.join("child"))); + } + } + + #[test] + fn initial_subscription_cannot_certify_a_root_lost_before_registration() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().canonicalize().unwrap(); + let boundary = crate::watcher_filter::WatcherWalkBoundary::for_root(&root).unwrap(); + std::fs::remove_dir(&root).unwrap(); + let result = + register_initial_tree(&boundary, &Arc::new(RwLock::new(None)), &[], &mut |_| { + Ok(true) + }); + assert!(result.is_err()); + } + + #[test] + fn partial_initial_coverage_keeps_shared_watching_but_never_grants_v3_trust() { + let _serial = crate::blob_store::v3::serial_for_test(); + let temp = tempfile::tempdir().unwrap(); + let storage = tempfile::tempdir().unwrap(); + let root = temp.path().canonicalize().unwrap(); + std::fs::create_dir_all(root.join("readable")).unwrap(); + std::fs::create_dir_all(root.join("unreadable/nested")).unwrap(); + std::fs::write(root.join("unreadable/nested/file.rs"), b"fn hidden() {}\n").unwrap(); + let _denied = deny_initial_listing(root.join("unreadable")); + let (tx, rx) = mpsc::channel(); + let watcher = ProjectWatcher::create( + root.clone(), + vec![], + tx, + Arc::new(RwLock::new(None)), + Arc::new(AtomicU64::new(0)), + ) + .unwrap(); + let coverage = super::super::initial_coverage_of(&watcher); + let super::super::InitialCoverage::Incomplete(gaps) = &coverage else { + panic!("partial registration must name its gap") + }; + assert!(gaps.iter().any(|gap| gap.path == root.join("unreadable") + && gap.kind + == super::super::CoverageGapKind::ListingFailed( + std::io::ErrorKind::PermissionDenied + ))); + let changed = root.join("readable/still-watched.rs"); + std::fs::write(&changed, b"fn visible() {}\n").unwrap(); + let deadline = Instant::now() + Duration::from_secs(5); + loop { + let event = rx + .recv_timeout(deadline.saturating_duration_since(Instant::now())) + .unwrap() + .unwrap(); + if event.paths.contains(&changed) { + break; + } + } + let _subscription = super::super::subscription::start(&root, coverage.clone()); + assert_eq!( + super::super::subscription::status(&root).unwrap().coverage, + coverage + ); + assert!(super::super::subscription::current(&root).is_none()); + let policy = crate::blob_store::v2::TrigramPolicy { + max_file_size: 1 << 20, + }; + let driver = crate::views::first_load::CheckoutDriver::new_v3( + storage.path(), + "family", + "worker", + &root, + crate::views::manifest_v2::Producers { + trigram: policy.fingerprint_hex(), + semantic: None, + callgraph: "unregistered".into(), + }, + Arc::new(crate::views::first_load::ConfiguredMembershipWalker), + vec![Arc::new(crate::views::first_load::TrigramBridge::new( + storage.path().to_owned(), + policy, + ))], + ) + .unwrap(); + let access = driver.access(); + let (snapshot, revision) = driver + .verified_bind(&access, driver.installed_snapshot().generation().clone()) + .unwrap(); + assert!(snapshot + .generation() + .published() + .unwrap() + .verified_frontier + .is_none()); + assert!(driver.take_reconcile_work().walks > 0); + use crate::views::first_load::FirstLoadDriver; + driver.install(&access, &snapshot, revision).unwrap(); + assert!(driver.installed_snapshot().needs_reconcile()); + } + + #[test] + fn watcher_cookie_inotify_recovery_precedes_root_cookie_without_command_tracking() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().canonicalize().unwrap(); + let staging = tempfile::tempdir().unwrap(); + let tree = staging.path().join("tree"); + std::fs::create_dir(&tree).unwrap(); + std::fs::write(tree.join("source.rs"), b"already populated").unwrap(); + let counters = crate::context::watcher_counters_for_root(&root); + assert!(counters.bash_changes.get().is_none()); + let (tx, rx) = mpsc::channel(); + let _watcher = ProjectWatcher::create( + root.clone(), + vec![], + tx, + Arc::new(RwLock::new(None)), + Arc::new(AtomicU64::new(0)), + ) + .unwrap(); + std::fs::rename(&tree, root.join("tree")).unwrap(); + let cookie = root.join(".aft-cookie-123-nonce"); + std::fs::write(&cookie, b"").unwrap(); + let deadline = Instant::now() + Duration::from_secs(5); + let mut recovered = false; + loop { + let event = rx + .recv_timeout(deadline.saturating_duration_since(Instant::now())) + .unwrap() + .unwrap(); + assert!(!event.need_rescan()); + recovered |= event.paths.contains(&root.join("tree/source.rs")); + if event.paths.contains(&cookie) { + assert!( + recovered, + "cookie overtook recovered new-directory membership" + ); + break; + } + } + } + use std::sync::{Condvar, Mutex, OnceLock, RwLock}; use std::time::{Duration, Instant}; diff --git a/crates/aft/src/watcher_backend/mod.rs b/crates/aft/src/watcher_backend/mod.rs index 5785c74302..b372e42284 100644 --- a/crates/aft/src/watcher_backend/mod.rs +++ b/crates/aft/src/watcher_backend/mod.rs @@ -3,7 +3,65 @@ use std::sync::atomic::AtomicU64; use std::sync::{mpsc, Arc}; use crate::watcher_filter::SharedGitignore; +pub(crate) mod cookie; +#[cfg(test)] +mod cookie_tests; pub(crate) mod flush; +pub(crate) mod subscription; + +// Inotify reports per-directory gaps. Recursive-handle backends currently +// report only successful or unproved registration at construction. +#[cfg_attr(not(target_os = "linux"), allow(dead_code))] +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) enum CoverageGapKind { + ListingFailed(std::io::ErrorKind), + MetadataFailed(std::io::ErrorKind), + WatchFailed(std::io::ErrorKind), + WatchLost, +} + +#[cfg_attr(not(target_os = "linux"), allow(dead_code))] +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct CoverageGap { + pub path: PathBuf, + pub kind: CoverageGapKind, +} + +#[cfg_attr(not(target_os = "linux"), allow(dead_code))] +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) enum InitialCoverage { + Complete, + Incomplete(Vec), + Unproved, +} + +impl InitialCoverage { + pub(crate) fn is_complete(&self) -> bool { + matches!(self, Self::Complete) + } +} + +// Raw-event test shims and other opaque handles cannot grant kernel coverage. +// Inspect the actual retained native handle without constraining the generic +// filter API used by those event injectors. +pub(crate) fn initial_coverage_of(handle: &dyn std::any::Any) -> InitialCoverage { + handle + .downcast_ref::() + .map_or(InitialCoverage::Unproved, |watcher| { + watcher.initial_coverage() + }) +} + +#[cfg(not(target_os = "linux"))] +impl ProjectWatcher { + pub(crate) fn initial_coverage(&self) -> InitialCoverage { + if cfg!(any(target_os = "macos", windows)) { + InitialCoverage::Complete + } else { + InitialCoverage::Unproved + } + } +} #[cfg(target_os = "macos")] mod fsevents; diff --git a/crates/aft/src/watcher_backend/subscription.rs b/crates/aft/src/watcher_backend/subscription.rs new file mode 100644 index 0000000000..64e88a30ca --- /dev/null +++ b/crates/aft/src/watcher_backend/subscription.rs @@ -0,0 +1,153 @@ +//! Subscription generation and observed gaps for steady-state refresh. This +//! does not prove that all writes before a cookie reached the driver, so it +//! cannot justify a bind based on Git/stat observations. + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex, OnceLock, Weak}; + +use super::InitialCoverage; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) struct Continuity { + pub generation: u64, + pub gap: u64, +} +struct State { + generation: u64, + gap: AtomicU64, + coverage: InitialCoverage, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct SubscriptionStatus { + pub continuity: Continuity, + pub coverage: InitialCoverage, +} +pub(crate) struct Subscription { + _state: Arc, + root: PathBuf, +} +impl Drop for Subscription { + fn drop(&mut self) { + let mut roots = roots() + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if roots + .get(&self.root) + .is_some_and(|state| state.ptr_eq(&Arc::downgrade(&self._state))) + { + roots.remove(&self.root); + } + } +} +static NEXT: AtomicU64 = AtomicU64::new(1); +fn roots() -> &'static Mutex>> { + static ROOTS: OnceLock>>> = OnceLock::new(); + ROOTS.get_or_init(Default::default) +} + +pub(crate) fn start(root: &Path, coverage: InitialCoverage) -> Subscription { + let state = Arc::new(State { + generation: NEXT.fetch_add(1, Ordering::Relaxed), + gap: AtomicU64::new(0), + coverage, + }); + roots() + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .insert(root.to_path_buf(), Arc::downgrade(&state)); + Subscription { + _state: state, + root: root.to_path_buf(), + } +} + +fn retained(root: &Path) -> Option> { + let mut roots = roots() + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + match roots.get(root).and_then(Weak::upgrade) { + Some(state) => Some(state), + None => { + roots.remove(root); + None + } + } +} + +pub(crate) fn current(root: &Path) -> Option { + retained(root) + .filter(|state| state.coverage.is_complete()) + .map(|state| Continuity { + generation: state.generation, + gap: state.gap.load(Ordering::Acquire), + }) +} + +pub(crate) fn status(root: &Path) -> Option { + retained(root).map(|state| SubscriptionStatus { + continuity: Continuity { + generation: state.generation, + gap: state.gap.load(Ordering::Acquire), + }, + coverage: state.coverage.clone(), + }) +} + +pub(crate) fn gap(root: &Path) { + if let Some(state) = roots() + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .get(root) + .and_then(Weak::upgrade) + { + state.gap.fetch_add(1, Ordering::AcqRel); + } +} + +#[cfg(test)] +mod tests { + #[test] + fn incomplete_and_unproved_handles_never_grant_a_continuity_token() { + let root = tempfile::tempdir().unwrap(); + let coverage = + super::InitialCoverage::Incomplete(vec![crate::watcher_backend::CoverageGap { + path: root.path().join("unreadable"), + kind: crate::watcher_backend::CoverageGapKind::ListingFailed( + std::io::ErrorKind::PermissionDenied, + ), + }]); + let partial = super::start(root.path(), coverage.clone()); + assert_eq!(super::status(root.path()).unwrap().coverage, coverage); + assert!(super::current(root.path()).is_none()); + drop(partial); + let _unproved = super::start( + root.path(), + crate::watcher_backend::initial_coverage_of(&()), + ); + assert_eq!( + super::status(root.path()).unwrap().coverage, + super::InitialCoverage::Unproved + ); + assert!(super::current(root.path()).is_none()); + } + + #[test] + fn subscription_restart_stop_and_gap_change_the_continuity_token() { + let root = tempfile::tempdir().unwrap(); + assert!(super::current(root.path()).is_none()); + let first = super::start(root.path(), super::InitialCoverage::Complete); + let before = super::current(root.path()).unwrap(); + super::gap(root.path()); + assert_ne!(super::current(root.path()).unwrap(), before); + let second = super::start(root.path(), super::InitialCoverage::Complete); + let after = super::current(root.path()).unwrap(); + assert_ne!(before.generation, after.generation); + drop(first); + assert_eq!(super::current(root.path()), Some(after)); + drop(second); + assert!(super::current(root.path()).is_none()); + } +} diff --git a/crates/aft/src/watcher_filter.rs b/crates/aft/src/watcher_filter.rs index eb4933d394..c76df5b841 100644 --- a/crates/aft/src/watcher_filter.rs +++ b/crates/aft/src/watcher_filter.rs @@ -180,6 +180,8 @@ pub enum WatcherDispatchEvent { Paths(Vec), /// Every path had only explicit data modifications, never topology events. ContentOnlyPaths(Vec), + /// Ordered control; preceding changes must enter the driver before its ack. + CookieObserved(PathBuf), RescanRequired(RescanReason), IgnoreRulesChanged { paths: Vec, @@ -285,7 +287,9 @@ pub fn watcher_event_invalidates(kind: ¬ify::EventKind) -> bool { pub fn watcher_path_is_infra_skip(path: &Path) -> bool { // Operating-system metadata files (a Finder `.DS_Store` rewrite, say) are // never indexed by the project walks, so their events carry no change. - if crate::project_content::is_excluded_event(path) { + if crate::watcher_backend::cookie::is_cookie_path(path) + || crate::project_content::is_excluded_event(path) + { return true; } watcher_path_has_infra_component(path) @@ -2289,6 +2293,10 @@ pub fn run_watcher_thread( return; } crate::slog_info!("watcher started: {}", root_path.display()); + let _subscription = crate::watcher_backend::subscription::start( + &root_path, + crate::watcher_backend::initial_coverage_of(&_watcher), + ); let mut filter = WatcherFilterThread::new( config, matcher, @@ -2403,10 +2411,11 @@ impl WatcherFilterThread { } match raw_rx.recv_timeout(self.next_recv_timeout()) { - Ok(Ok(event)) => { + Ok(Ok(mut event)) => { #[cfg(feature = "test-timing-hooks")] hold_watcher_intake_for_test(&self.shutdown); self.config.counters.note_raw_event(); + let cookies = self.take_cookie_paths(&mut event); if self.config.counters.bash_changes.get().is_some() && self.config.counters.bash_flush.observe(&event) { @@ -2460,6 +2469,14 @@ impl WatcherFilterThread { return; } } + for cookie in cookies { + if !self.flush_pending() + || self.send_dispatch(WatcherDispatchEvent::CookieObserved(cookie)) + == Dispatch::Closed + { + return; + } + } } Ok(Err(error)) => { if let Some(journal) = self.config.counters.bash_changes.get() { @@ -2486,6 +2503,33 @@ impl WatcherFilterThread { } } + fn take_cookie_paths(&self, event: &mut notify::Event) -> Vec { + let acknowledge = + !event.need_rescan() && matches!(event.kind, notify::EventKind::Create(_)); + let mut cookies = Vec::new(); + event.paths.retain(|path| { + if !crate::watcher_backend::cookie::is_cookie_path(path) { + return true; + } + // Only root cookies are controls. Do not unlink a similarly named + // file in an auxiliary watch or a nested directory. + if path.parent() == Some(self.config.project_root.as_path()) { + let removed = std::fs::remove_file(path).or_else(|error| { + if error.kind() == std::io::ErrorKind::NotFound { + Ok(()) + } else { + Err(error) + } + }); + if acknowledge && removed.is_ok() { + cookies.push(path.clone()); + } + } + false + }); + cookies + } + fn project_root_was_deleted(&mut self, now: Instant) -> bool { // Check on a wall-clock cadence, not once per raw event. Ignored build // bursts can otherwise turn the root-deletion fallback into a stat loop. @@ -2705,6 +2749,7 @@ impl WatcherFilterThread { .strip_prefix(&self.config.project_root) .is_ok_and(watcher_path_is_high_churn_infra) || crate::os_metadata::is_os_metadata_path(&path) + || crate::watcher_backend::cookie::is_cookie_path(&path) { continue; } @@ -2981,12 +3026,21 @@ impl WatcherFilterThread { WatcherDispatchEvent::Paths(paths) => self.hold_paths(paths, false), WatcherDispatchEvent::ContentOnlyPaths(paths) => self.hold_paths(paths, true), WatcherDispatchEvent::RescanRequired(reason) => { - self.backlog.retain(|held| !is_path_batch(held)); + // Losing the paths before a held cookie also loses its proof. + self.backlog.retain(|held| { + !is_path_batch(held) && !matches!(held, WatcherDispatchEvent::CookieObserved(_)) + }); if !rescan_held { self.backlog .push_back(WatcherDispatchEvent::RescanRequired(reason)); } } + WatcherDispatchEvent::CookieObserved(path) => { + if !rescan_held { + self.backlog + .push_back(WatcherDispatchEvent::CookieObserved(path)); + } + } WatcherDispatchEvent::IgnoreRulesChanged { paths } => match self.backlog.back_mut() { Some(WatcherDispatchEvent::IgnoreRulesChanged { paths: held }) => { merge_paths(held, paths); @@ -3036,7 +3090,9 @@ impl WatcherFilterThread { // logged and counted like an operating-system event overflow. A // rescan already running is told to run again instead. let during_rescan = self.log_overflow(RescanReason::BufferOverflow); - self.backlog.retain(|held| !is_path_batch(held)); + self.backlog.retain(|held| { + !is_path_batch(held) && !matches!(held, WatcherDispatchEvent::CookieObserved(_)) + }); if !during_rescan { self.backlog.push_back(WatcherDispatchEvent::RescanRequired( RescanReason::BufferOverflow, @@ -3149,6 +3205,116 @@ pub(crate) fn take_watcher_overflow_logs_for_test() -> Vec { #[cfg(test)] mod tests { + #[test] + fn watcher_cookie_bypasses_ignore_and_flushes_mixed_paths_before_ack() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().canonicalize().unwrap(); + let source = root.join("source.rs"); + let cookie = root.join(".aft-cookie-123-nonce"); + std::fs::write(&source, b"source").unwrap(); + std::fs::write(&cookie, b"").unwrap(); + let mut builder = ignore::gitignore::GitignoreBuilder::new(&root); + builder.add_line(None, ".aft-cookie-*").unwrap(); + let matcher = Arc::new(RwLock::new(Some(Arc::new(builder.build().unwrap())))); + let (tx, rx) = watcher_dispatch_channel(); + let (raw_tx, raw_rx) = mpsc::channel(); + let shutdown = Arc::new(AtomicBool::new(false)); + let mut filter = WatcherFilterThread::new( + WatcherFilterConfig::new(root.clone(), None), + matcher, + Arc::new(AtomicU64::new(0)), + tx, + Arc::clone(&shutdown), + ); + let worker = thread::spawn(move || filter.run(raw_rx)); + raw_tx + .send(Ok(notify::Event::new(notify::EventKind::Create( + notify::event::CreateKind::File, + )) + .add_path(source.clone()) + .add_path(cookie.clone()))) + .unwrap(); + assert_eq!( + rx.recv_timeout(Duration::from_secs(5)).unwrap(), + WatcherDispatchEvent::Paths(vec![source]) + ); + assert_eq!( + rx.recv_timeout(Duration::from_secs(5)).unwrap(), + WatcherDispatchEvent::CookieObserved(cookie.clone()) + ); + assert!(!cookie.exists()); + shutdown.store(true, Ordering::SeqCst); + drop(raw_tx); + worker.join().unwrap(); + } + + #[test] + fn watcher_cookie_unlinks_on_overflow_but_only_create_can_ack() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().canonicalize().unwrap(); + let (filter, _rx, _) = filter_with_full_dispatch_channel(&root); + let cookie = root.join(".aft-cookie-123-nonce"); + for kind in [ + notify::EventKind::Remove(notify::event::RemoveKind::File), + notify::EventKind::Any, + ] { + std::fs::write(&cookie, b"").unwrap(); + let mut event = notify::Event::new(kind).add_path(cookie.clone()); + assert!(filter.take_cookie_paths(&mut event).is_empty()); + assert!(event.paths.is_empty()); + assert!(!cookie.exists()); + } + std::fs::write(&cookie, b"").unwrap(); + let mut event = + notify::Event::new(notify::EventKind::Create(notify::event::CreateKind::File)) + .add_path(cookie.clone()) + .set_flag(notify::event::Flag::Rescan); + assert!(filter.take_cookie_paths(&mut event).is_empty()); + assert!(event.need_rescan()); + assert!(!cookie.exists()); + let nested = root.join("nested/.aft-cookie-123-nonce"); + std::fs::create_dir_all(nested.parent().unwrap()).unwrap(); + std::fs::write(&nested, b"not a root control").unwrap(); + let mut event = + notify::Event::new(notify::EventKind::Create(notify::event::CreateKind::File)) + .add_path(nested.clone()); + assert!(filter.take_cookie_paths(&mut event).is_empty()); + assert!(nested.exists()); + } + + #[test] + fn watcher_cookie_backlog_preserves_barriers_and_drops_them_with_lost_paths() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().canonicalize().unwrap(); + let (mut filter, _rx, _) = filter_with_full_dispatch_channel(&root); + filter.send_dispatch(WatcherDispatchEvent::Paths(vec![root.join("a.rs")])); + filter.send_dispatch(WatcherDispatchEvent::CookieObserved( + root.join(".aft-cookie-1-nonce"), + )); + filter.send_dispatch(WatcherDispatchEvent::Paths(vec![root.join("b.rs")])); + assert_eq!( + filter.backlog.len(), + 3, + "paths must not merge across the cookie" + ); + filter.send_dispatch(WatcherDispatchEvent::RescanRequired( + RescanReason::BufferOverflow, + )); + assert_eq!(filter.backlog.len(), 1); + assert!(matches!( + filter.backlog.front(), + Some(WatcherDispatchEvent::RescanRequired(_)) + )); + filter.send_dispatch(WatcherDispatchEvent::CookieObserved( + root.join(".aft-cookie-1-next"), + )); + assert_eq!( + filter.backlog.len(), + 1, + "a held rescan cannot certify a cookie" + ); + } + #[test] fn issue645_nested_git_and_local_manifests_get_kernel_exclusions() { use super::*; diff --git a/crates/aft/tests/integration/callgraph_worktree_test.rs b/crates/aft/tests/integration/callgraph_worktree_test.rs index f69a6d4e26..44ad676c9c 100644 --- a/crates/aft/tests/integration/callgraph_worktree_test.rs +++ b/crates/aft/tests/integration/callgraph_worktree_test.rs @@ -457,16 +457,19 @@ fn callgraph_unindexed_path_differs_from_missing_symbol() { let ctx = configure(&checkout, &storage, true); let unindexed = checkout.join("not-yet-indexed.ts"); std::fs::write(&unindexed, source("newCaller")).unwrap(); + // With views on, a new file is part of the checkout view the query binds, + // so it is answered at once rather than reported as not yet indexed. for operation in OPERATIONS { let response = callgraph_when_ready(operation, || { query_file(&ctx, &unindexed, operation, "newCaller") }); - assert_eq!( + assert_ne!( response.data["code"], "not_indexed", "{operation}: {response:?}" ); - assert_eq!(response.data["reason"], "not_in_generation"); } + let response = query_file(&ctx, &unindexed, "callers", "newCaller"); + assert!(response.success, "{response:?}"); let response = callgraph_when_ready("callers", || { aft::commands::callers::handle_callers( &request(json!({ @@ -517,55 +520,9 @@ fn callgraph_worktree_published_owner_assembles_its_own_changed_source() { std::fs::write(checkout.join("index.ts"), source("checkoutCaller")).unwrap(); commit(&checkout); let ctx = configure_deferred(&checkout, &storage, true); - let response = query(&ctx, &checkout, "callers", "checkoutCaller"); - assert_eq!(response.data["code"], "callgraph_building", "{response:?}"); - assert_eq!(response.data["index"]["callgraph"]["status"], "building"); - assert_eq!(response.data["progress"]["phase"], "configure_maintenance"); - assert_eq!(response.data["progress"]["pending_paths"], Value::Null); - let message = response.data["message"].as_str().unwrap(); - assert!( - message.contains("this worktree") && message.contains("assembling"), - "{message}" - ); - assert!( - message.contains("grep or aft_search with pattern"), - "{message}" - ); - assert!(!message.contains("main checkout"), "{message}"); - - // The worktree contributes its own source blob during maintenance. The - // owner keeps its original source and is never asked to build this branch. - let ctx = std::sync::Arc::new(ctx); - let (prepared, release, installed) = - aft::context::install_callgraph_view_install_test_gate(checkout.clone()); - let executor = aft::executor::Executor::new(); - let root_id = cortexkit_paths::ProjectRootId::from_path(&checkout).unwrap(); - executor.register_actor(root_id.clone(), ctx.clone()); - let maintenance = executor.submit( - root_id, - aft::executor::Lane::Mutating, - "worktree-maintenance".into(), - Box::new(|ctx| { - aft::runtime_drain::drain_deferred_configure_maintenance(ctx); - Response::success("worktree-maintenance", json!({})) - }), - ); - let generation = prepared - .recv_timeout(std::time::Duration::from_secs(30)) - .expect("worktree lazy callgraph generation was not prepared"); - // Configure maintenance schedules lazy publication but does not join it. - // Release the prepared graph, then await installation of that exact - // generation before asking either query path to use the worktree reader. - release.send(()).unwrap(); - assert_eq!( - installed - .recv_timeout(std::time::Duration::from_secs(30)) - .unwrap_or_else(|_| panic!( - "worktree lazy callgraph generation {generation} was not installed for {}", - checkout.display() - )), - generation - ); + // With views on, the first query binds this worktree's own checkout view + // from its own source, before configure maintenance has run: it never + // answers from the owner's graph and never reports the owner's callers. match ctx.callgraph_store_for_ops() { aft::context::CallgraphStoreAccess::Ready(store) => { assert_eq!(store.reader_kind(), "view"); @@ -579,6 +536,8 @@ fn callgraph_worktree_published_owner_assembles_its_own_changed_source() { assert!(response.data.to_string().contains("checkoutCaller")); assert!(!response.data.to_string().contains("ownerCaller")); assert!(response.data.get("borrowed_callgraph").is_none()); + assert!(ctx.callgraph_view_route_count() > 0); + aft::runtime_drain::drain_deferred_configure_maintenance(&ctx); assert_eq!( std::fs::read_to_string(owner.join("index.ts")).unwrap(), source("ownerCaller") @@ -589,12 +548,6 @@ fn callgraph_worktree_published_owner_assembles_its_own_changed_source() { owner_generation, "worktree publication must not replace the owner's view" ); - assert!( - maintenance - .recv_timeout(std::time::Duration::from_secs(30)) - .expect("worktree configure maintenance did not finish") - .success - ); assert!( aft::callgraph_store::CallGraphStore::open_readonly(ctx.callgraph_store_dir(), checkout) .unwrap() diff --git a/crates/aft/tests/integration/inspect_command_test.rs b/crates/aft/tests/integration/inspect_command_test.rs index 404361f7b2..375a866cf0 100644 --- a/crates/aft/tests/integration/inspect_command_test.rs +++ b/crates/aft/tests/integration/inspect_command_test.rs @@ -1884,141 +1884,32 @@ fn scoped_inspect_views_worktree_reports_checkout_only_dead_function() { assert!(!fs::read_to_string(owner.join("target.ts")) .unwrap() .contains("checkoutOnlyDead")); - // A view that predates another edit must not be passed off as this checkout. + // An edit after the view was bound is reflected at once: the checkout + // view is verified against the tree for every request, so an older view + // is never passed off as this checkout. write_file( &linked, "target.ts", "export function used() {}\nexport function nextDead() {}\n", ); - let pending = inspect_tool_call( + let current = inspect_tool_call( &ctx, json!({ - "id": "worktree-view-stale", "command": "inspect", "scope": "target.ts", "sections": "dead_code" + "id": "worktree-view-current", "command": "inspect", "scope": "target.ts", "sections": "dead_code" }), ); - assert_eq!( - pending["summary"]["dead_code"]["unavailable"], true, - "{pending:#}" - ); - assert!(pending["details"]["dead_code"].is_null(), "{pending:#}"); -} - -#[test] -fn inspect_views_owner_persists_and_reuses_tier2_contributions() { - let _env_lock = env_serial_lock(); - crate::helpers::disable_in_process_file_watcher(); - let (temp, root) = fixture_project(); - write_file( - &root, - "main.ts", - "import { used } from './target';\nused();\n", - ); - write_file( - &root, - "target.ts", - "export function used() {}\nexport function dead() {}\n", - ); - for args in [ - vec!["init", "--quiet"], - vec!["add", "."], - vec![ - "-c", - "user.name=Test", - "-c", - "user.email=test@example.com", - "commit", - "--quiet", - "-m", - "fixture", - ], - ] { - assert!(std::process::Command::new("git") - .current_dir(&root) - .args(args) - .status() - .unwrap() - .success()); - } - let storage = temp.path().join("storage"); - let ctx = AppContext::new( - Box::new(TreeSitterProvider::new()), - Config { - storage_dir: Some(storage.clone()), - ..Config::default() - }, - ) - .with_test_checkpoint_store(); - ctx.isolate_cold_build_limiter_for_test(2); - let configured = handle_configure( - &request(json!({ - "id": "configure-owner", "command": "configure", "harness": "opencode", "project_root": root, "storage_dir": storage, - "config": crate::helpers::user_config(json!({ "indexes": { "trigram": false, "semantic": false, "callgraph": true }, "views": {"enabled": true} })) - })), - &ctx, - ); - assert!(configured.success, "{configured:?}"); - assert!(ctx.inspect_writer()); - ctx.inspect_manager() - .set_automatic_tier2_refresh_allowed(false); - let publish = aft::views::assembly::AssemblyRequest { - storage, - project_root: root.clone(), - family: aft::search_index::artifact_cache_key(&root), - scope: aft::path_identity::project_scope_key(&root), - desired_head: aft::views::assembly::head_tree_fingerprint( - &aft::alias::head_tree_entries(&root).unwrap(), - ), - changed_paths: Default::default(), - semantic_keys: Default::default(), - require_semantic: false, - allow_blob_put: true, - callgraph: true, - }; - aft::views::assembly::publish_checkout(&publish).unwrap(); - configure_fake_scope_lsp(&ctx); - let first = inspect_tool_call( - &ctx, - json!({"id": "owner-first", "command": "inspect", "sections": ["dead_code"]}), - ); - assert_eq!(first["success"], true, "{first:#}"); - let cache = InspectCache::open_readonly(ctx.inspect_dir(), root.clone()) - .unwrap() - .expect("writer-backed owner cache"); - let contributions = cache - .load_tier2_contributions(InspectCategory::DeadCode) - .unwrap(); - assert_eq!( - contributions.len(), - 2, - "owner inspect must persist source contributions" - ); - assert!(cache - .last_full_run(InspectCategory::DeadCode) - .unwrap() - .is_some()); - let scanned_before = ctx.inspect_manager().source_scan_files_for_test(); - assert!(scanned_before > 0, "first request must reach the scanner"); - let second = inspect_tool_call( - &ctx, - json!({"id": "owner-second", "command": "inspect", "sections": ["dead_code"]}), - ); - assert_eq!(second["success"], true, "{second:#}"); - assert_eq!(dead_code_items(&first), dead_code_items(&second)); - assert_eq!( - ctx.inspect_manager().source_scan_files_for_test(), - scanned_before, - "unchanged owner inspect must quick-reuse contributions without scanning source files" + assert!( + current["summary"]["dead_code"].get("unavailable").is_none(), + "{current:#}" ); - assert_eq!( - cache - .load_tier2_contributions(InspectCategory::DeadCode) - .unwrap() - .len(), - 2 + let items = dead_code_items(¤t); + assert!( + items.iter().any(|(_, symbol)| symbol == "nextDead"), + "{current:#}" ); assert!( - ctx.inspect_manager().reuse_start_count_for_test() >= 10, - "both requests must use the contribution reuse pipeline" + !items.iter().any(|(_, symbol)| symbol == "checkoutOnlyDead"), + "{current:#}" ); } diff --git a/crates/aft/tests/integration/main.rs b/crates/aft/tests/integration/main.rs index f231b5bbb1..5d5f1110ea 100644 --- a/crates/aft/tests/integration/main.rs +++ b/crates/aft/tests/integration/main.rs @@ -195,6 +195,7 @@ mod trace_to_symbol_test; mod typescript_native_lsp_test; mod undo_path_lifecycle_test; mod url_fetch_test; +mod v3_base_refs; mod v3_isolation; mod view_assembly_wiring_test; mod vue_test; diff --git a/crates/aft/tests/integration/per_checkout_9.rs b/crates/aft/tests/integration/per_checkout_9.rs index b60480cd1d..412fb84f12 100644 --- a/crates/aft/tests/integration/per_checkout_9.rs +++ b/crates/aft/tests/integration/per_checkout_9.rs @@ -1806,178 +1806,6 @@ fn standalone_semantic_grace_and_load_never_block_requests() { assert!(aft.shutdown().success()); } -/// A view generation published with the call graph off holds no call graph. -/// Every reader of a view's call graph reports it unavailable, by name, never -/// as zero callers: here the call graph tools (and zoom's call graph field) -/// in a standalone process whose current generation was published with the -/// call graph off, while the republish that builds the graph is still -/// running. Once that republish lands, the same query answers. -#[test] -fn callgraph_ops_report_a_keyless_view_generation_as_disabled() { - let temp = tempfile::tempdir().unwrap(); - let base = std::fs::canonicalize(temp.path()).unwrap(); - let root = base.join("repo"); - let storage = base.join("storage"); - init_repo( - &root, - &[( - "src/lib.rs", - "pub fn target() -> u32 {\n 1\n}\n\npub fn caller() -> u32 {\n target()\n}\n" - .to_string(), - )], - ); - publish_without_callgraph(&root, &storage); - // The republish with the call graph waits until this file exists, so the - // generation without call graph data stays current for as long as the - // checks below take, however slow the machine. - let publication_gate = base.join("publication.release"); - let mut aft = crate::helpers::AftProcess::spawn_with_env(&[( - "AFT_TEST_VIEW_PUBLICATION_GATE_FILE", - publication_gate.as_os_str(), - )]); - let with_graph = Planes { - trigram: true, - callgraph: true, - semantic: false, - }; - let configured = aft.send( - &json!({ - "id": "cfg", - "command": "configure", - "harness": "opencode", - "project_root": root, - "storage_dir": storage, - "config": crate::helpers::user_config(config_doc(true, with_graph, None)), - }) - .to_string(), - ); - assert_eq!(configured["success"], true, "{configured:#}"); - let callers = json!({ - "id": "callers", - "command": "callers", - "file": root.join("src/lib.rs"), - "symbol": "target", - }) - .to_string(); - // Configure finishes its view setup in background steps, one after each - // request. Until it has loaded the current generation (the one without - // call graph data), callers answers `callgraph_building`; keep sending - // requests until it answers anything else. The held publication means - // that answer comes from the generation without call graph data. - let deadline = Instant::now() + DEADLINE; - let answer = loop { - aft.send(&json!({"id": "ping", "command": "ping"}).to_string()); - let answer = aft.send(&callers); - if answer["code"] != "callgraph_building" { - break answer; - } - assert!( - Instant::now() < deadline, - "configure never loaded the generation without call graph data; last answer: {answer:#}" - ); - thread::sleep(Duration::from_millis(20)); - }; - assert!( - !current_generation_has_callgraph(&root, &storage), - "the generation without call graph data is no longer current" - ); - assert_eq!(answer["success"], false, "{answer:#}"); - assert_eq!(answer["code"], "callgraph_unavailable", "{answer:#}"); - assert!( - answer["message"] - .as_str() - .unwrap() - .contains("call graph is disabled (indexes.callgraph=false)"), - "{answer:#}" - ); - let zoom = aft.send( - &json!({ - "id": "zoom", - "command": "zoom", - "file": root.join("src/lib.rs"), - "symbol": "target", - "callgraph": true, - }) - .to_string(), - ); - // Zoom's own call lists come from the file's syntax tree; its call graph - // field must say the index is not serving, not present an empty graph. - assert_eq!(zoom["callgraph"]["status"], "unavailable", "{zoom:#}"); - assert!( - zoom["callgraph"]["index"]["reason"] - .as_str() - .unwrap_or_default() - .contains("call graph is disabled (indexes.callgraph=false)"), - "{zoom:#}" - ); - // Let the republish with call graph data run. - std::fs::write(&publication_gate, b"release").unwrap(); - // Once the republish with call graph data lands, callers answers. Right - // after that publication its derived database is checkpointed, and a - // reader that meets the checkpoint gets a retryable `callgraph_building`. - // That answer is acceptable only once the published generation carries a - // call graph: the pointer is read before each request, so "building" - // while the generation without call graph data is still current fails. - let deadline = Instant::now() + DEADLINE; - loop { - let keyed_generation_published = current_generation_has_callgraph(&root, &storage); - let answer = aft.send(&callers); - if answer["success"] == true { - assert!(answer.to_string().contains("caller"), "{answer:#}"); - break; - } - if !(keyed_generation_published && answer["code"] == "callgraph_building") { - assert_eq!(answer["code"], "callgraph_unavailable", "{answer:#}"); - } - assert!( - Instant::now() < deadline, - "the call graph was never published" - ); - thread::sleep(Duration::from_millis(250)); - } - // Zoom reads the store the same way `callers` does and can meet the same - // post-publication checkpoint, which it reports as a retryable - // `callgraph_building`. Retry only on that answer; any other verdict, and - // above all `callgraph_unavailable`, for the published graph fails. - let deadline = Instant::now() + DEADLINE; - loop { - let zoom = aft.send( - &json!({ - "id": "zoom-ready", - "command": "zoom", - "file": root.join("src/lib.rs"), - "symbol": "target", - "callgraph": true, - }) - .to_string(), - ); - assert_eq!(zoom["success"], true, "{zoom:#}"); - if zoom["callgraph"].is_null() { - break; - } - assert_eq!(zoom["callgraph"]["code"], "callgraph_building", "{zoom:#}"); - assert!( - Instant::now() < deadline, - "zoom never read the published call graph: {zoom:#}" - ); - thread::sleep(Duration::from_millis(50)); - } - assert!(aft.shutdown().success()); -} - -/// Whether `root`'s current published view generation carries call graph -/// data. False while no generation is published or its manifest cannot be -/// read (a superseded generation may be collected between the two reads). -fn current_generation_has_callgraph(root: &Path, storage: &Path) -> bool { - let view = - aft::views::ViewStore::open(storage, &aft::path_identity::project_scope_key(root)).unwrap(); - let Some(generation) = view.current_generation_read_only().unwrap() else { - return false; - }; - view.load_manifest(&generation) - .is_ok_and(|manifest| !aft::views::assembly::manifest_lacks_callgraph(&manifest)) -} - /// Publishes `root`'s current view generation with the call graph off, as a /// views-on session with `callgraph_store: false` does. fn publish_without_callgraph(root: &Path, storage: &Path) { 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/per_checkout_semantic_runtime.rs b/crates/aft/tests/integration/per_checkout_semantic_runtime.rs index acc2f1af19..b40d69888e 100644 --- a/crates/aft/tests/integration/per_checkout_semantic_runtime.rs +++ b/crates/aft/tests/integration/per_checkout_semantic_runtime.rs @@ -285,7 +285,8 @@ fn drain(ctx: &AppContext) { } /// Waits until the views-on lane is loaded and every semantic file of the -/// checkout has current vectors. +/// checkout has current vectors, and the lexical lane answers from the +/// checkout's lexical view. fn wait_views_filled(ctx: &AppContext) { let deadline = Instant::now() + DEADLINE; loop { @@ -295,13 +296,7 @@ fn wait_views_filled(ctx: &AppContext) { .search(&vector("probe"), 1, &|_| true) .is_ok_and(|answer| answer.complete()) }); - let lexical = ctx - .search_index() - .read() - .unwrap() - .as_ref() - .is_some_and(|index| index.ready); - if complete && lexical { + if complete && lexical_view_serves(ctx) { return; } assert!( @@ -313,6 +308,18 @@ fn wait_views_filled(ctx: &AppContext) { } } +/// With views on, the legacy search index is never loaded: the lexical lane +/// is the checkout's lexical view, bound on demand by each query. A grep that +/// succeeds and counts as a lexical-view route shows that lane is ready. +fn lexical_view_serves(ctx: &AppContext) -> bool { + let before = ctx.lexical_view_route_counts()[0]; + let answer = aft::commands::grep::handle_grep( + &request(json!({"id": "lexical-ready", "command": "grep", "pattern": "fn"})), + ctx, + ); + answer.success && ctx.lexical_view_route_counts()[0] > before +} + fn wait_legacy_ready(ctx: &AppContext) { let deadline = Instant::now() + DEADLINE; loop { diff --git a/crates/aft/tests/integration/state_commands_test.rs b/crates/aft/tests/integration/state_commands_test.rs index 4d7f6c2726..e0297b4518 100644 --- a/crates/aft/tests/integration/state_commands_test.rs +++ b/crates/aft/tests/integration/state_commands_test.rs @@ -711,18 +711,39 @@ fn unreadable_git_bind_serves_tools_without_saving_an_identity_and_retries() { assert!(!degraded_reasons(&mut aft) .join("; ") .contains("git_identity_unavailable")); - assert_eq!(wait_for_single_built_family(storage.path()), "git"); + assert_eq!( + wait_for_single_built_family(&mut aft, storage.path()), + "git" + ); assert!(aft.shutdown().success()); } /// After a finalizing bind, wait until exactly one artifact family is owned -/// and built for the root, and return its saved identity kind. +/// and built for the root, and return its saved identity kind. A views-off +/// root builds its trigram index under `index/`; with views on the +/// legacy index is never written and the checkout's lexical view builds the +/// family's trigram plane under `blobs/v3/` instead. That view is +/// built by the first lexical query, not during configure maintenance of the +/// stdin/stdout process these tests spawn, so each wait round sends a grep. #[cfg(unix)] -fn wait_for_single_built_family(storage: &Path) -> String { +fn wait_for_single_built_family(aft: &mut AftProcess, storage: &Path) -> String { let deadline = std::time::Instant::now() + std::time::Duration::from_secs(60); loop { let owners = entry_names(storage, "artifact-owners"); - let built = entry_names(storage, "index"); + let mut built = entry_names(storage, "index"); + built.extend( + entry_names(storage, "blobs/v3") + .into_iter() + .filter(|family| { + storage + .join("blobs/v3") + .join(family) + .join("trigram.sqlite") + .is_file() + }), + ); + built.sort(); + built.dedup(); if owners.len() == 1 && built.contains(&owners[0]) { assert_eq!(built, owners, "one folder must build one index family"); let saved = saved_root_identities(storage); @@ -737,6 +758,9 @@ fn wait_for_single_built_family(storage: &Path) -> String { std::time::Instant::now() < deadline, "no index was built after the finalizing bind: owners={owners:?} index={built:?}" ); + let grep = aft + .send(&json!({"id": "build-lexical", "command": "grep", "pattern": "fn"}).to_string()); + assert_eq!(grep["success"], true, "{grep}"); std::thread::sleep(std::time::Duration::from_millis(100)); } } @@ -784,7 +808,7 @@ fn provisional_bind_then_finalize(path_pin: bool) -> String { !degraded_reasons(&mut aft).contains(&PROVISIONAL_REASON.to_string()), "a finalizing bind clears the provisional reason" ); - let kind = wait_for_single_built_family(storage); + let kind = wait_for_single_built_family(&mut aft, storage); assert!(aft.shutdown().success()); kind } @@ -831,7 +855,7 @@ fn provisional_identity_settles_without_a_second_bind_once_aft_db_frees() { ); std::thread::sleep(std::time::Duration::from_millis(100)); } - assert_eq!(wait_for_single_built_family(storage), "git"); + assert_eq!(wait_for_single_built_family(&mut aft, storage), "git"); assert!(aft.shutdown().success()); } 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-4v-frontier-audit.md b/docs/design/views-4v-frontier-audit.md new file mode 100644 index 0000000000..bf4fa4a194 --- /dev/null +++ b/docs/design/views-4v-frontier-audit.md @@ -0,0 +1,165 @@ +# Watcher delivery-frontier audit + +## Decision and scope + +This audit concerns **verified bind**, not whether ordinary watching works. A +cookie acknowledgment must mean that every relevant write completed before +cookie creation has reached the views driver as reflected data or pending work. +An empty queue, a quiet interval, or a native flush returning is not that proof. +The cookie lives directly in the checkout root and is created exclusively as +`.aft-cookie--`; it must never become corpus membership. + +The audit starts from commit `a8e301bea1eb7d2cba7ecbaccfeecc4a461c6138`. +No backend is enabled for verified bind by this slice. In particular, FSEvents +is **off pending proof**, not demonstrated incorrect. Cookie/overflow +infrastructure can be built without relaxing that restriction. E0 is the start +of a fully registered watcher subscription; F is the later cookie delivery +frontier. A delivery sequence orders invalidations at the driver, and the +durable cut is the exact prefix of those changes persisted in a publication. +Connecting those points to ready publication belongs to later verified-bind work. +Ordinary strict reconciliation remains available with its existing, weaker +per-path stability and subsequent-event-invalidation contract. + +## Shared filters, batching and the driver queue + +`watcher_filter::WatcherFilterThread` receives normalized native events over an +MPSC channel. Ordinary paths are canonicalized, deduplicated, and batched before +corpus filtering. `send_dispatch` and `retry_backlog` feed a bounded FIFO driver +channel; held batches may merge, and overflow replaces paths with a rescan. +Consequently, observing a cookie at raw intake alone cannot acknowledge a +views frontier: earlier paths may still be in either batch, backlog, channel, +or `runtime_drain`'s sliced `pending_paths`. + +The additive cookie plumbing must bypass ignore/infra filters, unlink a root +cookie immediately at intake, flush earlier paths, and enqueue a distinct +control rather than a membership path. A mixed event must retain its non-cookie +paths. Only a create event may acknowledge a ticket; unlink/metadata events must +not. The shared `watcher_backend::flush::is_marker_path` admission predicate must +also reject cookies during a filesystem walk, not just during event delivery. +The command-report `.aft-flush` acknowledgment remains a separate protocol. + +The driver-side cookie arm must record all preceding queued paths as pending +before acknowledging the exact registered cookie. A rescan must enter the +driver as `WatcherState::Overflowed` and invalidate an in-flight installation; +a subsequent ordinary path must not reset that state to Healthy. A cookie held +in a backlog whose paths are discarded must be discarded too (timeout, not a +false successful frontier). Drops coalesced during a legacy rescan also set a +pending driver-gap signal, consumed at drain entry and before a cookie ack; +legacy rescan work coalescing must not hide a gap from a newly installed driver. +This provides an ordered **delivery acknowledgment**, +not ready publication: the bind owner must still establish E0, reject any gap, +replay through the acknowledged cut, and revalidate before publication. + +The additive implementation includes regression tests named +`watcher_cookie_delayed_delivery_past_read_inotify`, +`watcher_cookie_delayed_delivery_past_read_fsevents`, and +`watcher_cookie_delayed_delivery_past_read_windows`, using a backend-delivery shim +which holds a write notification and its following cookie past a source read. +They must show no acknowledgment before release and a pending path before the +acknowledgment afterwards. They test the application queue, **not** a kernel's +completed-write coverage. Native macOS and Windows execution is delegated to CI. + +## Linux: inotify + +`inotify::ProjectWatcher::create_with_plan` creates one notify inotify instance, +collects directories, and synchronously installs non-recursive watches before +spawning its backend thread. Every per-directory descriptor belongs to that +instance's ordered queue; extra watches also use that instance. Successful +return is later than registration of the collected set, but is not yet a safe +E0: `collect_watch_directories` walks **before** the root watch is installed. +A directory created after its parent was enumerated but before the parent was +watched can be absent from the collected set without a queued create event. +The later bind must close that registration gap before recording E0, and must +not mistake a returned watcher handle for a complete subscription proof. + +For newly delivered directory creates/renames, `watch_new_tree` does install +each directory's watch before `exclusion_read_dir`. Already-populated files are +recovered by listing. `PendingNewDirectoryFiles` delays that synthetic file +batch; it must be flushed before a root cookie even when command-report tracking +is inactive. Otherwise the root cookie could overtake a recovered child write. +An independent metadata walk cannot assume the backend has already processed +every directory create: watch-before-list coordination with that walk remains +an enablement obligation of the bind implementation. + +Upstream notify's inotify queue-overflow flag is forwarded as `need_rescan`. +An incomplete/over-budget new-tree listing also emits a rescan. The filter maps +these to `RescanRequired`, and the context adapter must mark the installed +driver Overflowed before any subsequent cookie acknowledgment. Watch-limit and +other fatal backend errors terminate delivery; a disconnected/error stream +cannot certify a frontier. Retained watches are re-added without unwatching in +`reconcile_watch_directories`, but policy/boundary changes still require bind +revalidation. A single queue is necessary, not proof that every directory was +covered when a write occurred. + +## macOS: FSEvents + +`fsevents::FsEventsStream::start_paths` starts one recursive project stream with +FileEvents and waits for `FSEventStreamStart` on its run loop. There is no +per-directory watch-registration walk: a recursive stream covers newly created +descendants without adding directory handles. Excluded/cross-device descendants +are not thereby certified. A later bind can record E0 only after successful +startup and coverage/root/policy validation, not before the startup handshake. + +The callback translates in native array order, hands off one vector, and advances +its replay cursor only after handoff. The backend forwards each vector in order +to the filter. Stream replacement replays from the old delivered event ID rather +than SinceNow; a missing/wrapped cursor requires a rescan. MustScanSubDirs, +UserDropped and KernelDropped all emit `Flag::Rescan` in `translate_event`; +those controls must survive filters and mark the driver Overflowed. + +FSEvents is **off: completed-write-before-cookie coverage through the views +queue is unproved**. Auxiliary streams are independently ordered +(`fsevents.rs:163-164` at the audited commit). FlushSync covers only events +already in the pipeline (`fsevents.rs:214-222`), not all completed filesystem +writes. Preserving callback/event-ID order does not by itself close that gap. +No root cookie certifies an independently ordered auxiliary stream. No +quiet-window substitute is accepted. + +The later native test required to turn it on is +`fsevents_completed_writes_before_cookie_reach_views_frontier`: on a writable +same-device local root, establish E0, hold delivery beyond the stat/content read, +complete writes in multiple existing and newly created descendants, create the +cookie, and prove every pre-cookie write reflected or pending through the exact +views delivery cut before ready publication. Exercise coalesced callbacks, +stream handoff, and drops (drops must refuse verified bind); separately fence +auxiliary streams or exclude their state from the frontier claim. The shim test +above is necessary application-ordering evidence, not this native proof. + +## Windows: ReadDirectoryChangesW + +`windows::ProjectWatcher::start` blocks on `start_rx` until `open_watches` has +opened and submitted every initial read. `project_watch_targets` includes one +recursive root handle and separate non-recursive extra-path/parent handles. +Each `DirectoryWatch` has its own ordered notification buffer and pending read. +The recursive root handle already covers newly created directories; there is no +per-child registration/listing seam within that handle. E0 must follow all read +submissions and root/target validation. + +`run_completion_loop` rearms a read before translating its completed buffer, and +forwards records in buffer order. A completion port merges independent handles; +it does not give their writes a shared filesystem ordering. A root cookie +certifies neither an earlier undelivered extra-handle completion nor handle +replacement. Single-recursive-handle checkout-only coverage is a possible later +restriction, not a proof supplied by this slice. + +ERROR_NOTIFY_ENUM_DIR and successful zero-byte completions produce the typed +buffer-overflow rescan. These must mark the driver Overflowed; other fatal +completion errors end the stream and invalidate any bind. DirectoryWatcher +replacement drops its old watcher before starting the next; no verified bind +may span that registration gap. Native completed-write coverage (including +last-write/close semantics) and multi-handle fencing remain unproved. + +## Verified-bind on/off table + +These are enablement decisions, not changes to ordinary watcher selection. + +| Backend / root | Verified bind | Reason / remaining obligation | +| --- | --- | --- | +| Linux inotify, writable local same-device root | **Off pending coverage proof** | Initial collect-before-watch gap; coordinate new-directory registration before subscription E0 and during the bind walk through cookie frontier F. One instance plus ordered cookies alone is insufficient. | +| macOS FSEvents, writable local same-device root | **Off pending proof** | Completed-write-before-cookie coverage through views queue unproved; independent auxiliary streams; FlushSync only covers events already in pipeline. Native turn-on test named above belongs to later verified-bind work. | +| Windows native recursive root plus auxiliary handles | **Off pending proof** | Ordered buffers are per handle, not a global frontier; native completed-write coverage and handle lifecycle must be proved. | +| notify fallback; polling; any other unproved backend | **Off** | No proved ordered completed-write frontier. | +| Network filesystems (including NFS/SMB); FAT | **Off** | Backend/local timestamp and delivery assumptions do not apply. | +| Read-only roots | **Off** | Cannot create an exclusive cookie. | +| Cross-mount or cross-device subtrees | **Off for the subtree** | A root stream/cookie does not prove coverage on another mount/device. | +| Cookie timeout, watcher loss, root/policy change, unregistered directory | **Off for that bind** | Strict reconcile; never treat silence as continuity. | diff --git a/docs/design/views-v3-format.md b/docs/design/views-v3-format.md index 97eb07ad19..36072d87f9 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,73 @@ 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-`. A second, tree-keyed + base kind (`base-t1-`) is defined in *Tree-keyed bases and blob receipts* + below; a scope is never relabelled from one kind to the other. +- **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**. @@ -537,8 +610,11 @@ contract is [views-slice4-opening.md](views-slice4-opening.md). - The implemented registry remains `views_v3_registry` format/schema **1/1**. Reserve **2/2** for slice 4's base-reference protocol, **3/3** for 4v's token - and stat-evidence protocol, **4/4** for slice 5's donor-seal protocol, and - **5/5** for 6a's symbol/import protocol. These versions gate the whole member + and stat-evidence protocol and **4/4** for slice 5's donor-seal protocol. + Version 5/5, earlier reserved for slice 6a's symbol and import protocol, is + **not used**: the import ledger is a sidecar file and the symbol plane is an + ordinary plane store (see *Import ledger and symbol plane* below), so neither + changes the registry schema. These versions gate the whole member protocol, including its sidecars; a version can advance without adding a registry table. An owning slice installs its complete schema through an explicit versioned upgrade, never a @@ -562,10 +638,11 @@ contract is [views-slice4-opening.md](views-slice4-opening.md). registry-floor version that makes unsupported readers refuse. - Every artifact publication uses the combined v3 floor boundary. Slice 4 requests registry floor 2 before its artifacts; 4v requests 3 before stat - evidence or tokens; 5 requests 4 before donor/seal publication; 6a requests 5 - before **either** symbol-plane or import-ledger publication. Every publisher - also requests store floor 1. These are future written versions, not changes - to today's startup baseline. + evidence or tokens; 5 requests 4 before donor/seal publication. Slice 6a + writes its symbol plane and import ledger only inside + `blob_store::v3::publish`, which requests registry floor 1 and store floor 1 + before creating either file. Every publisher also requests store floor 1. + These are future written versions, not changes to today's startup baseline. ### Assigned paths and owners @@ -578,10 +655,58 @@ contract is [views-slice4-opening.md](views-slice4-opening.md). | 4 | Plane-row sweep over `F/{semantic,callgraph,trigram}.sqlite`; progress at `retention/cursors/v3-plane-rows/.json` | Store 1/1; cursor 1; registry 2/2 for sweep coordination | Implement in `gc/family.rs` and `storage_retention.rs`. Mark the composed depth-one closure, including overrides, then delete only unmarked eligible rows under the epoch/lease protocol. Bound the producing iterator and deadline; resume rather than treating a partial scan as complete. Include later registered planes when they exist. | | 4 | `DiskBudget` wiring in `crates/aft/src/views/eviction.rs`, using `F/members.sqlite` reservations/accounting and the existing namespace inventory | Registry 2/2; no separate budget file or new config format | Preserve allocated-byte charging, the 2 GiB family target, 4 GiB hard/namespace limits and max(4 GiB, 5%) device floor. Refuse with the named storage gap and no legacy fallback. | | 4v | `M/stat-evidence-.sqlite` | Stat-evidence 1/1, registry floor 3 | Generation-paired path/root/file identity, size/times, stable content digest, proven Git OID if any, observation-time conversion-policy digest and per-plane keys. No stat-cache or current-policy-only proof. | +| 4v | `M/tree-proof.sqlite` in a `base-t1-` member | Tree proof 1/1, registry floor 3 | Full-index tree descriptor proven from the coherent index and counted `rev-parse HEAD^{tree}`, plus one byte-bound Git-blob receipt per base member. Never a header string or a stat-cache tuple. See *Tree-keyed bases and blob receipts*. | | 4v | `F/members.sqlite`: `view_tokens` rows | `views_v3_registry` 3/3 | Single-use advisory token bound to identity at first consumption, with expiry and consume state. Stored in daemon storage, never the checkout. Stat evidence and tokens do not replace freshness validation. | | 5 | `M/donor--.seal.json` beside `M/donor--.sqlite` | Donor seal 1, registry floor 4 | Atomically publish fingerprint, size and `journal_mode=DELETE` only after the detached donor is checkpointed and synced and has no WAL/SHM. A published manifest names only a completed seal. Never copy/clone a live SQLite file set. | -| 6a | `F/symbol.sqlite` | `views_v3_store` 1/1, new-store layout 2; registry floor 5 | The v3 symbol plane uses the same immutable `payload`/`payload_digest` contract and reservation protocol. No in-place legacy conversion. | -| 6a | `F/members.sqlite`: `imports` rows | `views_v3_registry` 5/5 | Record completion/rejection per artifact, compatible producer and destination plane, with owner-token claim/recovery semantics. Import into v3 only, stream inputs above 1 GiB and retain legacy bytes. | +| 6a | `F/symbol.sqlite` | `views_v3_store` 1/1, new-store layout 2; store floor 1 | The v3 symbol plane uses the same immutable `payload`/`payload_digest` contract and reservation protocol. No in-place legacy conversion. Implemented; see *Import ledger and symbol plane*. | +| 6a | `F/imports.sqlite` (sidecar, not a registry table) | Import ledger 1/1, `application_id` `0x41465449`; registry stays 1/1 or 2/2 | Record completion/rejection per legacy key, artifact, compatible producer and destination plane, with owner-token claim/recovery semantics. Import into v3 only, stream inputs above 1 GiB and retain legacy bytes. Implemented; see *Import ledger and symbol plane*. | + +#### Stat evidence and advisory tokens + +Registry 3/3 is an exact-predecessor upgrade of 2/2 under the v3 reservation +barrier, after raising only `views_v3_registry` to 3 and `views_v3_store` to 1. +It adds `view_tokens(nonce TEXT PRIMARY KEY, root BLOB NOT NULL, +expected_tree TEXT NOT NULL, minted_ms INTEGER NOT NULL, expires_ms INTEGER NOT NULL, +bound_root BLOB, bound_gitdir BLOB, consumed_ms INTEGER)`. Root is the canonical +future checkout path. The two bound identity blobs and consume time are all +NULL at mint and all set in one first-consumption transaction. A consumed row +is retained until expiry so replay and identity mismatch can be distinguished; +neither can authorize verification or publication. The lifetime is ten minutes. +Identity blobs are versioned JSON, not a Git stat-cache tuple. Older registry +readers supporting at most 2 refuse registry 3; v1/v2 store formats are unchanged. + +Stat-evidence 1/1 uses `application_id=0x41465445`, `PRAGMA user_version=1`, and +`evidence_meta(singleton INTEGER PRIMARY KEY CHECK(singleton=1), +format_version INTEGER NOT NULL, schema_version INTEGER NOT NULL, +generation TEXT NOT NULL, root BLOB NOT NULL, policy TEXT NOT NULL)` plus +`stat_evidence(path BLOB PRIMARY KEY, record BLOB NOT NULL)`. Records are +versioned JSON containing root/file identity, size, mtime, ctime or Windows +ChangeTime, observation time, content digest, an optional proven Git OID, +the policy digest paired with that observation, and per-plane keys. The file +is staged and synced before the generation pointer CAS and is reusable only +when its generation, root, policy, file identity, timestamps and racy checks +all agree. Missing, corrupt or future evidence means reading source again. +Evidence does not upgrade membership-keyed bases to tree-keyed identities. + +Evidence staging is `M/.stat-evidence-.sqlite.tmp.` (and +SQLite's `-journal`, `-wal`, `-shm` suffixes if present), so a crashed write has +an attributable generation. The prepared-generation pin is acquired before +the manifest or evidence is exposed and remains held through publication. +The existing generation sweep discovers the permanent database and these +temporaries and retires them in the same pin-checked claim as the generation; +its existing moved-file/unlink accounting includes their bytes. Current and +pinned generations retain evidence and staging regardless of age. Abandoned +staging is removed only when its generation retires, never by a second sweep +or by a timer that can bypass a live pin. + +Policy capture uses counted `git config --list --show-origin -z` **and** +`git var GIT_ATTR_SYSTEM` / `git var GIT_ATTR_GLOBAL`, rather than guessing +an installation prefix for attribute sources. This supersedes the earlier +config-only query wording in the views design. Every successful spawn, +including an unsupported-variable exit, contributes to `git_subprocesses` +in the process work counters. Either source query failing disables stat reuse +and takes strict reconciliation. Measurements of these additional queries +belong to the later performance gate. The registry-version increments above are serial handoffs, not permission for an earlier slice to write a later owner's rows. Owners implement and test their @@ -593,3 +718,226 @@ and member retention remain unchanged. Slice 4's binder must honour the `evicting` contract above: when it finds its member in `evicting`, it never uses that view directory, and either treats the member as absent and publishes a fresh one or waits for the eviction to finish. + +#### Tree-keyed bases and blob receipts + +Source: `git_index_reader.rs` (tree computation and blob object IDs), +`views/base_refs.rs` (identity), `views/tree_base.rs` (builder), +`views/verification.rs` (proof file and bind-time checks). + +**Identity.** A base manifest whose header carries `head_tree` is a +tree-keyed base; one without it keeps the membership identity above, +unchanged. A tree-keyed manifest has only regular entries, each with a +`git_oid`, and no `base` header, tombstones or overrides. Its tree-membership +digest is BLAKE3 over the length-prefixed tag +`aft/views-v3/base-tree-membership/1`, the entry count, then per path (in path +order) the path bytes, kind byte 1, mode, content hash, size, +resolution-input flag and the `git_oid` text. Every OID and the tree are +lowercase hex of one length (40 for SHA-1, 64 for SHA-256). The identity digest +(tag `aft/views-v3/base-identity-tree/1`) covers family, all producers, the +ignore fingerprint, the platform family, the object format name (`sha1` or +`sha256`), `head_tree:` and the tree-membership digest. The scope is +`base-t1-`. The membership digest still refuses any manifest +with `head_tree` or a `git_oid`, and membership resolution still requires the +`base-m1-` prefix. + +The identity only names the tree a base claims. Header fields never prove it. +Trust needs the proof file below and a live check against Git at bind time. +Without both, a tree-keyed base is treated like a membership base: every path +takes the full check. + +**Older readers.** A tree-keyed base is published only after the registry floor +is raised to 3, so readers that support at most registry 2 refuse the domain. +A registry-3 reader without this section refuses the scope: membership +resolution rejects the `base-t1-` prefix, and `membership_digest` rejects the +manifest. Such a reader never authenticates a tree-keyed base as +membership-keyed. Its sweep records the base's edges as `base_corrupt` (strict +rebuild for those workers) and can then reclaim the base as unreferenced. That +costs a rebuild in a mixed-version fleet; it does not change any answer. + +**Builder.** The claim owner reads a coherent index snapshot. It computes the +full index tree in Git's object format and compares it with a counted +`git rev-parse HEAD^{tree}`. The computation refuses unmerged stages, +assume-valid, skip-worktree and intent-to-add entries, and duplicate paths. It +also refuses file/directory conflicts, empty, `.` or `..` components, any +component equal to `.git` (ASCII case-insensitive), and modes other than +100644, 100755, 120000 and 160000. Split and sparse indexes were already +refused by the reader. Every member is a walker-admitted path with a stage-0 +regular index entry. It is read with the same native stat before and after +the read. The bytes are hashed as BLAKE3 content and as Git's +`blob \0` object. The blob OID must equal the index entry's. On +unix the executable bit must agree with the index mode. A failure of any check +for any member means no tree-keyed base: the bind builds a membership base as +before. The conversion policy must be reusable (see *Stat evidence*). The +receipt pairs the policy digest captured for this build with that read, never +with an older index tuple. + +**Proof file `M/tree-proof.sqlite`.** One per tree-keyed base member. The +base's identity and membership never change across readiness successors, so +one proof serves every generation of the scope. It is written once by the +claim owner, after the member is registered and the registry floor is 3, and +before the first publication. After that it is never rewritten. +`application_id=0x41465454`, `PRAGMA user_version=1`, `journal_mode=DELETE`: + +```sql +CREATE TABLE proof_meta(singleton INTEGER PRIMARY KEY CHECK(singleton=1), + format_version INTEGER NOT NULL, schema_version INTEGER NOT NULL, + base_scope TEXT NOT NULL, identity_digest TEXT NOT NULL, membership_digest TEXT NOT NULL, + object_format TEXT NOT NULL, head_tree TEXT NOT NULL, root BLOB NOT NULL, + policy TEXT NOT NULL, receipts_digest TEXT NOT NULL); +CREATE TABLE blob_receipts(path BLOB PRIMARY KEY, record BLOB NOT NULL); +``` + +Format/schema are 1/1. `root` is the versioned JSON root identity used by stat +evidence. A receipt record is versioned JSON (`version: 1`): mode, size, +content digest, Git blob OID, the native stat observed around the read, +observation time (rounded down to the second), policy digest and delivery +sequence. `receipts_digest` is BLAKE3 over the tag `aft/views-v3/tree-receipts/1`, +the receipt count, then each path and its record bytes, length-prefixed and in +path order. + +Staging is `M/.tree-proof.sqlite.tmp.`. It is synced, renamed into place +and followed by a directory sync. A failed writer removes its own staging. A +crash can leave one staging file, which stays until the member is reclaimed. +The generation sweep does not match either name. Both are member files, so +the existing per-member accounting counts them, and whole-base reclaim removes +them. A reader reads the proof only while it holds the authenticated base's +exact-generation read marker. + +**Bind-time check.** A worker whose durable generation names a `base-t1-` base +resolves that base (pin-then-verify) and reads the proof. The bind refuses the +proof unless all of these hold: + +- the file's application ID, version and format/schema are as above; +- `receipts_digest` matches the stored receipts; +- scope, identity and membership digests equal the authenticated base; +- head tree and object format equal the base manifest; +- the root identity and policy digest equal the current bind guard, and the + current policy is reusable; +- every base member has exactly one receipt, and each receipt's mode, size, + content and OID equal its manifest entry. + +Git must then agree now. The guard's counted `rev-parse HEAD^{tree}` must name +the proof's tree. A coherent index read now must compute that same tree. Each +receipt's path must have a stage-0 index entry with no exception flag, the +same mode and the receipt's OID. A receipt that passes is only a candidate. +The stat-evidence reuse rules still decide each path: equal native stat and +file identity, mtime before the observation second, no watcher delivery for +the path, matching disk state, and ready, present plane keys. Any failed check +means that path, or with a refused proof every path, takes the full check. + +## Import ledger and symbol plane + +Sources: `crates/aft/src/migration/v3_import.rs` (decoders, staging, +registration), `crates/aft/src/migration/v3_ledger.rs` (ledger and driver), +`crates/aft/src/migration/v3_symbols.rs` (symbol importer) and +`crates/aft/src/views/symbol_store.rs` (symbol plane). Imports write only +under `blobs/v3//` and their own staging directory; they never write +a v1 or v2 row, manifest, pointer or schema, and never move, change or delete +a legacy file. + +### Symbol plane `F/symbol.sqlite` + +- An ordinary v3 plane store (`views_v3_store` 1/1, layout 2, `store_meta.plane + = 'symbol'`), opened and written through the same `blob_store::v3` + connections, `Publication` and `PlaneReader` as the other planes: + `FamilyPlane::Symbol`. It is a v3-only plane: it is not in + `FamilyPlane::ALL` (which manifests, v2 stores, v2 GC and eviction + iterate) but is in `FamilyPlane::V3_STORES`, which the v3 plane-row sweep + and `v3_format_report` use. No manifest entry records symbol readiness. +- Key: BLAKE3 over the tag `aft/views-v3/symbol/v1`, then the content hash + (32 bytes), the language id (the parser's `LangId` name, for example + `Rust`) and the producer id, each preceded by its + length as a big-endian u64. The producer id is + `aft-symbols/extraction-` (`symbol_cache_disk::SCHEMA_VERSION`, + bumped on any grammar, query or extractor change), so a parser or schema + change produces a different key and misses. A source hash alone is never + a key. +- Payload (payload schema 1): `u8` layout 1, then three length-prefixed + (`u32` LE) fields: producer id, language id, and the symbols as the JSON + array `symbols.bin` stores. A reader checks the layout, producer and + language against its key's inputs before decoding. +- **GC.** Manifests never name symbol rows, so the sweep does not use marking + for them: it deletes a symbol row only when its `ref_epoch` is more than + `SYMBOL_RETENTION_SWEEPS` (8) sweep epochs old. A lookup that uses a row + touches it inside a publication, which restamps it. +- **Residency.** Decoded symbol lists are shared as `Arc>` + through a process-wide cache in `views/symbol_store.rs`, bounded at 64 MiB + of estimated bytes; eviction from it never touches stored rows. +- **Floor and accounting.** Written only inside `v3::publish` (registry floor + 1, store floor 1, a reservation per batch); its bytes are part of the + family's namespace inventory like any plane store. + +### Import ledger `F/imports.sqlite` + +A sidecar SQLite file, not a registry table: `members.sqlite` and its +version are unchanged. It is created, and every row transition is written, +inside a `v3::publish` call (64 KiB reservation), so the file exists only +after the v3 floor is raised and each write holds the reservation lock. +Readers (status, prune eligibility, retention) use this process's ledger +connection when it has one; otherwise they copy the database and its WAL to a +private temporary directory and read the copy, so a read never writes under +the storage root. `aft cache prune-legacy` reads `members.sqlite` the same way. + +```sql +CREATE TABLE format_meta (singleton INTEGER PRIMARY KEY CHECK(singleton=1), + format_version INTEGER NOT NULL, schema_version INTEGER NOT NULL, schema_text TEXT NOT NULL); +CREATE TABLE imports (legacy_key TEXT NOT NULL, artifact TEXT NOT NULL, + producer TEXT NOT NULL, plane TEXT NOT NULL, + state TEXT NOT NULL CHECK(state IN ('claimed','staged','validated','registered','done','rejected')), + attempt INTEGER NOT NULL CHECK(attempt>=1), owner_pid INTEGER, owner_start INTEGER, owner_token TEXT, + source_hash BLOB, source_len INTEGER, staged_hash BLOB, + imported INTEGER NOT NULL DEFAULT 0, skipped INTEGER NOT NULL DEFAULT 0, + reason TEXT, updated_ms INTEGER NOT NULL, completed_ms INTEGER, + PRIMARY KEY(legacy_key, artifact, producer, plane)); +``` + +Format/schema 1/1, `application_id` `0x41465449` ("AFTI"), WAL, +`synchronous=FULL`. A newer format is refused by name. `artifact` is +`trigram` (`index//cache.bin`), `semantic` (`semantic//semantic.bin`) +or `symbols` (`symbols//symbols.bin`); `producer` is the trigram policy +fingerprint, the semantic producer id or the symbol producer id; `plane` is +the destination plane. A row completes only its own (artifact, producer, +plane): a semantic-only completion never suppresses a trigram or symbol +import, and a producer change starts a new row. + +- **States.** `claimed → staged → validated → registered → done`, or + `rejected{reason}` from any non-terminal state. Each transition is one + `BEGIN IMMEDIATE` compare-and-set on the row's state and owner token. A + live owner makes other callers back off; a dead or released owner's row is + taken over with `attempt + 1` and resumes from its durable state, + repeating at most one idempotent step. +- **staged:** the canonical conversion is streamed to + `/migration/v3///--.bundle` (temp, fsync, rename, directory sync), with the + legacy source's BLAKE3 and length recorded. Old binaries never look under + `migration/v3`. A bundle is a magic `AFTIMP03`, the artifact byte, entries + (`u8` 1, path, content hash, size, key, payload), then `u8` 0, the entry + count and a BLAKE3 of every earlier byte. +- **validated:** the bundle is re-read and every key re-derived, and the + legacy source is re-hashed and must still be the converted bytes. +- **registered:** every payload is in the destination plane, written in + batches of at most 32 MiB, each one `v3::publish` with a reservation of + twice its bytes plus 1 MiB. A refused reservation reports the plane's + named storage gap `storage_gap:` (`v3_namespace_budget`, + `device_free_space_floor`, `reservation_ledger_unreadable`); the row keeps + its state with the owner released and the reason recorded, so a later + run resumes. Nothing falls back to a legacy reader. +- **done:** the staging bundle is removed. + +Inputs have no size limit. The decoders stream: the trigram `cache.bin` +is checksummed in one sequential pass, its postings are counted per file in +one pass over the posting blob, then inverted in file-id ranges whose +postings fit a working-set budget (64 MiB by default), one more pass per +range; `semantic.bin` is decoded one file's rows at a +time. Memory is bounded by the per-file tables (path, size and hash per +file) plus the budget, never by the input size. The `semantic.bin` decoder +is read-only and refuses trailing bytes (appended segments or a torn tail) +rather than repairing or folding them; a path whose rows are not +contiguous is skipped and left for the plane to embed. + +Imported trigram and semantic rows are not named by any manifest until a +base or worker build reuses them (`contains`, then `put_or_touch`). A v3 +plane-row sweep that runs between the import and that build may collect +them; the build then recomputes those files. Imported symbol rows follow the +symbol plane's epoch rule above. diff --git a/packages/aft-bridge/src/__tests__/view-token.test.ts b/packages/aft-bridge/src/__tests__/view-token.test.ts new file mode 100644 index 0000000000..c067978af4 --- /dev/null +++ b/packages/aft-bridge/src/__tests__/view-token.test.ts @@ -0,0 +1,29 @@ +import { expect, test } from "bun:test"; +import type { AftProjectTransport } from "../transport.js"; +import { mintViewToken } from "../view-token.js"; + +test("view token uses a management request, not an agent tool", async () => { + const calls: unknown[] = []; + const transport = { + send: async (...args: unknown[]) => { + calls.push(args); + return { success: true, token: "opaque", expires_ms: 100, advisory: true }; + }, + } as unknown as AftProjectTransport; + expect(await mintViewToken(transport, "/future/root", "abc")).toEqual({ + token: "opaque", + expires_ms: 100, + advisory: true, + }); + expect(calls).toEqual([["view_token", { root: "/future/root", expected_tree: "abc" }]]); +}); + +test("view token refuses a non-advisory or failed response", async () => { + for (const response of [ + { success: false, code: "VIEW_TOKEN", message: "views disabled" }, + { success: true, token: "opaque", expires_ms: 100, advisory: false }, + ]) { + const transport = { send: async () => response } as unknown as AftProjectTransport; + await expect(mintViewToken(transport, "/future/root", "abc")).rejects.toThrow(); + } +}); diff --git a/packages/aft-bridge/src/index.ts b/packages/aft-bridge/src/index.ts index 4a843b03f5..8881db863f 100644 --- a/packages/aft-bridge/src/index.ts +++ b/packages/aft-bridge/src/index.ts @@ -468,6 +468,7 @@ export { subcConnectionFileError, } from "./transport-factory.js"; export { readBinaryVersionOffThread } from "./version-probe.js"; +export { mintViewToken, type ViewToken } from "./view-token.js"; // --- aft_zoom plain-text formatter (shared by both plugin hosts) --- export type { RustZoomBatchEntry, diff --git a/packages/aft-bridge/src/view-token.ts b/packages/aft-bridge/src/view-token.ts new file mode 100644 index 0000000000..50e3950657 --- /dev/null +++ b/packages/aft-bridge/src/view-token.ts @@ -0,0 +1,37 @@ +import type { AftProjectTransport } from "./transport.js"; + +export interface ViewToken { + token: string; + expires_ms: number; + advisory: true; +} + +/** Mint a provisioning hint. The daemon still verifies every checkout bind. */ +export async function mintViewToken( + transport: AftProjectTransport, + root: string, + expectedTree: string, +): Promise { + const response = await transport.send("view_token", { + root, + expected_tree: expectedTree, + }); + if (!response.success) { + throw new Error( + typeof response.message === "string" ? response.message : "View token mint failed", + ); + } + const data = response; + if (typeof data !== "object" || data === null) { + throw new Error("Invalid advisory view token response"); + } + const value = data as Record; + if ( + typeof value.token !== "string" || + typeof value.expires_ms !== "number" || + value.advisory !== true + ) { + throw new Error("Invalid advisory view token response"); + } + return { token: value.token, expires_ms: value.expires_ms, advisory: true }; +}