From bc936a1e6c6cf76d80220e4668bd730732de2a43 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Mon, 26 Oct 2026 16:09:43 +0100 Subject: [PATCH 001/100] mason: v3 registry 2/2 base-reference schema and Windows free-space probe Adds the base_refs, base_builds, strict_rebuild, base_sweeps and sweep_state tables as registry format/schema 2/2 (reserved for slice 4 in docs/design/views-v3-format.md), installed only by an exact-predecessor upgrade from 1/1 after the registry reader floor is raised to 2. Registries created by the 3a copy path stay 1/1. views_v3_registry is now advertised as readable at 2, so reader_floor's advertisement test and the v3 newer-format refusal test (which used 2 as 'newer') move to 2 and 3 respectively. Also: barrier-only registry opens (no covering measurement), a plane-row sweep over the family's v3 plane stores, LedgerReader member/read helpers, and GetDiskFreeSpaceExW for the non-unix free-space probe, which 3a left unsupported. Co-authored-by: alfonso-aft <289616620+alfonso-aft@users.noreply.github.com> --- crates/aft/src/blob_store/v3.rs | 344 +++++++++++++++++++++++++- crates/aft/src/blob_store/v3/tests.rs | 8 +- crates/aft/src/persisted_format.rs | 3 +- crates/aft/src/reader_floor.rs | 10 +- 4 files changed, 352 insertions(+), 13 deletions(-) diff --git a/crates/aft/src/blob_store/v3.rs b/crates/aft/src/blob_store/v3.rs index ea4e4dc7b0..9f53cd3e10 100644 --- a/crates/aft/src/blob_store/v3.rs +++ b/crates/aft/src/blob_store/v3.rs @@ -42,7 +42,15 @@ mod replay; pub use copy::{copy_on_first_use, CopyOutcome, CopySource, SWEEP_LEASE_WAIT}; const META: &str = "CREATE TABLE format_meta (singleton INTEGER PRIMARY KEY CHECK(singleton=1), format_version INTEGER NOT NULL, schema_version INTEGER NOT NULL, layout_tag INTEGER NOT NULL, schema_text TEXT NOT NULL);"; -const REGISTRY_SCHEMA: &str = " +/// Registry format/schema version of the base-reference protocol +/// (`views_v3_registry` 2/2 in docs/design/views-v3-format.md). A version-1 +/// registry is upgraded in place, by the exact-predecessor migration in +/// [`RegistryWriter::upgrade_to_base_refs`], only by a writer that raised the +/// registry reader floor to 2 first. +pub const REGISTRY_BASE_REFS_VERSION: u32 = 2; +macro_rules! registry_v1_schema { + () => { + " CREATE TABLE members ( scope TEXT NOT NULL PRIMARY KEY, kind TEXT NOT NULL CHECK(kind IN ('view','base')), root BLOB, view_dir TEXT NOT NULL, registered_at_ms INTEGER NOT NULL, @@ -57,7 +65,34 @@ CREATE TABLE reservations (id INTEGER PRIMARY KEY AUTOINCREMENT, charge_bytes INTEGER NOT NULL CHECK(charge_bytes>=0), completed_seq INTEGER); CREATE TABLE accounting (singleton INTEGER PRIMARY KEY CHECK(singleton=1), measured_bytes INTEGER NOT NULL, watermark INTEGER NOT NULL); -"; +" + }; +} +macro_rules! base_refs_schema { + () => { + "CREATE TABLE base_refs (worker_scope TEXT NOT NULL, base_scope TEXT NOT NULL, nonce TEXT NOT NULL, + membership_digest TEXT NOT NULL, worker_generation TEXT, + state TEXT NOT NULL CHECK(state IN ('intent','orphaned','published')), + owner_pid INTEGER NOT NULL, owner_start INTEGER NOT NULL, updated_epoch INTEGER NOT NULL, + PRIMARY KEY(worker_scope, base_scope, nonce)); +CREATE INDEX base_refs_by_base ON base_refs(base_scope); +CREATE TABLE base_builds (base_scope TEXT NOT NULL PRIMARY KEY, + owner_pid INTEGER NOT NULL, owner_start INTEGER NOT NULL, owner_token TEXT NOT NULL, + attempt INTEGER NOT NULL CHECK(attempt>=1), + state TEXT NOT NULL CHECK(state IN ('building','serving')), heartbeat_at_ms INTEGER NOT NULL); +CREATE TABLE strict_rebuild (worker_scope TEXT NOT NULL, worker_generation TEXT NOT NULL, + reason TEXT NOT NULL, recorded_epoch INTEGER NOT NULL, PRIMARY KEY(worker_scope, worker_generation)); +CREATE TABLE base_sweeps (base_scope TEXT NOT NULL PRIMARY KEY, + unreferenced_sweeps INTEGER NOT NULL DEFAULT 0); +CREATE TABLE sweep_state (singleton INTEGER PRIMARY KEY CHECK(singleton=1), + gc_epoch INTEGER NOT NULL, lease_pid INTEGER, lease_start INTEGER, reconcile_cursor TEXT, + dangling_base_edges INTEGER NOT NULL DEFAULT 0, live_base_cap_hits INTEGER NOT NULL DEFAULT 0); +" + }; +} +const REGISTRY_SCHEMA: &str = registry_v1_schema!(); +const BASE_REFS_SCHEMA: &str = base_refs_schema!(); +const REGISTRY_SCHEMA_V2: &str = concat!(registry_v1_schema!(), base_refs_schema!()); const PLANE_SCHEMA: &str = " CREATE TABLE store_meta (singleton INTEGER PRIMARY KEY CHECK(singleton=1), plane TEXT NOT NULL, gc_epoch INTEGER NOT NULL); CREATE TABLE blob_payloads (full_key BLOB NOT NULL PRIMARY KEY CHECK(length(full_key)=32), @@ -185,7 +220,39 @@ fn probe_free_space(path: &Path) -> Option<(u64, u64)> { (stat.f_blocks as u64).saturating_mul(stat.f_frsize as u64), )) } -#[cfg(not(unix))] +#[cfg(windows)] +fn probe_free_space(path: &Path) -> Option<(u64, u64)> { + use std::os::windows::ffi::OsStrExt; + // As on unix: probe the nearest existing ancestor without creating it. + let mut existing = path; + while !existing.exists() { + existing = existing + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .unwrap_or(Path::new(".")); + } + let wide: Vec = existing + .as_os_str() + .encode_wide() + .chain(std::iter::once(0)) + .collect(); + let mut available = 0u64; + let mut total = 0u64; + let mut total_free = 0u64; + // SAFETY: `wide` is NUL-terminated and the three out-pointers name live + // u64 locals for the duration of the call. + let ok = unsafe { + windows_sys::Win32::Storage::FileSystem::GetDiskFreeSpaceExW( + wide.as_ptr(), + &mut available, + &mut total, + &mut total_free, + ) + }; + // `available` honours per-user quotas, like statvfs's f_bavail. + (ok != 0).then_some((available, total)) +} +#[cfg(not(any(unix, windows)))] fn probe_free_space(_path: &Path) -> Option<(u64, u64)> { None } @@ -407,7 +474,13 @@ fn validate(c: &Connection, path: &Path, kind: Kind) -> Result<()> { |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)), ) .map_err(|_| incompatible(kind, path, version, IncompatibleReason::SchemaDrift))?; - let Some(compiled_schema) = kind.schema_for(layout) else { + let compiled_schema = match (kind, version, schema) { + // A registry of the base-reference protocol carries its own exact + // text; any other version/schema pair falls through to the drift check. + (Kind::Registry, 2, 2) if layout == 0 => Some(REGISTRY_SCHEMA_V2), + _ => kind.schema_for(layout), + }; + let Some(compiled_schema) = compiled_schema else { return Err(incompatible( kind, path, @@ -418,8 +491,18 @@ fn validate(c: &Connection, path: &Path, kind: Kind) -> Result<()> { let expected = Connection::open_in_memory()?; expected.execute_batch(META)?; expected.execute_batch(compiled_schema)?; - if version != u64::from(FORMAT_VERSION) - || schema != SCHEMA_VERSION + let known_version = match kind { + Kind::Registry => { + (version, schema) == (u64::from(FORMAT_VERSION), SCHEMA_VERSION) + || (version, schema) + == ( + u64::from(REGISTRY_BASE_REFS_VERSION), + REGISTRY_BASE_REFS_VERSION, + ) + } + Kind::Plane(_) => version == u64::from(FORMAT_VERSION) && schema == SCHEMA_VERSION, + }; + if !known_version || text != format!("{META}{compiled_schema}") || schema_rows(c).ok() != Some(schema_rows(&expected)?) || !initialization::has_seed(c, kind) @@ -545,6 +628,24 @@ impl LedgerReader { pub fn accounting(&self) -> Result { accounting(&**self.database.connection()?) } + /// The member row of `scope` as `(kind, state, view_dir)`, read without + /// any lock or barrier. Pin-then-verify readers use it before and after + /// taking their marker. + pub fn member(&self, scope: &str) -> Result> { + Ok(self + .database + .connection()? + .query_row( + "SELECT kind,state,view_dir FROM members WHERE scope=?1", + [scope], + |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)), + ) + .optional()?) + } + /// Runs a read-only query on the ledger connection. + pub fn read(&self, body: impl FnOnce(&Connection) -> Result) -> Result { + body(&**self.database.connection()?) + } fn reservation_owners(&self) -> Result<(u64, bool)> { let connection = self.database.connection()?; let mut statement = connection.prepare( @@ -749,6 +850,78 @@ impl<'a> RegistryWriter<'a> { pub fn accounting(&self) -> Result { accounting(&**self.connection()?) } + /// An existing registry for barrier-only work (base references, build + /// claims, sweeps). It takes no covering measurement: nothing it does + /// changes the namespace inventory, and admission still measures through + /// [`Self::reserve`]. + pub fn open_for_barrier( + storage: &Path, + family: &str, + lock: &'a ReservationLock, + ) -> Result { + Self::open_unmeasured(storage, family, lock, false) + } + pub fn storage(&self) -> &Path { + &self.lock.storage + } + pub fn family(&self) -> Result<&str> { + self.dir + .file_name() + .and_then(|s| s.to_str()) + .ok_or(Error::InvalidIdentity) + } + pub fn family_dir(&self) -> &Path { + &self.dir + } + /// The registry's format version: 1 before the base-reference upgrade, + /// [`REGISTRY_BASE_REFS_VERSION`] after it. + pub fn registry_version(&self) -> Result { + Ok(self.connection()?.query_row( + "SELECT format_version FROM format_meta WHERE singleton=1", + [], + |r| r.get(0), + )?) + } + /// Exact-predecessor migration from registry 1/1 to 2/2: adds the + /// base-reference tables in one barrier transaction and rewrites + /// `format_meta`. The caller must already have raised the registry reader + /// floor to 2. A registry already at 2/2 is left alone; any other state is + /// schema drift. + pub fn upgrade_to_base_refs(&mut self) -> Result<()> { + let path = self.dir.join("members.sqlite"); + self.with_barrier(|tx| { + let (version, schema, text): (u32, u32, String) = tx.query_row( + "SELECT format_version,schema_version,schema_text FROM format_meta WHERE singleton=1", + [], + |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)), + )?; + if (version, schema) == (REGISTRY_BASE_REFS_VERSION, REGISTRY_BASE_REFS_VERSION) + && text == format!("{META}{REGISTRY_SCHEMA_V2}") + { + return Ok(()); + } + if (version, schema) != (FORMAT_VERSION, SCHEMA_VERSION) + || text != format!("{META}{REGISTRY_SCHEMA}") + { + return Err(incompatible( + Kind::Registry, + &path, + u64::from(version), + IncompatibleReason::SchemaDrift, + )); + } + tx.execute_batch(BASE_REFS_SCHEMA)?; + tx.execute( + "INSERT INTO sweep_state(singleton,gc_epoch) VALUES(1,1)", + [], + )?; + tx.execute( + "UPDATE format_meta SET format_version=?1,schema_version=?1,schema_text=?2 WHERE singleton=1", + params![REGISTRY_BASE_REFS_VERSION, format!("{META}{REGISTRY_SCHEMA_V2}")], + )?; + Ok(()) + }) + } fn connection(&self) -> Result> { self.database.connection() } @@ -1054,6 +1227,157 @@ pub fn publish_locked( }) .map_err(|e| Error::Io(std::io::Error::other(e)))? } +/// Per-visit progress of the v3 plane-row sweep: for each plane, the last +/// key (hex) the previous bounded visit examined. +#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct PlaneRowCursor { + pub format_version: u32, + pub after: std::collections::BTreeMap, +} + +/// What one bounded plane-row visit did. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct PlaneRowSweep { + pub examined: u64, + pub deleted: u64, + /// Every plane was scanned to its end in this visit. + pub complete: bool, +} + +impl RegistryWriter<'_> { + /// Deletes plane rows of this family that no marked generation, pin or + /// protected reader names, under the storage-root lock this writer holds. + /// Every v3 plane write happens inside a publication that holds the same + /// lock, so no row can be put or touched between the caller's marking and + /// this deletion. Rows with `ref_epoch >= epoch` are never deleted, and + /// every plane's `gc_epoch` is raised to `epoch` first, so a row written by + /// a later publication carries an epoch this sweep cannot delete. + /// + /// The scan stops after `limit` examined rows or at `deadline` and resumes + /// from `cursor` on the next visit; reaching a bound never counts as having + /// seen the remaining rows. + pub fn sweep_plane_rows( + &mut self, + epoch: i64, + marked: &std::collections::HashSet<[u8; 32]>, + cursor: &mut PlaneRowCursor, + limit: u64, + deadline: Option, + ) -> Result { + self.lock.verify(&self.lock.storage)?; + cursor.format_version = 1; + let mut report = PlaneRowSweep { + complete: true, + ..PlaneRowSweep::default() + }; + for plane in FamilyPlane::ALL { + let path = self.dir.join(format!("{}.sqlite", plane.as_str())); + if !path.try_exists()? { + cursor.after.remove(plane.as_str()); + continue; + } + let database = connections::shared(&path, Kind::Plane(plane), false, false)?; + let mut c = database.connection()?; + { + let tx = c.transaction_with_behavior(TransactionBehavior::Immediate)?; + tx.execute( + "UPDATE store_meta SET gc_epoch=?1 WHERE singleton=1 AND gc_epoch?2 ORDER BY full_key LIMIT 64", + "DELETE FROM blob_payloads WHERE full_key=?1 AND ref_epoch?2 ORDER BY p.full_key LIMIT 64", + "DELETE FROM blob_payloads WHERE full_key=?1 AND id IN (SELECT blob_id FROM blob_liveness WHERE ref_epoch= limit || deadline.is_some_and(|d| Instant::now() >= d) { + report.complete = false; + break; + } + let tx = c.transaction_with_behavior(TransactionBehavior::Immediate)?; + let keys: Vec> = tx + .prepare(select)? + .query_map(params![epoch, after], |r| r.get(0))? + .collect::>()?; + if keys.is_empty() { + tx.commit()?; + after.clear(); + break; + } + for key in &keys { + report.examined += 1; + let protected = <[u8; 32]>::try_from(key.as_slice()) + .map_or(true, |key| marked.contains(&key)); + if !protected { + report.deleted += tx.execute(delete, params![key, epoch])? as u64; + } + } + tx.commit()?; + after = keys.last().cloned().unwrap_or_default(); + } + if after.is_empty() { + cursor.after.remove(plane.as_str()); + } else { + cursor + .after + .insert(plane.as_str().to_owned(), super::v2::to_hex(&after)); + } + if !report.complete { + break; + } + } + Ok(report) + } +} + +/// Opens (creating when absent) the family registry for the base-reference +/// protocol and upgrades it to registry 2/2. The registry floor is raised to +/// 2 before any byte of the new schema exists, and the store floor to 1, +/// through the combined v3 floor boundary every v3 writer uses. +#[doc(hidden)] +pub fn open_base_refs_registry<'a>( + storage: &Path, + family: &str, + lock: &'a ReservationLock, + observer: Option<&dyn DurabilityObserver>, +) -> Result> { + family_dir(storage, family)?; + lock.verify(storage)?; + crate::reader_floor::publish_v3_with_floor( + storage, + REGISTRY_BASE_REFS_VERSION, + FORMAT_VERSION, + || { + observe(observer, DurabilityStep::V3FloorRaised); + let exists = family_dir(storage, family)? + .join("members.sqlite") + .try_exists()?; + let mut registry = if exists { + RegistryWriter::open_for_barrier(storage, family, lock)? + } else { + RegistryWriter::open(storage, family, lock, true)? + }; + registry.upgrade_to_base_refs()?; + Ok(registry) + }, + ) + .map_err(|e| Error::Io(std::io::Error::other(e)))? +} fn publish_under_lock( storage: &Path, family: &str, @@ -1313,6 +1637,14 @@ impl PlaneReader { } } +/// Serializes tests that depend on the process-wide free-space override and +/// sets it to unlimited, so admission in other modules' tests never races the +/// refusal cases in `tests`. +#[cfg(test)] +pub(crate) fn serial_for_test() -> std::sync::MutexGuard<'static, ()> { + tests::serial() +} + #[cfg(test)] mod copy_tests; #[cfg(test)] diff --git a/crates/aft/src/blob_store/v3/tests.rs b/crates/aft/src/blob_store/v3/tests.rs index 64ed8bcf73..fc905f832a 100644 --- a/crates/aft/src/blob_store/v3/tests.rs +++ b/crates/aft/src/blob_store/v3/tests.rs @@ -179,6 +179,7 @@ fn publication_observes_floor_before_files_and_flush_before_commit() { | DurabilityStep::ManifestParentSynced | DurabilityStep::PointerCas | DurabilityStep::PointerSynced => panic!("v2 boundary in v3 publication"), + other => panic!("base-reference boundary {other:?} in a copy publication"), } } } @@ -685,6 +686,7 @@ fn killed_v3_publishers_recover_at_both_new_boundaries_without_losing_orphans() | DurabilityStep::ManifestParentSynced | DurabilityStep::PointerCas | DurabilityStep::PointerSynced => panic!("V3_ONLY contains a v2 boundary"), + other => panic!("V3_ONLY contains the base-path boundary {other:?}"), } } } @@ -859,11 +861,13 @@ fn newer_format_keeps_artifact_refusal_and_malformed_file_reports_found_zero() { let path = db(root.path(), A); Connection::open(&path) .unwrap() - .execute("UPDATE format_meta SET format_version=2", []) + // Registry 2 is the base-reference protocol this build reads; the + // first newer version is 3. + .execute("UPDATE format_meta SET format_version=3", []) .unwrap(); match LedgerReader::open_existing(root.path(), A).err().unwrap() { Error::Incompatible(error) => { - assert_eq!(error.found, 2); + assert_eq!(error.found, 3); assert_eq!(error.source, persisted_format::RefusalSource::Artifact); } error => panic!("{error}"), diff --git a/crates/aft/src/persisted_format.rs b/crates/aft/src/persisted_format.rs index 80a22b952e..1037e120db 100644 --- a/crates/aft/src/persisted_format.rs +++ b/crates/aft/src/persisted_format.rs @@ -111,7 +111,8 @@ impl PersistedStore { PersistedStore::ArtifactOwner => crate::artifact_owner::SCHEMA_VERSION, PersistedStore::BackupMeta => crate::backup::SCHEMA_VERSION, PersistedStore::BashTask => crate::bash_background::persistence::SCHEMA_VERSION, - PersistedStore::ViewsV3Registry | PersistedStore::ViewsV3Store => 1, + PersistedStore::ViewsV3Registry => crate::blob_store::v3::REGISTRY_BASE_REFS_VERSION, + PersistedStore::ViewsV3Store => 1, } } diff --git a/crates/aft/src/reader_floor.rs b/crates/aft/src/reader_floor.rs index f966008fa4..67c786e59d 100644 --- a/crates/aft/src/reader_floor.rs +++ b/crates/aft/src/reader_floor.rs @@ -785,12 +785,14 @@ pub(crate) mod tests { #[test] fn v3_registration_is_advertised_but_not_prepared() { let formats = persisted_format::formats_json(); - for name in ["views_v3_registry", "views_v3_store"] { - assert_eq!(formats["stores"][name], 1, "{name}"); + // The registry reads the base-reference protocol (2/2); plane stores + // keep format 1. Neither appears in the startup baseline floor. + for (name, version) in [("views_v3_registry", 2), ("views_v3_store", 1)] { + assert_eq!(formats["stores"][name], version, "{name}"); let store = PersistedStore::from_name(name).expect("registered store"); assert!(PersistedStore::ALL.contains(&store)); - assert_eq!(store.supported(), 1); - assert_eq!(store.written(), 1); + assert_eq!(store.supported(), version); + assert_eq!(store.written(), version); } let root = tempfile::tempdir().unwrap(); assert!(prepare_uncached(root.path()).is_empty()); From 966e20ddc5d1612dfa525ce63839080ca670a87f Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Mon, 26 Oct 2026 16:09:57 +0100 Subject: [PATCH 002/100] mason: authenticated v3 base identity, referrers and two-sweep reclaim Base identity: tagged, length-prefixed BLAKE3 digests of a base manifest's membership and content (readiness excluded; head_tree and git_oid refused, slice 4 bases are membership-keyed) and of its effective identity (family, producers, ignore fingerprint, platform family, membership). The base scope name derives from the identity digest. resolve_base pins the base's current generation with a read marker, re-verifies pointer and member row, and recomputes both digests from the pinned manifest; only that AuthenticatedBase validates a BaseHeader (bound_generation stays diagnostic). Worker side: publish_worker runs the insertion barrier (BaseRefMarker with a per-acquisition nonce plus an intent row in one IMMEDIATE transaction under the reservation lock), authenticates the base after the barrier, rebinds the delta to the authenticated header, verify_held and a base re-check right before the pointer CAS, then ReferrerPublished. Conflict/EquivalentWinner and failed verifies delete only their own intent row under the barrier before the marker drops; a CAS that may have landed is published, never cleaned up. open_foreign_v3 (and read::open_foreign_v3_generation) does the dual pin-then-verify with three attempts and owns both markers. read::open_foreign_generation keeps refusing a base header, now because a v2 view can never hold an authenticatable base reference (rule 6); its test asserts the new reason. Sweep: marking of current, read-marked and pinned generations under the reservation lock, exact edges, dangling edges counted and recorded in strict_rebuild without aborting, bounded reconciliation (256 rows / 25 ms, updated_epoch < S only, wrap-around position), repair of valid edges, Hold::Referenced via eviction::v3_base_hold, the two-sweep reclaim, and the plane-row sweep. The retention collector runs it per v3 family visit and deletes a deregistered worker's rows with its member row. DurabilityStep gains the seven base-path boundaries (BASE_PATH); the v2 kill test's step list excludes them. Tests use injected EIO/EXDEV/ENOSPC at each boundary through contracts::inject_fault_at. Co-authored-by: alfonso-aft <289616620+alfonso-aft@users.noreply.github.com> --- crates/aft/src/pins/live.rs | 29 + crates/aft/src/root_cache.rs | 42 + crates/aft/src/storage_retention.rs | 126 ++- crates/aft/src/views/base_builder.rs | 166 +++ crates/aft/src/views/base_refs.rs | 964 ++++++++++++++++++ crates/aft/src/views/base_refs_tests.rs | 691 +++++++++++++ crates/aft/src/views/base_sweep.rs | 818 +++++++++++++++ crates/aft/src/views/base_sweep_tests.rs | 566 ++++++++++ crates/aft/src/views/contracts.rs | 95 +- crates/aft/src/views/eviction.rs | 54 + crates/aft/src/views/manifest_v2.rs | 11 + crates/aft/src/views/mod.rs | 3 + crates/aft/src/views/read.rs | 31 +- .../integration/per_checkout_registry.rs | 3 +- 14 files changed, 3589 insertions(+), 10 deletions(-) create mode 100644 crates/aft/src/views/base_builder.rs create mode 100644 crates/aft/src/views/base_refs.rs create mode 100644 crates/aft/src/views/base_refs_tests.rs create mode 100644 crates/aft/src/views/base_sweep.rs create mode 100644 crates/aft/src/views/base_sweep_tests.rs diff --git a/crates/aft/src/pins/live.rs b/crates/aft/src/pins/live.rs index 1b713dca95..fa78a24495 100644 --- a/crates/aft/src/pins/live.rs +++ b/crates/aft/src/pins/live.rs @@ -51,6 +51,35 @@ impl LivePin { }) } + /// Creates an empty live pin in the `pins/` of the v3 member `scope`, + /// inside the v3 registry barrier: the member must still be registered and + /// not `evicting`, so the retention collector's final protection check + /// either sees this pin or has already removed the member. + pub fn create_v3(storage: &Path, family: &str, scope: &str) -> Result { + crate::views::base_refs::with_base_barrier(storage, family, |tx| { + let state: Option = rusqlite::OptionalExtension::optional(tx.query_row( + "SELECT state FROM members WHERE scope=?1", + [scope], + |r| r.get(0), + ))?; + if state.is_none() || state.as_deref() == Some(crate::views::registry::STATE_EVICTING) { + return Err(crate::views::base_refs::BaseRefError::Unavailable(format!( + "v3 member {scope} is not registered" + ))); + } + let view_dir = crate::blob_store::v3::view_dir(storage, family, scope)?; + Self::create_in(&view_dir, family.to_owned(), scope.to_owned()).map_err(|error| { + crate::views::base_refs::BaseRefError::Io(std::io::Error::other(error.to_string())) + }) + }) + .map_err(|error| match error { + crate::views::base_refs::BaseRefError::Unavailable(_) => { + PinError::NotRegistered(scope.to_owned()) + } + other => PinError::Registry(other.to_string()), + }) + } + fn create_in(view_dir: &Path, family: String, view: String) -> Result { let pid = std::process::id(); let now = now_ms(); diff --git a/crates/aft/src/root_cache.rs b/crates/aft/src/root_cache.rs index 68276e4a49..d35a54969b 100644 --- a/crates/aft/src/root_cache.rs +++ b/crates/aft/src/root_cache.rs @@ -761,6 +761,10 @@ pub struct ReadMarkerMetadata { pub pid: u32, pub hostname: String, pub created_at_ms: u64, + /// Per-acquisition identity of a base-reference marker. Ordinary readers + /// leave it out, so their files are unchanged. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub nonce: Option, } #[derive(Debug)] @@ -774,10 +778,45 @@ pub struct ReadMarker { impl ReadMarker { pub fn create(cache_dir: &Path, generation_label: &str) -> io::Result { + Self::create_inner(cache_dir, generation_label, None) + } + + /// A marker whose file also records `nonce`, so its holder can later + /// prove the file at its unique path is still its own acquisition. + pub fn create_with_nonce( + cache_dir: &Path, + generation_label: &str, + nonce: &str, + ) -> io::Result { + Self::create_inner(cache_dir, generation_label, Some(nonce.to_owned())) + } + + /// True when the marker file at this marker's own path still exists and + /// carries this marker's metadata, nonce included. A sweep that judged the + /// owner dead removes the file, and this then reports `false`. + pub fn verify_held(&self) -> io::Result { + use std::io::Read; + let mut file = match self.generation.repair_read_file(&self.name) { + Ok(file) => file, + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(false), + Err(error) => return Err(error), + }; + let mut bytes = Vec::new(); + (&mut file).take(32 * 1024).read_to_end(&mut bytes)?; + Ok(serde_json::from_slice::(&bytes) + .is_ok_and(|stored| stored == self.metadata)) + } + + fn create_inner( + cache_dir: &Path, + generation_label: &str, + nonce: Option, + ) -> io::Result { let metadata = ReadMarkerMetadata { pid: std::process::id(), hostname: current_hostname(), created_at_ms: now_ms(), + nonce, }; let dir = read_marker_dir(cache_dir, generation_label); crate::private_storage::open_dir(cache_dir, &dir)?; @@ -1559,6 +1598,7 @@ mod tests { pid: 0, hostname: current_hostname(), created_at_ms: now_ms(), + nonce: None, }; fs::create_dir_all(marker_path.parent().unwrap()).unwrap(); write_marker_file(&marker_path, &metadata).unwrap(); @@ -1579,6 +1619,7 @@ mod tests { pid, hostname: current_hostname(), created_at_ms: 1_000, + nonce: None, }; fs::create_dir_all(marker_path.parent().unwrap()).unwrap(); write_marker_file(&marker_path, &metadata).unwrap(); @@ -1600,6 +1641,7 @@ mod tests { pid: 123, hostname: format!("other-{}", current_hostname()), created_at_ms: now_ms(), + nonce: None, }; fs::create_dir_all(marker_path.parent().unwrap()).unwrap(); write_marker_file(&marker_path, &metadata).unwrap(); diff --git a/crates/aft/src/storage_retention.rs b/crates/aft/src/storage_retention.rs index 5316ee0964..380739e214 100644 --- a/crates/aft/src/storage_retention.rs +++ b/crates/aft/src/storage_retention.rs @@ -40,7 +40,7 @@ const INTERVAL: Duration = Duration::from_secs(10 * 60); const STARTUP_GRACE: Duration = Duration::from_secs(5 * 60); const PASS_BUDGET: Duration = Duration::from_secs(5); const PASS_ENTRIES: usize = 64; -const TREE_ENTRIES: usize = 4096; +pub(crate) const TREE_ENTRIES: usize = 4096; const TRASH_SCAN_HANDLES: usize = 64; const HISTORY_ENTRIES: usize = 8192; static REPORTED_BINDING_READ_ERRORS: OnceLock>> = OnceLock::new(); @@ -1614,7 +1614,7 @@ fn read_json(path: &Path) -> io::Result { serde_json::from_slice(&fs::read(path)?).map_err(io::Error::other) } -fn bounded_dirs(directory: &Path, cap: usize) -> io::Result> { +pub(crate) fn bounded_dirs(directory: &Path, cap: usize) -> io::Result> { let entries = match fs::read_dir(directory) { Ok(entries) => entries, Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()), @@ -4660,7 +4660,7 @@ enum V3Step { /// way, so they never keep a member. `Err` is uncertainty (an unreadable pin, /// or a `pins/` or `readers/` listing longer than the bound) and keeps the /// member without resetting its count, as the v2 family sweep does. -fn v3_member_protected( +pub(crate) fn v3_member_protected( tx: &rusqlite::Transaction<'_>, scope: &str, view_dir: &Path, @@ -4887,10 +4887,16 @@ fn v3_finish_eviction( crate::views::registry::remove_view_dir(view_dir)?; registry .with_barrier(|tx| { - tx.execute( + let removed = tx.execute( "DELETE FROM members WHERE scope=?1 AND state=?2", rusqlite::params![scope, crate::views::registry::STATE_EVICTING], )?; + // A deregistered worker's referrer and strict-rebuild rows go in + // the same transaction as its member row, after its directory. + if removed > 0 && crate::views::base_refs::has_base_refs(tx)? { + tx.execute("DELETE FROM base_refs WHERE worker_scope=?1", [scope])?; + tx.execute("DELETE FROM strict_rebuild WHERE worker_scope=?1", [scope])?; + } Ok(()) }) .map_err(|error| io::Error::other(error.to_string()))?; @@ -5203,6 +5209,44 @@ fn collect_v3_family( }, )?; } + // The base-reference sweep: referrer reconciliation, the two-sweep base + // reclaim and the plane-row sweep. It needs the registry's base-reference + // tables and runs under the reservation lock this visit already holds. + if !cancelled() + && Instant::now() < deadline + && registry.registry_version().map_err(v3)? + >= crate::blob_store::v3::REGISTRY_BASE_REFS_VERSION + { + let plane_cursor = cursor_root(paths)? + .join("v3-plane-rows") + .join(format!("{family}.json")); + let bounds = crate::views::base_sweep::SweepBounds { + deadline: Some(deadline), + ..Default::default() + }; + match crate::views::base_sweep::sweep_family_locked( + storage, + family, + &lock, + None, + bounds, + Some(&plane_cursor), + ) { + Ok(swept) => { + report.removed_roots += swept.reclaimed_bases.len(); + report.removed_blobs += swept.plane_rows_deleted as usize; + report.examined_blobs += swept.plane_rows_examined as usize; + report.stopped_early |= !swept.plane_rows_complete && swept.skipped.is_none(); + } + Err(error) => { + if report.errors.len() < 16 { + report + .errors + .push(format!("v3 base sweep of family {family}: {error}")); + } + } + } + } // Every collector pass ends with a covering measurement, so committed // usage also reflects files removed by anything else since the last one. // It is skipped once one is already owed: the deadline has passed. @@ -12827,3 +12871,77 @@ mod v3_collector_tests { assert!(member.join("data.bin").exists()); } } + +#[cfg(test)] +mod v3_base_reference_collector_tests { + use super::*; + use crate::views::base_refs::tests::{ + bind, plane_has, publish_test_base, published, put_rows, refs, worker_delta, FAMILY, + }; + use crate::views::base_refs::with_base_barrier; + + fn pass(storage: &Path) -> SweepReport { + let mut report = SweepReport::default(); + collect_one_v3_family( + &StoragePaths::same(storage), + &|| false, + &mut report, + V3Bounds::PASS, + ) + .unwrap(); + assert!(report.errors.is_empty(), "{report:?}"); + report + } + + fn registered(storage: &Path, scope: &str) -> bool { + crate::blob_store::v3::LedgerReader::open_existing(storage, FAMILY) + .unwrap() + .member(scope) + .unwrap() + .is_some() + } + + #[test] + fn the_collector_keeps_a_referenced_base_and_reclaims_it_two_passes_after_its_worker_goes() { + let _serial = crate::blob_store::v3::serial_for_test(); + let storage = tempfile::tempdir().unwrap(); + let (identity, generation) = publish_test_base(storage.path(), &[("src/a.rs", b"alpha")]); + put_rows(storage.path(), &[b"new"]); + let (_, composed) = published( + bind( + storage.path(), + "worker-a", + &identity, + &generation, + worker_delta(&identity, &generation, b"new"), + None, + ) + .unwrap(), + ); + drop(composed); + for _ in 0..3 { + pass(storage.path()); + assert!(registered(storage.path(), &identity.scope)); + assert!(plane_has(storage.path(), b"alpha")); + } + // The worker is deregistered: its rows go with its member row. + with_base_barrier(storage.path(), FAMILY, |tx| { + tx.execute( + "UPDATE members SET state='evicting' WHERE scope='worker-a'", + [], + )?; + Ok(()) + }) + .unwrap(); + pass(storage.path()); + assert!(!registered(storage.path(), "worker-a")); + assert!(refs(storage.path()).is_empty()); + // That same pass counted the base once; the next one reclaims it. + assert!(registered(storage.path(), &identity.scope)); + pass(storage.path()); + assert!(!registered(storage.path(), &identity.scope)); + pass(storage.path()); + assert!(!plane_has(storage.path(), b"alpha")); + assert!(!storage.path().join("views/v3").exists()); + } +} diff --git a/crates/aft/src/views/base_builder.rs b/crates/aft/src/views/base_builder.rs new file mode 100644 index 0000000000..4299a4d471 --- /dev/null +++ b/crates/aft/src/views/base_builder.rs @@ -0,0 +1,166 @@ +//! v3 base scopes: registration, publication and readiness successors, and +//! (with the build claim) single-flight construction. +//! +//! A base is published once its membership and lexical plane are complete. +//! From then on its membership digest, identity and trigram segment are +//! fixed; its pointer moves only to readiness successors, which may turn +//! `Pending` into `Ready` or `Failed`, `Failed` into `Ready`, and nothing +//! else. A `Ready` key never changes. + +use std::path::{Path, PathBuf}; + +use rusqlite::{params, OptionalExtension, Transaction}; + +use crate::blob_store::v2::FamilyPlane; +use crate::blob_store::v3; + +use super::base_refs::{with_base_barrier, BaseIdentity, BaseRefError, Result, KIND_BASE}; +use super::contracts::{boundary, DurabilityObserver, DurabilityStep}; +use super::manifest_v2::{GenerationName, ManifestV2, PublishV2}; +use super::readiness::PlaneState; +use super::registry::STATE_EVICTING; +use super::ViewStore; + +/// Registers the base member row (`kind = base`, no root) inside a barrier. +/// A member in `evicting` is never reused. +pub(crate) fn register_base_in( + tx: &Transaction<'_>, + storage: &Path, + family: &str, + scope: &str, +) -> Result<(PathBuf, bool)> { + let dir = v3::view_dir(storage, family, scope)?; + let existing: Option<(String, String)> = tx + .query_row( + "SELECT kind,state FROM members WHERE scope=?1", + [scope], + |r| Ok((r.get(0)?, r.get(1)?)), + ) + .optional()?; + match existing { + Some((_, state)) if state == STATE_EVICTING => Err(BaseRefError::Unavailable(format!( + "base {scope} is evicting" + ))), + Some((kind, _)) if kind != KIND_BASE => Err(BaseRefError::Corrupt(format!( + "{scope} is registered as a {kind}" + ))), + Some(_) => Ok((dir, false)), + None => { + tx.execute( + "INSERT INTO members(scope,kind,root,view_dir,registered_at_ms,last_bind_ms,state) VALUES(?1,'base',NULL,?2,?3,?3,'active')", + params![scope, dir.to_string_lossy(), crate::pins::now_ms() as i64], + )?; + Ok((dir, true)) + } + } +} + +/// Whether `next` may replace `current` as the base's generation: same +/// identity and segment, and every entry's per-plane state either unchanged +/// or one of the allowed monotone transitions. +pub fn check_successor(family: &str, current: &ManifestV2, next: &ManifestV2) -> Result<()> { + let old = BaseIdentity::of(family, current)?; + let new = BaseIdentity::of(family, next)?; + if old != new { + return Err(BaseRefError::Corrupt( + "a readiness successor changes the base identity".into(), + )); + } + if current.header().segment != next.header().segment { + return Err(BaseRefError::Corrupt( + "a readiness successor replaces the trigram segment".into(), + )); + } + for ((path, before), (_, after)) in current.entries().zip(next.entries()) { + for plane in FamilyPlane::ALL { + let allowed = match (before.plane_state(plane), after.plane_state(plane)) { + (a, b) if a == b => true, + (Some(PlaneState::Pending { .. }), Some(PlaneState::Ready { .. })) => true, + (Some(PlaneState::Pending { .. }), Some(PlaneState::Failed { .. })) => true, + (Some(PlaneState::Failed { .. }), Some(PlaneState::Ready { .. })) => true, + // A retry may record a new reason for a still-pending entry. + (Some(PlaneState::Pending { .. }), Some(PlaneState::Pending { .. })) => true, + _ => false, + }; + if !allowed { + return Err(BaseRefError::Corrupt(format!( + "forbidden {} transition for {:?}", + plane.as_str(), + path + ))); + } + } + } + Ok(()) +} + +/// A first base publication needs complete membership and a complete lexical +/// plane: no trigram entry may still be pending. Graph and semantic entries +/// may be. +pub fn check_lexical_complete(manifest: &ManifestV2) -> Result<()> { + for (path, entry) in manifest.entries() { + if entry + .plane_state(FamilyPlane::Trigram) + .is_some_and(PlaneState::is_pending) + { + return Err(BaseRefError::Corrupt(format!( + "first base publication with a pending lexical entry {path:?}" + ))); + } + } + Ok(()) +} + +/// What a base publication did. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum BasePublication { + /// The pointer names `generation`. + Published { generation: String, first: bool }, + /// Another publisher moved the pointer first; nothing was published. + Lost { current: Option }, +} + +/// Publishes `manifest` as the base's first generation, or as a readiness +/// successor of its current one. Callers own the base's build claim; the +/// manifest's Ready keys must already be durable in the family's v3 plane +/// stores and protected by the caller's pin until this returns. +pub fn publish_base( + storage: &Path, + family: &str, + manifest: &ManifestV2, + observer: Option<&dyn DurabilityObserver>, +) -> Result { + let identity = BaseIdentity::of(family, manifest)?; + let (dir, registered) = with_base_barrier(storage, family, |tx| { + register_base_in(tx, storage, family, &identity.scope) + })?; + if registered { + boundary(observer, DurabilityStep::BaseRegistered)?; + } + let store = ViewStore::open_dir(dir)?; + let current = store.current_generation()?; + match ¤t { + Some(current) => { + let current_manifest = store.load_manifest_v2(current)?; + check_successor(family, ¤t_manifest, manifest)?; + } + None => check_lexical_complete(manifest)?, + } + let name = GenerationName::for_manifest(manifest)?; + let prepared = store.prepare_v2(&name, current.as_deref(), manifest, observer)?; + match store.commit_v2(prepared, observer)? { + PublishV2::Published => { + if current.is_some() { + boundary(observer, DurabilityStep::BaseSuccessorPublished)?; + } + Ok(BasePublication::Published { + generation: name.to_string(), + first: current.is_none(), + }) + } + PublishV2::Conflict { current } => Ok(BasePublication::Lost { current }), + PublishV2::EquivalentWinner { current } => Ok(BasePublication::Lost { + current: Some(current), + }), + } +} diff --git a/crates/aft/src/views/base_refs.rs b/crates/aft/src/views/base_refs.rs new file mode 100644 index 0000000000..24341b43e2 --- /dev/null +++ b/crates/aft/src/views/base_refs.rs @@ -0,0 +1,964 @@ +//! Durable cross-view base references in the v3 namespace. +//! +//! A base is its own registered v3 member (`kind = base`, no root) under +//! `blobs/v3//views/`. Its membership is fixed at first +//! publication, and its scope name is derived from its full effective +//! identity. A worker manifest names a base through its `base` header; that +//! header is never trusted. Every consumer resolves the base through +//! [`resolve_base`], which pins the base's current generation with a read +//! marker, re-verifies the pointer and member row, and recomputes both +//! digests from the pinned manifest. Only the resulting +//! [`AuthenticatedBase`] can validate a header or become the base of a +//! composed generation. +//! +//! The worker side of the protocol ([`publish_worker`]) follows the insertion +//! barrier of the views design: a per-acquisition [`BaseRefMarker`] plus an +//! `intent` row in `base_refs` (one IMMEDIATE transaction under the +//! storage-root reservation lock), base re-verification after the barrier, +//! `verify_held` immediately before the worker's pointer CAS, then the row +//! becomes `published`. Aborts delete their own `intent` row under the +//! barrier before the marker drops. A CAS that may have landed is resolved +//! first and never cleaned up as an abort. +//! +//! Nothing here writes a v1 or v2 row, manifest or pointer. + +use std::fmt; +use std::io; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Arc; + +use rusqlite::{params, OptionalExtension, Transaction}; + +use crate::blob_store::v2::to_hex; +use crate::blob_store::v3::{self, LedgerReader, RegistryWriter, ReservationLock}; +use crate::root_cache::ReadMarker; + +use super::contracts::{boundary, DurabilityObserver, DurabilityStep}; +use super::manifest_v2::{ + BaseHeader, EntryV2, GenerationName, ManifestHeader, ManifestV2, PublishV2, +}; +use super::registry::STATE_EVICTING; +use super::snapshot::{ComposedGeneration, Residency}; +use super::ViewStore; + +/// Domain tag of the membership-and-content digest, version 1. +pub const MEMBERSHIP_DIGEST_TAG: &[u8] = b"aft/views-v3/base-membership/1"; +/// Domain tag of the effective-identity digest, version 1. +pub const IDENTITY_DIGEST_TAG: &[u8] = b"aft/views-v3/base-identity/1"; +/// Membership-keyed base scopes. Slice 4v's tree-keyed scopes will use a +/// different prefix; a scope is never relabelled from one to the other. +pub const BASE_SCOPE_PREFIX: &str = "base-m1-"; +/// Pin-then-verify attempts before a base is reported unavailable. +pub const RESOLVE_ATTEMPTS: usize = 3; +/// `base_refs.state` values. +pub const REF_INTENT: &str = "intent"; +pub const REF_ORPHANED: &str = "orphaned"; +pub const REF_PUBLISHED: &str = "published"; +pub const KIND_BASE: &str = "base"; +pub const KIND_VIEW: &str = "view"; +pub const STATE_ACTIVE: &str = "active"; + +#[derive(Debug)] +pub enum BaseRefError { + Store(v3::Error), + View(super::ViewError), + Io(io::Error), + /// The base (or worker) is absent, evicting or moved during every attempt. + Unavailable(String), + /// A header, manifest or registry row contradicts the authenticated base. + Corrupt(String), +} + +impl fmt::Display for BaseRefError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Store(error) => write!(f, "v3 store: {error}"), + Self::View(error) => write!(f, "{error}"), + Self::Io(error) => write!(f, "{error}"), + Self::Unavailable(reason) => write!(f, "base unavailable: {reason}"), + Self::Corrupt(reason) => write!(f, "corrupt base edge: {reason}"), + } + } +} + +impl std::error::Error for BaseRefError {} + +impl From for BaseRefError { + fn from(error: v3::Error) -> Self { + Self::Store(error) + } +} +impl From for BaseRefError { + fn from(error: super::ViewError) -> Self { + Self::View(error) + } +} +impl From for BaseRefError { + fn from(error: io::Error) -> Self { + Self::Io(error) + } +} +impl From for BaseRefError { + fn from(error: rusqlite::Error) -> Self { + Self::Store(v3::Error::Sqlite(error)) + } +} + +pub type Result = std::result::Result; + +/// Length-prefixed, domain-tagged BLAKE3 encoding. Every field carries its +/// length, so no two different field sequences hash the same bytes. +struct TaggedHasher(blake3::Hasher); + +impl TaggedHasher { + fn new(tag: &[u8]) -> Self { + let mut hasher = blake3::Hasher::new(); + hasher.update(&(tag.len() as u64).to_le_bytes()); + hasher.update(tag); + Self(hasher) + } + fn field(&mut self, bytes: &[u8]) { + self.0.update(&(bytes.len() as u64).to_le_bytes()); + self.0.update(bytes); + } + fn optional(&mut self, value: Option<&[u8]>) { + match value { + None => { + self.0.update(&[0]); + } + Some(bytes) => { + self.0.update(&[1]); + self.field(bytes); + } + } + } + fn byte(&mut self, value: u8) { + self.0.update(&[value]); + } + fn u64(&mut self, value: u64) { + self.0.update(&value.to_le_bytes()); + } + fn finish(self) -> [u8; 32] { + *self.0.finalize().as_bytes() + } +} + +/// The membership-and-content digest of a base manifest: every path with its +/// entry kind, mode, content hash, size and resolution-input flag, in path +/// order. Readiness is excluded, so readiness successors keep the digest. +/// +/// Slice 4 bases are membership-keyed: a manifest that claims a HEAD tree or +/// any `git_oid`, or carries composition sections, is not a valid base. +pub fn membership_digest(manifest: &ManifestV2) -> Result<[u8; 32]> { + if manifest.base_header().is_some() { + return Err(BaseRefError::Corrupt( + "a base manifest names another base".into(), + )); + } + if !manifest.tombstones().is_empty() || !manifest.readiness_overrides().is_empty() { + return Err(BaseRefError::Corrupt( + "a base manifest carries worker composition sections".into(), + )); + } + if manifest.header().head_tree.is_some() { + return Err(BaseRefError::Corrupt( + "a membership-keyed base claims a HEAD tree".into(), + )); + } + let mut hasher = TaggedHasher::new(MEMBERSHIP_DIGEST_TAG); + hasher.u64(manifest.len() as u64); + for (path, entry) in manifest.entries() { + hasher.field(path.as_bytes()); + match entry { + EntryV2::Regular { + mode, + content, + size, + git_oid, + resolution_input, + .. + } => { + if git_oid.is_some() { + return Err(BaseRefError::Corrupt( + "a membership-keyed base carries an unproven git_oid".into(), + )); + } + hasher.byte(1); + hasher.u64(u64::from(*mode)); + hasher.field(content.as_bytes()); + hasher.u64(*size); + hasher.byte(u8::from(*resolution_input)); + } + EntryV2::Symlink { target_bytes } => { + hasher.byte(2); + hasher.field(target_bytes.as_bytes()); + } + EntryV2::Gitlink { oid } => { + hasher.byte(3); + hasher.field(oid.as_bytes()); + } + EntryV2::Synthetic { name, .. } => { + hasher.byte(4); + hasher.field(name.as_bytes()); + } + } + } + Ok(hasher.finish()) +} + +/// The effective-identity digest: family, every plane producer, the ignore +/// fingerprint that decided membership, the platform family (path and +/// line-ending conventions), the explicit absence of a tree identity, and +/// the membership digest. +pub fn identity_digest(family: &str, header: &ManifestHeader, membership: &[u8; 32]) -> [u8; 32] { + let mut hasher = TaggedHasher::new(IDENTITY_DIGEST_TAG); + hasher.field(family.as_bytes()); + hasher.field(header.producers.trigram.as_bytes()); + hasher.optional(header.producers.semantic.as_deref().map(str::as_bytes)); + hasher.field(header.producers.callgraph.as_bytes()); + hasher.optional(header.ignore_fingerprint.as_deref().map(str::as_bytes)); + hasher.field(std::env::consts::FAMILY.as_bytes()); + hasher.field(b"head_tree:none"); + hasher.field(membership); + hasher.finish() +} + +/// The scope name of the base with this identity. +pub fn base_scope_for(identity: &[u8; 32]) -> String { + format!("{BASE_SCOPE_PREFIX}{}", to_hex(identity)) +} + +/// The authenticated identity of one base manifest, without any protection. +/// Builders use it before a base is published; readers use +/// [`AuthenticatedBase`]. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct BaseIdentity { + pub scope: String, + pub membership: [u8; 32], + pub identity: [u8; 32], +} + +impl BaseIdentity { + pub fn of(family: &str, manifest: &ManifestV2) -> Result { + let membership = membership_digest(manifest)?; + let identity = identity_digest(family, manifest.header(), &membership); + Ok(Self { + scope: base_scope_for(&identity), + membership, + identity, + }) + } + + pub fn header(&self, bound_generation: &str) -> BaseHeader { + BaseHeader { + scope: self.scope.clone(), + membership_digest: to_hex(&self.membership), + identity_digest: to_hex(&self.identity), + bound_generation: bound_generation.to_owned(), + } + } + + /// Checks a persisted header against this identity. `bound_generation` + /// is diagnostic only and never compared. + pub fn check_header(&self, header: &BaseHeader) -> Result<()> { + if header.scope != self.scope + || header.membership_digest != to_hex(&self.membership) + || header.identity_digest != to_hex(&self.identity) + { + return Err(BaseRefError::Corrupt(format!( + "header for {} does not match the pinned base {}", + header.scope, self.scope + ))); + } + Ok(()) + } +} + +/// A base generation protected by an exact-generation read marker and +/// authenticated against its own pinned manifest. Only [`resolve_base`] +/// constructs it. +#[derive(Debug)] +pub struct AuthenticatedBase { + identity: BaseIdentity, + generation: String, + view_dir: PathBuf, + manifest: ManifestV2, + marker: ReadMarker, +} + +impl AuthenticatedBase { + pub fn identity(&self) -> &BaseIdentity { + &self.identity + } + pub fn scope(&self) -> &str { + &self.identity.scope + } + pub fn generation(&self) -> &str { + &self.generation + } + pub fn view_dir(&self) -> &Path { + &self.view_dir + } + pub fn manifest(&self) -> &ManifestV2 { + &self.manifest + } + /// The header a worker publishes for this base, bound to this generation. + pub fn header(&self) -> BaseHeader { + self.identity.header(&self.generation) + } + pub fn check_header(&self, header: &BaseHeader) -> Result<()> { + self.identity.check_header(header) + } + /// The base side of a composed generation. It owns the marker, so the base + /// generation stays protected for as long as any bundle holds it. + pub fn into_generation(self) -> Arc { + Arc::new(ComposedGeneration::new( + self.generation, + self.manifest, + Some(Residency::Marker(self.marker)), + )) + } +} + +/// Reads a member row without any lock. `None` means no row. +fn member_row( + storage: &Path, + family: &str, + scope: &str, +) -> Result> { + let registry = v3::family_dir(storage, family)?.join("members.sqlite"); + if !registry.try_exists()? { + return Ok(None); + } + Ok(LedgerReader::open_existing(storage, family)?.member(scope)?) +} + +fn expected_dir(storage: &Path, family: &str, scope: &str, stored: &str) -> Result { + let dir = v3::view_dir(storage, family, scope)?; + if Path::new(stored) != dir { + return Err(BaseRefError::Corrupt(format!( + "member {scope} names an unexpected view directory" + ))); + } + Ok(dir) +} + +/// Durable pin-then-verify resolution of `scope`'s current base generation: +/// marker on `readers//`, then the pointer must still name G and the +/// member must still be a non-evicting `kind = base`, then the manifest's +/// digests are recomputed and must derive `scope`. Retries +/// [`RESOLVE_ATTEMPTS`] times when the pointer or row moves; a missing base is +/// reported unavailable, never replaced by another generation. +pub fn resolve_base(storage: &Path, family: &str, scope: &str) -> Result { + if !scope.starts_with(BASE_SCOPE_PREFIX) { + return Err(BaseRefError::Corrupt(format!( + "{scope} is not a membership-keyed base scope" + ))); + } + for _ in 0..RESOLVE_ATTEMPTS { + let Some((kind, state, stored_dir)) = member_row(storage, family, scope)? else { + return Err(BaseRefError::Unavailable(format!( + "{scope} is not registered" + ))); + }; + if kind != KIND_BASE { + return Err(BaseRefError::Corrupt(format!( + "{scope} is not a base member" + ))); + } + if state == STATE_EVICTING { + return Err(BaseRefError::Unavailable(format!("{scope} is evicting"))); + } + let view_dir = expected_dir(storage, family, scope, &stored_dir)?; + let Some(store) = ViewStore::existing_dir(view_dir.clone()) else { + return Err(BaseRefError::Unavailable(format!("{scope} has no pointer"))); + }; + let Some(generation) = store.current_generation_read_only()? else { + return Err(BaseRefError::Unavailable(format!("{scope} is unpublished"))); + }; + let marker = ReadMarker::create(&view_dir, &generation)?; + if store.current_generation_read_only()?.as_deref() != Some(generation.as_str()) { + continue; + } + match member_row(storage, family, scope)? { + Some((kind, state, _)) if kind == KIND_BASE && state != STATE_EVICTING => {} + _ => continue, + } + let manifest = store.load_manifest_v2(&generation)?; + let identity = BaseIdentity::of(family, &manifest)?; + if identity.scope != scope { + return Err(BaseRefError::Corrupt(format!( + "{scope} holds a manifest of identity {}", + identity.scope + ))); + } + return Ok(AuthenticatedBase { + identity, + generation, + view_dir, + manifest, + marker, + }); + } + Err(BaseRefError::Unavailable(format!( + "{scope} moved during {RESOLVE_ATTEMPTS} attempts" + ))) +} + +static NONCE_SEQ: AtomicU64 = AtomicU64::new(0); + +/// A process-unique, unguessable-enough acquisition identity. +pub(crate) fn fresh_nonce() -> String { + let mut hasher = blake3::Hasher::new(); + hasher.update(&std::process::id().to_le_bytes()); + hasher.update( + &std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() + .to_le_bytes(), + ); + hasher.update(&NONCE_SEQ.fetch_add(1, Ordering::Relaxed).to_le_bytes()); + to_hex(&hasher.finalize().as_bytes()[..16]) +} + +/// The insertion-barrier marker: a read marker in the base's `readers//` +/// whose file records a per-acquisition nonce. Its path is unique to this +/// acquisition, so another holder's release never removes it. +#[derive(Debug)] +pub struct BaseRefMarker { + marker: ReadMarker, + nonce: String, + generation: String, +} + +impl BaseRefMarker { + fn acquire(view_dir: &Path, generation: &str) -> io::Result { + let nonce = fresh_nonce(); + Ok(Self { + marker: ReadMarker::create_with_nonce(view_dir, generation, &nonce)?, + nonce, + generation: generation.to_owned(), + }) + } + /// True while the marker file at its own path still carries this + /// acquisition's nonce. + pub fn verify_held(&self) -> io::Result { + self.marker.verify_held() + } + pub fn touch_if_due(&self) -> io::Result<()> { + self.marker.touch_if_due() + } + pub fn nonce(&self) -> &str { + &self.nonce + } + pub fn generation(&self) -> &str { + &self.generation + } + pub fn path(&self) -> &Path { + self.marker.path() + } +} + +/// True when the registry has the base-reference tables. +pub(crate) fn has_base_refs(tx: &rusqlite::Connection) -> rusqlite::Result { + tx.query_row( + "SELECT EXISTS(SELECT 1 FROM sqlite_schema WHERE type='table' AND name='base_refs')", + [], + |r| r.get(0), + ) +} + +pub(crate) fn gc_epoch(tx: &rusqlite::Connection) -> rusqlite::Result { + tx.query_row( + "SELECT gc_epoch FROM sweep_state WHERE singleton=1", + [], + |r| r.get(0), + ) +} + +/// The base barrier: the storage-root reservation lock, then one IMMEDIATE +/// transaction on the family registry, upgraded to the base-reference +/// schema. A base has no view session, so this is its equivalent of +/// `ViewRegistration::under_pin_barrier`. Deregistration and reclaim take +/// the same barrier. +pub fn with_base_barrier( + storage: &Path, + family: &str, + body: impl FnOnce(&Transaction<'_>) -> Result, +) -> Result { + let lock = ReservationLock::acquire(storage)?; + let mut registry = v3::open_base_refs_registry(storage, family, &lock, None)?; + barrier(&mut registry, body) +} + +/// One barrier transaction on an already-open registry. An error from `body` +/// rolls the transaction back. +pub(crate) fn barrier( + registry: &mut RegistryWriter<'_>, + body: impl FnOnce(&Transaction<'_>) -> Result, +) -> Result { + let mut outcome = None; + let result = registry.with_barrier(|tx| match body(tx) { + Ok(value) => { + outcome = Some(Ok(value)); + Ok(()) + } + Err(error) => { + outcome = Some(Err(error)); + // Any refusal rolls back: nothing the closure wrote survives. + Err(v3::Error::Refused("base_barrier_body_failed")) + } + }); + match (result, outcome) { + (_, Some(Err(error))) => Err(error), + (Err(error), _) => Err(error.into()), + (Ok(()), Some(value)) => value, + (Ok(()), None) => unreachable!("the barrier ran its body"), + } +} + +/// Registers (or re-binds) a v3 worker view member inside a barrier. A member +/// in `evicting` is never reused: its directory is about to disappear. +pub(crate) fn register_view_in( + tx: &Transaction<'_>, + storage: &Path, + family: &str, + scope: &str, + root: &Path, +) -> Result { + if scope.starts_with(BASE_SCOPE_PREFIX) { + return Err(BaseRefError::Corrupt(format!( + "{scope} is reserved for bases" + ))); + } + let dir = v3::view_dir(storage, family, scope)?; + let existing: Option<(String, String)> = tx + .query_row( + "SELECT kind,state FROM members WHERE scope=?1", + [scope], + |r| Ok((r.get(0)?, r.get(1)?)), + ) + .optional()?; + match existing { + Some((_, state)) if state == STATE_EVICTING => { + return Err(BaseRefError::Unavailable(format!( + "worker member {scope} is evicting" + ))); + } + Some((kind, _)) if kind != KIND_VIEW => { + return Err(BaseRefError::Corrupt(format!( + "{scope} is not a view member" + ))); + } + _ => {} + } + tx.execute( + "INSERT INTO members(scope,kind,root,view_dir,registered_at_ms,last_bind_ms,state) VALUES(?1,'view',?2,?3,?4,?4,'active') ON CONFLICT(scope) DO UPDATE SET last_bind_ms=excluded.last_bind_ms,root=excluded.root,missing_sweeps=0", + params![scope, encode_root(root), dir.to_string_lossy(), crate::pins::now_ms() as i64], + )?; + Ok(dir) +} + +fn encode_root(root: &Path) -> Option> { + #[cfg(unix)] + { + use std::os::unix::ffi::OsStrExt; + Some(root.as_os_str().as_bytes().to_vec()) + } + #[cfg(not(unix))] + { + root.to_str().map(|text| text.as_bytes().to_vec()) + } +} + +/// Deletes one acquisition's `intent` row. Never touches a `published` row: +/// a landed reference is not abort cleanup. +pub(crate) fn delete_intent( + tx: &Transaction<'_>, + worker_scope: &str, + base_scope: &str, + nonce: &str, +) -> rusqlite::Result { + tx.execute( + "DELETE FROM base_refs WHERE worker_scope=?1 AND base_scope=?2 AND nonce=?3 AND state='intent'", + params![worker_scope, base_scope, nonce], + ) +} + +/// Turns one acquisition's row into the durable reference of +/// `worker_generation`, re-inserting it if a sweep already retired it. +pub(crate) fn publish_referrer( + tx: &Transaction<'_>, + worker_scope: &str, + base_scope: &str, + nonce: &str, + membership_digest: &str, + worker_generation: &str, +) -> rusqlite::Result<()> { + let epoch = gc_epoch(tx)?; + let owner = super::registry::current_owner(); + tx.execute( + "INSERT INTO base_refs(worker_scope,base_scope,nonce,membership_digest,worker_generation,state,owner_pid,owner_start,updated_epoch) VALUES(?1,?2,?3,?4,?5,'published',?6,?7,?8) ON CONFLICT(worker_scope,base_scope,nonce) DO UPDATE SET state='published',worker_generation=excluded.worker_generation,membership_digest=excluded.membership_digest,updated_epoch=excluded.updated_epoch", + params![ + worker_scope, + base_scope, + nonce, + membership_digest, + worker_generation, + owner.pid, + owner.start_time as i64, + epoch + ], + )?; + // A republication supersedes this worker's strict-rebuild record. + tx.execute( + "DELETE FROM strict_rebuild WHERE worker_scope=?1", + [worker_scope], + )?; + Ok(()) +} + +/// One worker publication that references a base. +pub struct WorkerPublication<'a> { + pub storage: &'a Path, + pub family: &'a str, + pub worker_scope: &'a str, + pub root: &'a Path, + /// The base header the delta was derived from. Its scope and digests are + /// checked against the pinned base; its generation is diagnostic. + pub base: &'a BaseHeader, + /// The delta: entries, tombstones and readiness overrides. Its `base` + /// header is replaced with the authenticated one before it is written. + pub delta: ManifestV2, + /// The durable worker generation the pointer CAS expects (`Wold`). + pub previous: Option, +} + +/// What a worker publication did. +#[derive(Debug)] +pub enum WorkerOutcome { + /// The pointer names the new generation and its referrer row is + /// `published`. The composed generation owns exact-generation markers on + /// both the worker and the base generation. + Published { + generation: String, + composed: Arc, + }, + /// Another publisher won the CAS. This job's intent row is gone; the caller + /// schedules a fresh, revalidated job against the winner. + Lost { + current: Option, + equivalent: bool, + }, +} + +// Test seam: runs after the worker's pointer CAS was attempted and before +// its outcome is handled, so tests can fail or observe that window. +#[cfg(test)] +thread_local! { + pub(crate) static AFTER_WORKER_CAS: std::cell::RefCell Option>>> = + const { std::cell::RefCell::new(None) }; +} + +/// Publishes a worker generation that references a base, following the +/// insertion barrier and publication order of the views design. +pub fn publish_worker( + request: WorkerPublication<'_>, + observer: Option<&dyn DurabilityObserver>, +) -> Result { + let WorkerPublication { + storage, + family, + worker_scope, + root, + base, + mut delta, + previous, + } = request; + let base_scope = base.scope.as_str(); + // (1) Inside the barrier: confirm the base, create the marker, then the + // intent row. File first: a rollback leaves only an orphan marker, which + // a sweep reclaims once this process is gone. + let (worker_dir, marker) = with_base_barrier(storage, family, |tx| { + let worker_dir = register_view_in(tx, storage, family, worker_scope, root)?; + let (kind, state, stored): (String, String, String) = tx + .query_row( + "SELECT kind,state,view_dir FROM members WHERE scope=?1", + [base_scope], + |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)), + ) + .optional()? + .ok_or_else(|| BaseRefError::Unavailable(format!("{base_scope} is not registered")))?; + if kind != KIND_BASE { + return Err(BaseRefError::Corrupt(format!("{base_scope} is not a base"))); + } + if state == STATE_EVICTING { + return Err(BaseRefError::Unavailable(format!( + "{base_scope} is evicting" + ))); + } + let base_dir = expected_dir(storage, family, base_scope, &stored)?; + let generation = ViewStore::existing_dir(base_dir.clone()) + .map(|store| store.current_generation_read_only()) + .transpose()? + .flatten() + .ok_or_else(|| BaseRefError::Unavailable(format!("{base_scope} has no pointer")))?; + let marker = BaseRefMarker::acquire(&base_dir, &generation)?; + let owner = super::registry::current_owner(); + tx.execute( + "INSERT INTO base_refs(worker_scope,base_scope,nonce,membership_digest,worker_generation,state,owner_pid,owner_start,updated_epoch) VALUES(?1,?2,?3,?4,NULL,'intent',?5,?6,?7)", + params![ + worker_scope, + base_scope, + marker.nonce(), + base.membership_digest, + owner.pid, + owner.start_time as i64, + gc_epoch(tx)? + ], + )?; + Ok((worker_dir, marker)) + })?; + let marked = boundary(observer, DurabilityStep::BaseRefMarked); + // Terminal cleanup: this acquisition's row goes under the barrier before + // its marker drops. + let cleanup = |marker: BaseRefMarker| -> Result<()> { + let result = with_base_barrier(storage, family, |tx| { + delete_intent(tx, worker_scope, base_scope, marker.nonce())?; + Ok(()) + }); + drop(marker); + result + }; + let abort = |error: BaseRefError, marker: BaseRefMarker| -> BaseRefError { + match cleanup(marker) { + Ok(()) => error, + Err(cleanup) => { + BaseRefError::Unavailable(format!("{error}; intent cleanup failed: {cleanup}")) + } + } + }; + + if let Err(error) = marked { + return Err(abort(error.into(), marker)); + } + + // (2) After the barrier: authenticate the base (pin-then-verify) and + // check that it is the marked generation or a readiness successor with + // the expected membership. + let authenticated = match resolve_base(storage, family, base_scope) + .and_then(|resolved| resolved.check_header(base).map(|()| resolved)) + { + Ok(resolved) => resolved, + Err(error) => return Err(abort(error, marker)), + }; + let header = authenticated.header(); + if let Err(error) = delta.set_base_header(header.clone()) { + return Err(abort(error.into(), marker)); + } + let base_generation = authenticated.into_generation(); + let worker_store = match ViewStore::open_dir(worker_dir.clone()) { + Ok(store) => store, + Err(error) => return Err(abort(error.into(), marker)), + }; + let name = match GenerationName::for_manifest(&delta) { + Ok(name) => name, + Err(error) => return Err(abort(error.into(), marker)), + }; + let generation = name.to_string(); + let prepared = match worker_store.prepare_v2(&name, previous.as_deref(), &delta, observer) { + Ok(prepared) => prepared, + Err(error) => return Err(abort(error.into(), marker)), + }; + // The bundle's own marker on the new worker generation exists before the + // CAS, so the generation is protected from the moment it is published. + let worker_marker = match ReadMarker::create(&worker_dir, &generation) { + Ok(marker) => marker, + Err(error) => return Err(abort(error.into(), marker)), + }; + let composed = match ComposedGeneration::compose( + generation.clone(), + delta, + base_generation, + Some(Residency::Marker(worker_marker)), + ) { + Ok(composed) => Arc::new(composed), + Err(error) => return Err(abort(error.into(), marker)), + }; + + // (3) Immediately before the CAS: the acquisition marker is still ours, + // and the base is still a registered, non-evicting base. + let verified = marker + .verify_held() + .map_err(BaseRefError::from) + .and_then(|held| { + if !held { + return Err(BaseRefError::Unavailable( + "the base marker was reclaimed before publication".into(), + )); + } + with_base_barrier(storage, family, |tx| { + let state: Option<(String, String)> = tx + .query_row( + "SELECT kind,state FROM members WHERE scope=?1", + [base_scope], + |r| Ok((r.get(0)?, r.get(1)?)), + ) + .optional()?; + match state { + Some((kind, state)) if kind == KIND_BASE && state != STATE_EVICTING => Ok(()), + _ => Err(BaseRefError::Unavailable(format!( + "{base_scope} left the registry before publication" + ))), + } + }) + }); + if let Err(error) = verified { + return Err(abort(error, marker)); + } + + // (4) The CAS. From here on the reference may be durable. + let outcome = worker_store.commit_v2(prepared, observer); + #[cfg(test)] + let outcome = + match AFTER_WORKER_CAS.with(|hook| hook.borrow_mut().as_mut().and_then(|hook| hook())) { + Some(error) => Err(super::ViewError::Io(error)), + None => outcome, + }; + let landed = match outcome { + Ok(PublishV2::Published) => Ok(()), + Ok(PublishV2::Conflict { current }) => { + cleanup(marker)?; + return Ok(WorkerOutcome::Lost { + current, + equivalent: false, + }); + } + Ok(PublishV2::EquivalentWinner { current }) => { + cleanup(marker)?; + return Ok(WorkerOutcome::Lost { + current: Some(current), + equivalent: true, + }); + } + Err(error) => { + // The CAS may have landed. Resolve it while keeping protection: a + // landed reference becomes `published`; only a pointer that + // certainly names something else allows abort cleanup; an + // unreadable pointer keeps the intent row for reconciliation. + match worker_store.current_generation_read_only() { + Ok(Some(current)) if current == generation => Err(error), + Ok(_) => return Err(abort(error.into(), marker)), + Err(_) => { + drop(marker); + return Err(error.into()); + } + } + } + }; + // (5) ReferrerPublished, under the barrier, before the marker drops. + let published = with_base_barrier(storage, family, |tx| { + publish_referrer( + tx, + worker_scope, + base_scope, + marker.nonce(), + &header.membership_digest, + &generation, + )?; + Ok(()) + }); + match published.and_then(|()| Ok(boundary(observer, DurabilityStep::ReferrerPublished)?)) { + Ok(()) => {} + Err(error) => { + // The reference stays protected: either the row is already + // `published`, or it is still `intent` and reconciliation repairs + // it from the published edge after this owner is gone. + drop(marker); + return Err(error); + } + } + // (6) The bundle owns its exact-generation markers; the insertion marker + // can go. + drop(marker); + landed.map_err(BaseRefError::from)?; + Ok(WorkerOutcome::Published { + generation, + composed, + }) +} + +/// Opens a v3 worker's published generation for a foreign or parent-folder +/// reader with a dual pin-then-verify: the worker's current W, then, when W +/// names a base, the base's current generation through [`resolve_base`], +/// checked against W's header. On any failed verify both markers drop and the +/// whole open retries, at most [`RESOLVE_ATTEMPTS`] times; then the base is +/// reported unavailable. The returned bundle owns both markers. Its answers are +/// those of the worker's last publication, labelled by +/// [`ComposedGeneration::published`]. +pub fn open_foreign_v3( + storage: &Path, + family: &str, + scope: &str, + producers: &super::manifest_v2::Producers, +) -> Result>> { + let mut last = None; + for _ in 0..RESOLVE_ATTEMPTS { + let Some((kind, state, stored)) = member_row(storage, family, scope)? else { + return Ok(None); + }; + if kind != KIND_VIEW { + return Err(BaseRefError::Corrupt(format!( + "{scope} is not a view member" + ))); + } + if state == STATE_EVICTING { + return Ok(None); + } + let view_dir = expected_dir(storage, family, scope, &stored)?; + let Some(store) = ViewStore::existing_dir(view_dir.clone()) else { + return Ok(None); + }; + let Some(generation) = store.current_generation_read_only()? else { + return Ok(None); + }; + let marker = ReadMarker::create(&view_dir, &generation)?; + if store.current_generation_read_only()?.as_deref() != Some(generation.as_str()) { + continue; + } + let manifest = store.load_manifest_v2(&generation)?; + manifest.ensure_producers(producers)?; + let Some(header) = manifest.base_header().cloned() else { + return Ok(Some(Arc::new(ComposedGeneration::new( + generation, + manifest, + Some(Residency::Marker(marker)), + )))); + }; + let base = match resolve_base(storage, family, &header.scope) + .and_then(|base| base.check_header(&header).map(|()| base)) + { + Ok(base) => base, + Err(error @ BaseRefError::Unavailable(_)) => { + last = Some(error); + continue; + } + Err(error) => return Err(error), + }; + return Ok(Some(Arc::new(ComposedGeneration::compose( + generation, + manifest, + base.into_generation(), + Some(Residency::Marker(marker)), + )?))); + } + Err(last.unwrap_or_else(|| { + BaseRefError::Unavailable(format!("{scope} moved during {RESOLVE_ATTEMPTS} attempts")) + })) +} + +#[cfg(test)] +#[path = "base_refs_tests.rs"] +pub(crate) mod tests; diff --git a/crates/aft/src/views/base_refs_tests.rs b/crates/aft/src/views/base_refs_tests.rs new file mode 100644 index 0000000000..55a562e3c2 --- /dev/null +++ b/crates/aft/src/views/base_refs_tests.rs @@ -0,0 +1,691 @@ +//! Tests of base identity, the worker insertion protocol and the foreign +//! reader. Fixture helpers are shared with the sweep and builder tests. + +use std::path::Path; +use std::sync::Arc; + +use super::*; +use crate::blob_store::v2::{ContentHash, FamilyKey, FamilyPlane}; +use crate::views::base_builder::{publish_base, BasePublication}; +use crate::views::manifest_v2::{EntryPlanes, ManifestHeader, Producers, PublishedFrontier}; +use crate::views::readiness::PlaneState; +use crate::views::RelPath; + +pub(crate) const FAMILY: &str = "00112233445566ff"; + +pub(crate) fn producers() -> Producers { + Producers { + trigram: "tri".into(), + semantic: Some("sem".into()), + callgraph: "graph".into(), + } +} + +pub(crate) fn header() -> ManifestHeader { + ManifestHeader { + producers: producers(), + head_tree: None, + ignore_fingerprint: None, + segment: None, + } +} + +/// The trigram key the fixtures use for `bytes`. +pub(crate) fn key_of(bytes: &[u8]) -> FamilyKey { + FamilyKey::new(FamilyPlane::Trigram, *blake3::hash(bytes).as_bytes()) +} + +pub(crate) fn path(name: &str) -> RelPath { + RelPath::new(name.as_bytes().to_vec()).unwrap() +} + +/// A regular entry whose trigram plane is Ready under `key_of(bytes)` and +/// whose semantic plane is still pending. +pub(crate) fn entry(bytes: &[u8]) -> EntryV2 { + EntryV2::regular( + ContentHash::of(bytes), + bytes.len() as u64, + EntryPlanes { + trigram: Some(PlaneState::ready(&key_of(bytes))), + semantic: Some(PlaneState::pending("awaiting the base owner")), + callgraph: None, + }, + ) +} + +pub(crate) fn base_manifest(files: &[(&str, &[u8])]) -> ManifestV2 { + let mut manifest = ManifestV2::new_v3(header(), None, PublishedFrontier::default()); + for (name, bytes) in files { + manifest.insert(path(name), entry(bytes)).unwrap(); + } + manifest +} + +/// Puts each payload into the family's v3 trigram store. +pub(crate) fn put_rows(storage: &Path, payloads: &[&[u8]]) { + v3::publish(storage, FAMILY, 1 << 20, None, |publication| { + for payload in payloads { + publication.put(&key_of(payload), payload, 1)?; + } + Ok(()) + }) + .unwrap(); +} + +pub(crate) fn plane_has(storage: &Path, payload: &[u8]) -> bool { + v3::PlaneReader::open_existing(storage, FAMILY, FamilyPlane::Trigram) + .unwrap() + .get(&key_of(payload)) + .unwrap() + .is_some() +} + +/// Publishes a base over `files` (rows first) and returns its identity and +/// generation. +pub(crate) fn publish_test_base(storage: &Path, files: &[(&str, &[u8])]) -> (BaseIdentity, String) { + put_rows( + storage, + &files.iter().map(|(_, bytes)| *bytes).collect::>(), + ); + let manifest = base_manifest(files); + let identity = BaseIdentity::of(FAMILY, &manifest).unwrap(); + let BasePublication::Published { generation, .. } = + publish_base(storage, FAMILY, &manifest, None).unwrap() + else { + panic!("first base publication lost") + }; + (identity, generation) +} + +pub(crate) fn frontier() -> PublishedFrontier { + PublishedFrontier { + delta_cut_version: 7, + watcher_epoch: 3, + published_at_ms: 1_000, + verified_frontier: None, + } +} + +/// A worker delta over the base: one changed file and one deleted base file. +pub(crate) fn worker_delta( + identity: &BaseIdentity, + generation: &str, + changed: &[u8], +) -> ManifestV2 { + let mut delta = ManifestV2::new_v3(header(), Some(identity.header(generation)), frontier()); + delta + .insert(path("src/changed.rs"), entry(changed)) + .unwrap(); + delta +} + +pub(crate) fn bind( + storage: &Path, + scope: &str, + identity: &BaseIdentity, + generation: &str, + delta: ManifestV2, + previous: Option, +) -> Result { + let root = storage.join("roots").join(scope); + std::fs::create_dir_all(&root).unwrap(); + let header = identity.header(generation); + publish_worker( + WorkerPublication { + storage, + family: FAMILY, + worker_scope: scope, + root: &root, + base: &header, + delta, + previous, + }, + None, + ) +} + +/// `(worker, base, state, worker_generation)` of every referrer row. +pub(crate) fn refs(storage: &Path) -> Vec<(String, String, String, Option)> { + LedgerReader::open_existing(storage, FAMILY) + .unwrap() + .read(|c| { + Ok(c.prepare("SELECT worker_scope,base_scope,state,worker_generation FROM base_refs ORDER BY worker_scope,base_scope,nonce")? + .query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)))? + .collect::>>()?) + }) + .unwrap() +} + +pub(crate) fn marker_files(dir: &Path) -> usize { + let readers = dir.join("readers"); + let Ok(generations) = std::fs::read_dir(readers) else { + return 0; + }; + generations + .flatten() + .map(|generation| std::fs::read_dir(generation.path()).map_or(0, |files| files.count())) + .sum() +} + +pub(crate) fn published(outcome: WorkerOutcome) -> (String, Arc) { + match outcome { + WorkerOutcome::Published { + generation, + composed, + } => (generation, composed), + other => panic!("expected a publication, got {other:?}"), + } +} + +#[test] +fn membership_digest_ignores_readiness_and_binds_content_and_paths() { + let a = base_manifest(&[("a.rs", b"one"), ("b.rs", b"two")]); + let mut ready = a.clone(); + if let Some(EntryV2::Regular { planes, .. }) = ready.get_mut(&path("a.rs")) { + planes.semantic = Some(PlaneState::ready(&FamilyKey::new( + FamilyPlane::Semantic, + [9; 32], + ))); + } + assert_eq!( + membership_digest(&a).unwrap(), + membership_digest(&ready).unwrap() + ); + let edited = base_manifest(&[("a.rs", b"one!"), ("b.rs", b"two")]); + assert_ne!( + membership_digest(&a).unwrap(), + membership_digest(&edited).unwrap() + ); + let renamed = base_manifest(&[("a2.rs", b"one"), ("b.rs", b"two")]); + assert_ne!( + membership_digest(&a).unwrap(), + membership_digest(&renamed).unwrap() + ); + let identity = BaseIdentity::of(FAMILY, &a).unwrap(); + assert!(identity.scope.starts_with(BASE_SCOPE_PREFIX)); + assert_ne!(identity, BaseIdentity::of("ffeeddccbbaa9988", &a).unwrap()); + let mut other_producer = a.clone(); + other_producer.header_mut().producers.semantic = None; + assert_ne!(identity, BaseIdentity::of(FAMILY, &other_producer).unwrap()); +} + +#[test] +fn a_membership_keyed_base_never_claims_tree_identity() { + let mut tree = base_manifest(&[("a.rs", b"one")]); + tree.header_mut().head_tree = Some("caller-supplied".into()); + assert!(matches!( + membership_digest(&tree), + Err(BaseRefError::Corrupt(_)) + )); + let mut oid = base_manifest(&[("a.rs", b"one")]); + if let Some(EntryV2::Regular { git_oid, .. }) = oid.get_mut(&path("a.rs")) { + *git_oid = Some("0".repeat(40)); + } + assert!(matches!( + membership_digest(&oid), + Err(BaseRefError::Corrupt(_)) + )); +} + +#[test] +fn resolution_authenticates_the_pinned_base_and_rejects_forged_headers() { + let _serial = v3::serial_for_test(); + let storage = tempfile::tempdir().unwrap(); + let (identity, generation) = publish_test_base(storage.path(), &[("a.rs", b"alpha")]); + let base = resolve_base(storage.path(), FAMILY, &identity.scope).unwrap(); + assert_eq!(base.generation(), generation); + assert_eq!(base.identity(), &identity); + assert_eq!(marker_files(base.view_dir()), 1); + base.check_header(&identity.header("any generation: diagnostic only")) + .unwrap(); + let mut forged = identity.header(&generation); + forged.membership_digest = "11".repeat(32); + assert!(matches!( + base.check_header(&forged), + Err(BaseRefError::Corrupt(_)) + )); + let mut forged = identity.header(&generation); + forged.identity_digest = "22".repeat(32); + assert!(matches!( + base.check_header(&forged), + Err(BaseRefError::Corrupt(_)) + )); + let dir = base.view_dir().to_path_buf(); + drop(base); + assert_eq!(marker_files(&dir), 0); + assert!(matches!( + resolve_base(storage.path(), FAMILY, &base_scope_for(&[3; 32])), + Err(BaseRefError::Unavailable(_)) + )); +} + +#[test] +fn a_manifest_stored_under_another_identity_is_never_authenticated() { + let _serial = v3::serial_for_test(); + let storage = tempfile::tempdir().unwrap(); + let (_identity, _) = publish_test_base(storage.path(), &[("a.rs", b"alpha")]); + // Register a base scope and give it a manifest whose identity derives a + // different scope: the stored strings are not proof. + let scope = base_scope_for(&[5; 32]); + let dir = with_base_barrier(storage.path(), FAMILY, |tx| { + Ok(crate::views::base_builder::register_base_in(tx, storage.path(), FAMILY, &scope)?.0) + }) + .unwrap(); + let store = ViewStore::open_dir(dir).unwrap(); + let manifest = base_manifest(&[("a.rs", b"beta")]); + let name = GenerationName::for_manifest(&manifest).unwrap(); + let prepared = store.prepare_v2(&name, None, &manifest, None).unwrap(); + assert_eq!( + store.commit_v2(prepared, None).unwrap(), + PublishV2::Published + ); + assert!(matches!( + resolve_base(storage.path(), FAMILY, &scope), + Err(BaseRefError::Corrupt(_)) + )); +} + +#[test] +fn a_worker_publication_records_a_published_referrer_and_composes_over_the_base() { + let _serial = v3::serial_for_test(); + let storage = tempfile::tempdir().unwrap(); + let (identity, generation) = publish_test_base( + storage.path(), + &[("src/a.rs", b"alpha"), ("src/changed.rs", b"old")], + ); + put_rows(storage.path(), &[b"new"]); + let (worker_generation, composed) = published( + bind( + storage.path(), + "worker-a", + &identity, + &generation, + worker_delta(&identity, &generation, b"new"), + None, + ) + .unwrap(), + ); + assert_eq!( + refs(storage.path()), + vec![( + "worker-a".to_string(), + identity.scope.clone(), + REF_PUBLISHED.to_string(), + Some(worker_generation.clone()) + )] + ); + // Lookups: the delta's changed entry, the inherited base entry. + assert_eq!( + composed.entry(&path("src/changed.rs")), + Some(&entry(b"new")) + ); + assert_eq!(composed.entry(&path("src/a.rs")), Some(&entry(b"alpha"))); + assert_eq!(composed.published(), Some(&frontier())); + // The bundle owns one marker on the base generation and one on its own; + // the insertion marker is gone. + let base_dir = v3::view_dir(storage.path(), FAMILY, &identity.scope).unwrap(); + let worker_dir = v3::view_dir(storage.path(), FAMILY, "worker-a").unwrap(); + assert_eq!(marker_files(&base_dir), 1); + assert_eq!(marker_files(&worker_dir), 1); + drop(composed); + assert_eq!(marker_files(&base_dir), 0); + assert_eq!(marker_files(&worker_dir), 0); + // Nothing under the unassigned namespace. + assert!(!storage.path().join("views/v3").exists()); +} + +#[test] +fn a_lost_cas_deletes_only_this_jobs_intent_row() { + let _serial = v3::serial_for_test(); + let storage = tempfile::tempdir().unwrap(); + let (identity, generation) = publish_test_base(storage.path(), &[("src/changed.rs", b"old")]); + put_rows(storage.path(), &[b"one", b"two"]); + let (first, _composed) = published( + bind( + storage.path(), + "worker-a", + &identity, + &generation, + worker_delta(&identity, &generation, b"one"), + None, + ) + .unwrap(), + ); + // A stale Wold: the CAS must lose, and only the losing job's row goes. + let outcome = bind( + storage.path(), + "worker-a", + &identity, + &generation, + worker_delta(&identity, &generation, b"two"), + None, + ) + .unwrap(); + assert!( + matches!(&outcome, WorkerOutcome::Lost { current: Some(current), equivalent: false } if *current == first), + "{outcome:?}" + ); + assert_eq!( + refs(storage.path()), + vec![( + "worker-a".into(), + identity.scope.clone(), + REF_PUBLISHED.into(), + Some(first) + )] + ); +} + +#[test] +fn a_failed_verify_cleans_up_its_intent_before_any_pointer_moves() { + let _serial = v3::serial_for_test(); + let storage = tempfile::tempdir().unwrap(); + let (identity, generation) = publish_test_base(storage.path(), &[("src/changed.rs", b"old")]); + let mut forged = identity.clone(); + forged.membership = [1; 32]; + let error = bind( + storage.path(), + "worker-a", + &forged, + &generation, + worker_delta(&identity, &generation, b"x"), + None, + ) + .unwrap_err(); + assert!(matches!(error, BaseRefError::Corrupt(_)), "{error}"); + assert!(refs(storage.path()).is_empty()); + let worker = v3::view_dir(storage.path(), FAMILY, "worker-a").unwrap(); + let pointer = + ViewStore::existing_dir(worker).map(|store| store.current_generation_read_only().unwrap()); + assert!(matches!(pointer, None | Some(None)), "{pointer:?}"); + let base_dir = v3::view_dir(storage.path(), FAMILY, &identity.scope).unwrap(); + assert_eq!(marker_files(&base_dir), 0); +} + +#[test] +fn a_landed_cas_is_published_and_never_erased_as_abort_cleanup() { + let _serial = v3::serial_for_test(); + let storage = tempfile::tempdir().unwrap(); + let (identity, generation) = publish_test_base(storage.path(), &[("src/changed.rs", b"old")]); + put_rows(storage.path(), &[b"landed"]); + AFTER_WORKER_CAS.with(|hook| { + *hook.borrow_mut() = Some(Box::new(|| Some(io::Error::other("sync failed after CAS")))) + }); + let result = bind( + storage.path(), + "worker-a", + &identity, + &generation, + worker_delta(&identity, &generation, b"landed"), + None, + ); + AFTER_WORKER_CAS.with(|hook| hook.borrow_mut().take()); + assert!(result.is_err()); + let worker = v3::view_dir(storage.path(), FAMILY, "worker-a").unwrap(); + let current = ViewStore::existing_dir(worker) + .unwrap() + .current_generation_read_only() + .unwrap() + .unwrap(); + assert_eq!( + refs(storage.path()), + vec![( + "worker-a".into(), + identity.scope.clone(), + REF_PUBLISHED.into(), + Some(current) + )] + ); + for _ in 0..3 { + let report = crate::views::base_sweep::sweep_family(storage.path(), FAMILY, None).unwrap(); + assert!(report.reclaimed_bases.is_empty(), "{report:?}"); + } + let reader = open_foreign_v3(storage.path(), FAMILY, "worker-a", &producers()) + .unwrap() + .unwrap(); + assert_eq!( + reader.entry(&path("src/changed.rs")), + Some(&entry(b"landed")) + ); + assert!(plane_has(storage.path(), b"old")); +} + +#[test] +fn a_foreign_reader_owns_both_markers_and_reports_the_published_frontier() { + let _serial = v3::serial_for_test(); + let storage = tempfile::tempdir().unwrap(); + let (identity, generation) = publish_test_base(storage.path(), &[("src/a.rs", b"alpha")]); + put_rows(storage.path(), &[b"new"]); + drop( + bind( + storage.path(), + "worker-a", + &identity, + &generation, + worker_delta(&identity, &generation, b"new"), + None, + ) + .unwrap(), + ); + let reader = crate::views::read::open_foreign_v3_generation( + storage.path(), + FAMILY, + "worker-a", + &producers(), + ) + .unwrap() + .unwrap(); + assert_eq!(reader.published(), Some(&frontier())); + assert_eq!(reader.entry(&path("src/a.rs")), Some(&entry(b"alpha"))); + let base_dir = v3::view_dir(storage.path(), FAMILY, &identity.scope).unwrap(); + let worker_dir = v3::view_dir(storage.path(), FAMILY, "worker-a").unwrap(); + assert_eq!(marker_files(&base_dir), 1); + assert_eq!(marker_files(&worker_dir), 1); + // The base cannot be reclaimed while the reader holds it, even after the + // worker's referrer row is gone. + with_base_barrier(storage.path(), FAMILY, |tx| { + tx.execute("DELETE FROM base_refs", [])?; + Ok(()) + }) + .unwrap(); + for _ in 0..3 { + let report = crate::views::base_sweep::sweep_family(storage.path(), FAMILY, None).unwrap(); + assert!(report.reclaimed_bases.is_empty(), "{report:?}"); + } + assert_eq!(reader.entry(&path("src/a.rs")), Some(&entry(b"alpha"))); + drop(reader); + assert_eq!(marker_files(&base_dir), 0); + assert_eq!(marker_files(&worker_dir), 0); +} + +#[test] +fn a_foreign_reader_reports_a_missing_base_unavailable_after_its_retries() { + let _serial = v3::serial_for_test(); + let storage = tempfile::tempdir().unwrap(); + let (identity, generation) = publish_test_base(storage.path(), &[("src/a.rs", b"alpha")]); + drop( + bind( + storage.path(), + "worker-a", + &identity, + &generation, + worker_delta(&identity, &generation, b"alpha"), + None, + ) + .unwrap(), + ); + // Remove the base behind the protocol's back (a damaged store). + with_base_barrier(storage.path(), FAMILY, |tx| { + tx.execute("DELETE FROM members WHERE kind='base'", [])?; + Ok(()) + }) + .unwrap(); + let error = open_foreign_v3(storage.path(), FAMILY, "worker-a", &producers()).unwrap_err(); + assert!(matches!(error, BaseRefError::Unavailable(_)), "{error}"); + let worker_dir = v3::view_dir(storage.path(), FAMILY, "worker-a").unwrap(); + assert_eq!( + marker_files(&worker_dir), + 0, + "every attempt dropped its marker" + ); +} + +#[test] +fn the_first_base_publication_needs_a_complete_lexical_plane() { + let _serial = v3::serial_for_test(); + let storage = tempfile::tempdir().unwrap(); + let mut manifest = base_manifest(&[("a.rs", b"alpha")]); + if let Some(EntryV2::Regular { planes, .. }) = manifest.get_mut(&path("a.rs")) { + planes.trigram = Some(PlaneState::pending("not tokenized")); + } + assert!(matches!( + publish_base(storage.path(), FAMILY, &manifest, None), + Err(BaseRefError::Corrupt(_)) + )); +} + +#[test] +fn readiness_successors_are_monotone_and_keep_identity_and_segment() { + use crate::views::base_builder::check_successor; + let base = base_manifest(&[("a.rs", b"alpha")]); + let set = |state: PlaneState| { + let mut next = base.clone(); + if let Some(EntryV2::Regular { planes, .. }) = next.get_mut(&path("a.rs")) { + planes.semantic = Some(state); + } + next + }; + let ready = set(PlaneState::ready(&FamilyKey::new( + FamilyPlane::Semantic, + [4; 32], + ))); + let failed = set(PlaneState::Failed { + reason: "model outage".into(), + producer: "sem".into(), + }); + check_successor(FAMILY, &base, &ready).unwrap(); + check_successor(FAMILY, &base, &failed).unwrap(); + check_successor(FAMILY, &failed, &ready).unwrap(); + assert!( + check_successor(FAMILY, &ready, &base).is_err(), + "Ready -> Pending" + ); + assert!( + check_successor(FAMILY, &ready, &failed).is_err(), + "Ready -> Failed" + ); + let other_ready = set(PlaneState::ready(&FamilyKey::new( + FamilyPlane::Semantic, + [5; 32], + ))); + assert!( + check_successor(FAMILY, &ready, &other_ready).is_err(), + "a Ready key changed" + ); + let mut segment = ready.clone(); + segment.header_mut().segment = Some("ab".repeat(32)); + assert!( + check_successor(FAMILY, &ready, &segment).is_err(), + "segment replaced" + ); + let edited = base_manifest(&[("a.rs", b"alpha!")]); + assert!( + check_successor(FAMILY, &base, &edited).is_err(), + "membership changed" + ); +} + +#[test] +fn an_injected_failure_at_each_publication_boundary_keeps_references_safe() { + use crate::views::base_sweep::sweep_family; + use crate::views::contracts::{inject_fault_at, InjectedFault}; + for fault in InjectedFault::ALL { + // BaseRegistered: the member row exists without a pointer. With no + // live claim it is an empty, unreferenced base and goes after two + // sweeps. + { + let _serial = v3::serial_for_test(); + let storage = tempfile::tempdir().unwrap(); + put_rows(storage.path(), &[b"alpha"]); + let manifest = base_manifest(&[("a.rs", b"alpha")]); + let identity = BaseIdentity::of(FAMILY, &manifest).unwrap(); + inject_fault_at(DurabilityStep::BaseRegistered, fault); + assert!(publish_base(storage.path(), FAMILY, &manifest, None).is_err()); + assert!(matches!( + resolve_base(storage.path(), FAMILY, &identity.scope), + Err(BaseRefError::Unavailable(_)) + )); + assert_eq!( + sweep_family(storage.path(), FAMILY, None) + .unwrap() + .counted_bases + .len(), + 1 + ); + assert_eq!( + sweep_family(storage.path(), FAMILY, None) + .unwrap() + .reclaimed_bases + .len(), + 1 + ); + } + // BaseSuccessorPublished: the pointer already names the successor. + { + let _serial = v3::serial_for_test(); + let storage = tempfile::tempdir().unwrap(); + let (identity, first) = publish_test_base(storage.path(), &[("a.rs", b"alpha")]); + let mut next = base_manifest(&[("a.rs", b"alpha")]); + if let Some(EntryV2::Regular { planes, .. }) = next.get_mut(&path("a.rs")) { + planes.semantic = Some(PlaneState::Failed { + reason: "model outage".into(), + producer: "sem".into(), + }); + } + inject_fault_at(DurabilityStep::BaseSuccessorPublished, fault); + assert!(publish_base(storage.path(), FAMILY, &next, None).is_err()); + let base = resolve_base(storage.path(), FAMILY, &identity.scope).unwrap(); + assert_ne!(base.generation(), first); + assert_eq!(base.manifest(), &next); + } + // BaseRefMarked: the job aborts and cleans up its own intent. + // ReferrerPublished: the row is already published and stays. + for step in [ + DurabilityStep::BaseRefMarked, + DurabilityStep::ReferrerPublished, + ] { + let _serial = v3::serial_for_test(); + let storage = tempfile::tempdir().unwrap(); + let (identity, generation) = + publish_test_base(storage.path(), &[("src/a.rs", b"alpha")]); + inject_fault_at(step, fault); + assert!(bind( + storage.path(), + "worker-a", + &identity, + &generation, + worker_delta(&identity, &generation, b"alpha"), + None, + ) + .is_err()); + let base_dir = v3::view_dir(storage.path(), FAMILY, &identity.scope).unwrap(); + assert_eq!(marker_files(&base_dir), 0, "{step:?} {fault:?}"); + let rows = refs(storage.path()); + if step == DurabilityStep::BaseRefMarked { + assert!(rows.is_empty(), "{step:?} {fault:?}: {rows:?}"); + } else { + assert_eq!(rows.len(), 1, "{step:?} {fault:?}: {rows:?}"); + assert_eq!(rows[0].2, REF_PUBLISHED); + for _ in 0..2 { + let report = sweep_family(storage.path(), FAMILY, None).unwrap(); + assert!(report.counted_bases.is_empty(), "{report:?}"); + } + assert!( + open_foreign_v3(storage.path(), FAMILY, "worker-a", &producers()) + .unwrap() + .is_some() + ); + } + } + } +} diff --git a/crates/aft/src/views/base_sweep.rs b/crates/aft/src/views/base_sweep.rs new file mode 100644 index 0000000000..6a9f146e60 --- /dev/null +++ b/crates/aft/src/views/base_sweep.rs @@ -0,0 +1,818 @@ +//! The v3 family sweep of the base-reference protocol: marking, dangling-edge +//! records, bounded referrer reconciliation, the two-sweep base reclaim and +//! the plane-row sweep. +//! +//! The whole sweep runs under the storage-root reservation lock. Every v3 +//! registry barrier and every v3 plane write takes that lock first, so no +//! referrer row, member row or plane row changes while the sweep marks. +//! Pointer compare-and-swaps and reader markers do not take the lock; the +//! protocol stays safe against them because: +//! +//! - a worker's `intent` row exists before its CAS, and an `intent` row whose +//! owner is live is never touched, so a reference that lands after marking +//! is still held by its row; +//! - rows written after the sweep began carry `updated_epoch >= S` and are not +//! visited by this sweep; +//! - readers mark first and verify the pointer after, so a reader either sees +//! a generation this sweep marked, or retries. +//! +//! Only I/O uncertainty aborts a sweep with nothing deleted. A dangling or +//! corrupt edge is counted, recorded in `strict_rebuild`, and the sweep goes +//! on. + +use std::collections::{BTreeMap, BTreeSet, HashSet}; +use std::io; +use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant}; + +use rusqlite::{params, OptionalExtension, Transaction}; + +use crate::blob_store::v3::{self, PlaneRowCursor, RegistryWriter, ReservationLock}; +use crate::pins::PinOwner; + +use super::base_refs::{ + barrier, gc_epoch, BaseIdentity, BaseRefError, Result, KIND_BASE, REF_INTENT, REF_ORPHANED, + REF_PUBLISHED, +}; +use super::contracts::{boundary, DurabilityObserver, DurabilityStep}; +use super::eviction::{v3_base_hold, Hold}; +use super::manifest_v2::ManifestV2; +use super::registry::STATE_EVICTING; +use super::ViewStore; + +/// Ruled referrer-reconciliation caps per family pass (pre-slice-4 ruling): +/// 256 rows or 25 ms, whichever comes first, cumulatively across +/// transactions. +pub const RECONCILE_ROWS: usize = 256; +pub const RECONCILE_TIME: Duration = Duration::from_millis(25); +/// Consecutive unreferenced sweeps before a base is reclaimed, mirroring +/// `MISSING_ROOT_SWEEPS`. +pub const UNREFERENCED_SWEEPS: i64 = 2; +/// Rows the plane-row sweep examines per visit before resuming next time. +pub const PLANE_ROWS_PER_VISIT: u64 = 100_000; + +/// Bounds of one sweep. Tests shrink them to reach a bound with a few rows. +#[derive(Clone, Copy, Debug)] +pub struct SweepBounds { + pub reconcile_rows: usize, + pub reconcile_time: Duration, + pub plane_rows: u64, + pub deadline: Option, +} + +impl Default for SweepBounds { + fn default() -> Self { + Self { + reconcile_rows: RECONCILE_ROWS, + reconcile_time: RECONCILE_TIME, + plane_rows: PLANE_ROWS_PER_VISIT, + deadline: None, + } + } +} + +/// An exact reference edge seen while marking. +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct Edge { + pub worker_scope: String, + pub worker_generation: String, + pub base_scope: String, + pub membership_digest: String, +} + +#[derive(Clone, Debug, Default)] +pub struct V3SweepReport { + pub epoch: i64, + pub marked_generations: usize, + pub marked_keys: usize, + pub valid_edges: usize, + /// Edges to a missing, non-base, chained, evicting or different-membership + /// base, counted on every sweep that sees them. + pub dangling_base_edges: usize, + pub reconciled_rows: usize, + pub kept_rows: usize, + pub deleted_rows: usize, + pub orphaned_rows: usize, + pub repaired_rows: usize, + /// Every row older than the sweep was visited in this pass. + pub reconcile_complete: bool, + pub counted_bases: Vec, + pub reclaimed_bases: Vec, + pub held_bases: Vec<(String, String)>, + pub plane_rows_examined: u64, + pub plane_rows_deleted: u64, + pub plane_rows_complete: bool, + /// Why nothing was swept (no registry, a version-1 registry, or a live + /// sweeper holding the lease). + pub skipped: Option, +} + +struct Member { + scope: String, + kind: String, + state: String, + view_dir: PathBuf, +} + +#[derive(Default)] +struct Marks { + keys: HashSet<[u8; 32]>, + generations: usize, + edges: BTreeSet, + /// Each base member's current identity; `None` when its current manifest + /// is not a valid base (a chain, a git_oid claim, a mismatched scope). + base_current: BTreeMap>, +} + +fn not_found(error: &super::ViewError) -> bool { + match error { + super::ViewError::IoAt { source, .. } | super::ViewError::Io(source) => { + source.kind() == io::ErrorKind::NotFound + } + _ => false, + } +} + +/// Generations of one member directory that must stay readable: current, +/// read-marked by a live owner, and pinned by a live owner. Pinned key lists +/// are marked directly. Markers and pins of dead owners are reclaimed on the +/// way. An unreadable pin or an over-long listing is uncertainty. +fn protected_generations( + view_dir: &Path, + keys: &mut HashSet<[u8; 32]>, +) -> Result> { + let mut generations = BTreeSet::new(); + if let Some(store) = ViewStore::existing_dir(view_dir.to_path_buf()) { + if let Some(current) = store.current_generation_read_only()? { + generations.insert(current); + } + } + let cap = crate::storage_retention::TREE_ENTRIES; + for entry in crate::storage_retention::bounded_dirs(&view_dir.join("readers"), cap)? { + if !entry.file_type()?.is_dir() { + continue; + } + let Some(generation) = entry.file_name().to_str().map(str::to_owned) else { + return Err(BaseRefError::Io(io::Error::other( + "non-UTF-8 reader directory", + ))); + }; + if crate::root_cache::sweep_read_markers(view_dir, &generation).protected { + generations.insert(generation); + } + } + for entry in crate::storage_retention::bounded_dirs(&view_dir.join("pins"), cap)? { + let path = entry.path(); + if path.extension().and_then(|extension| extension.to_str()) != Some("json") { + continue; + } + let metadata = crate::pins::read_metadata_strict(&path) + .map_err(|error| BaseRefError::Io(io::Error::other(error.to_string())))?; + let (metadata_path, keys_path) = crate::pins::pin_paths(view_dir, &metadata.generation); + if metadata_path != path { + return Err(BaseRefError::Io(io::Error::other(format!( + "pin {} names another generation", + path.display() + )))); + } + if !crate::pins::owner_is_live(&metadata.owner) { + let _ = std::fs::remove_file(&metadata_path); + let _ = std::fs::remove_file(&keys_path); + continue; + } + keys.extend( + crate::pins::read_keys(&keys_path) + .map_err(|error| BaseRefError::Io(io::Error::other(error.to_string())))?, + ); + generations.insert(metadata.generation); + } + Ok(generations) +} + +fn load(view_dir: &Path, generation: &str) -> Result> { + let Some(store) = ViewStore::existing_dir(view_dir.to_path_buf()) else { + return Ok(None); + }; + match store.load_manifest_v2(generation) { + Ok(manifest) => Ok(Some(manifest)), + // Nothing to mark, exactly as `mark_generation_if_present` treats a + // missing v2 manifest (for example, a private name pinned before its + // manifest was written). + Err(error) if not_found(&error) => Ok(None), + Err(super::ViewError::GenerationMismatch(_)) => Ok(None), + Err(error) => Err(error.into()), + } +} + +fn mark_member(family: &str, member: &Member, marks: &mut Marks) -> Result<()> { + let current = ViewStore::existing_dir(member.view_dir.clone()) + .map(|store| store.current_generation_read_only()) + .transpose()? + .flatten(); + for generation in protected_generations(&member.view_dir, &mut marks.keys)? { + let Some(manifest) = load(&member.view_dir, &generation)? else { + continue; + }; + marks.generations += 1; + marks + .keys + .extend(manifest.ready_keys().map(|key| *key.as_bytes())); + if member.kind == KIND_BASE { + if current.as_deref() == Some(generation.as_str()) { + let identity = BaseIdentity::of(family, &manifest) + .ok() + .filter(|identity| identity.scope == member.scope); + marks.base_current.insert(member.scope.clone(), identity); + } + continue; + } + if let Some(header) = manifest.base_header() { + marks.edges.insert(Edge { + worker_scope: member.scope.clone(), + worker_generation: generation, + base_scope: header.scope.clone(), + membership_digest: header.membership_digest.clone(), + }); + } + } + Ok(()) +} + +/// Why an edge protects nothing, or `None` for a valid edge. +fn dangling_reason( + edge: &Edge, + members: &BTreeMap, + marks: &Marks, +) -> Option<&'static str> { + let Some(base) = members.get(&edge.base_scope) else { + return Some("base_not_registered"); + }; + if base.kind != KIND_BASE { + return Some("not_a_base"); + } + if base.state == STATE_EVICTING { + return Some("base_evicting"); + } + match marks.base_current.get(&edge.base_scope) { + None => Some("base_unpublished"), + Some(None) => Some("base_corrupt"), + Some(Some(identity)) + if crate::blob_store::v2::to_hex(&identity.membership) != edge.membership_digest => + { + Some("membership_changed") + } + Some(Some(_)) => None, + } +} + +/// Runs one sweep, taking the storage-root reservation lock. +pub fn sweep_family( + storage: &Path, + family: &str, + observer: Option<&dyn DurabilityObserver>, +) -> Result { + // A standalone sweep treats `storage` as both the artifact and the + // durable root, and declares it so a reclaimed base directory may be + // removed. The retention collector declares split roots itself. + crate::root_cache::register_durable_owner(&crate::root_cache::StoragePaths::same(storage))?; + let lock = ReservationLock::acquire(storage)?; + sweep_family_locked( + storage, + family, + &lock, + observer, + SweepBounds::default(), + None, + ) +} + +/// Runs one sweep under the caller's reservation lock. `plane_cursor` is the +/// file that keeps the plane-row sweep's resume position between visits; a +/// sweep without one restarts the plane scan from the beginning. +pub fn sweep_family_locked( + storage: &Path, + family: &str, + lock: &ReservationLock, + observer: Option<&dyn DurabilityObserver>, + bounds: SweepBounds, + plane_cursor: Option<&Path>, +) -> Result { + let mut report = V3SweepReport::default(); + if !v3::family_dir(storage, family)? + .join("members.sqlite") + .try_exists()? + { + report.skipped = Some("no_registry".into()); + return Ok(report); + } + let mut registry = RegistryWriter::open_for_barrier(storage, family, lock)?; + if registry.registry_version()? < v3::REGISTRY_BASE_REFS_VERSION { + report.skipped = Some("registry_without_base_refs".into()); + return Ok(report); + } + let me = super::registry::current_owner(); + // Begin: take the sweep lease and raise the epoch. Everything written + // from here on carries `updated_epoch >= S`. + let begun = barrier(&mut registry, |tx| { + let lease: (Option, Option) = tx.query_row( + "SELECT lease_pid,lease_start FROM sweep_state WHERE singleton=1", + [], + |r| Ok((r.get(0)?, r.get(1)?)), + )?; + if let (Some(pid), Some(start)) = lease { + let owner = PinOwner { + pid, + start_time: start as u64, + }; + if owner != me && crate::pins::owner_is_live(&owner) { + return Ok(None); + } + } + tx.execute( + "UPDATE sweep_state SET gc_epoch=gc_epoch+1,lease_pid=?1,lease_start=?2 WHERE singleton=1", + params![me.pid, me.start_time as i64], + )?; + Ok(Some(gc_epoch(tx)?)) + })?; + let Some(epoch) = begun else { + report.skipped = Some("sweep_lease_held".into()); + return Ok(report); + }; + report.epoch = epoch; + let result = sweep_inner( + storage, + family, + &mut registry, + epoch, + observer, + bounds, + plane_cursor, + &mut report, + ); + let released = barrier(&mut registry, |tx| { + tx.execute( + "UPDATE sweep_state SET lease_pid=NULL,lease_start=NULL WHERE singleton=1 AND lease_pid=?1", + [me.pid], + )?; + Ok(()) + }); + result?; + released?; + Ok(report) +} + +#[allow(clippy::too_many_arguments)] +fn sweep_inner( + storage: &Path, + family: &str, + registry: &mut RegistryWriter<'_>, + epoch: i64, + observer: Option<&dyn DurabilityObserver>, + bounds: SweepBounds, + plane_cursor: Option<&Path>, + report: &mut V3SweepReport, +) -> Result<()> { + let members: Vec = barrier(registry, |tx| { + let mut statement = + tx.prepare("SELECT scope,kind,state,view_dir FROM members ORDER BY scope")?; + let rows = statement + .query_map([], |r| { + Ok(( + r.get::<_, String>(0)?, + r.get::<_, String>(1)?, + r.get::<_, String>(2)?, + r.get::<_, String>(3)?, + )) + })? + .collect::>>()?; + Ok(rows) + })? + .into_iter() + .filter_map(|(scope, kind, state, stored)| { + // A row naming any other directory is never followed or deleted. + let dir = v3::view_dir(storage, family, &scope).ok()?; + (Path::new(&stored) == dir).then_some(Member { + scope, + kind, + state, + view_dir: dir, + }) + }) + .collect(); + + // Finish base reclaims a crash interrupted after `evicting` committed. + for member in members + .iter() + .filter(|member| member.kind == KIND_BASE && member.state == STATE_EVICTING) + { + finish_base_reclaim(registry, member)?; + report.reclaimed_bases.push(member.scope.clone()); + } + + // Mark outside the barrier. Any I/O uncertainty aborts with nothing + // deleted. + let mut marks = Marks::default(); + for member in members + .iter() + .filter(|member| member.state != STATE_EVICTING) + { + mark_member(family, member, &mut marks)?; + } + report.marked_generations = marks.generations; + report.marked_keys = marks.keys.len(); + let by_scope: BTreeMap = members + .iter() + .map(|member| (member.scope.clone(), member)) + .collect(); + let mut valid = BTreeSet::new(); + let mut dangling = Vec::new(); + for edge in &marks.edges { + match dangling_reason(edge, &by_scope, &marks) { + None => { + valid.insert(edge.clone()); + } + Some(reason) => dangling.push((edge.clone(), reason)), + } + } + report.valid_edges = valid.len(); + report.dangling_base_edges = dangling.len(); + if !dangling.is_empty() { + barrier(registry, |tx| { + for (edge, reason) in &dangling { + tx.execute( + "INSERT INTO strict_rebuild(worker_scope,worker_generation,reason,recorded_epoch) VALUES(?1,?2,?3,?4) ON CONFLICT(worker_scope,worker_generation) DO UPDATE SET reason=excluded.reason,recorded_epoch=excluded.recorded_epoch", + params![edge.worker_scope, edge.worker_generation, reason, epoch], + )?; + } + tx.execute( + "UPDATE sweep_state SET dangling_base_edges=dangling_base_edges+?1 WHERE singleton=1", + [dangling.len() as i64], + )?; + Ok(()) + })?; + } + + reconcile(registry, epoch, &valid, bounds, report)?; + repair_missing(registry, epoch, &valid, report)?; + let referenced: BTreeSet<&str> = valid.iter().map(|edge| edge.base_scope.as_str()).collect(); + for member in members + .iter() + .filter(|member| member.kind == KIND_BASE && member.state != STATE_EVICTING) + { + count_or_reclaim(registry, member, &referenced, observer, report)?; + } + + // Plane rows last: the markers and pins listed above may have been the + // only protection of rows that a reclaimed base named. + let mut cursor = plane_cursor + .and_then(|path| std::fs::read(path).ok()) + .and_then(|bytes| serde_json::from_slice::(&bytes).ok()) + .unwrap_or_default(); + let swept = registry.sweep_plane_rows( + epoch, + &marks.keys, + &mut cursor, + bounds.plane_rows, + bounds.deadline, + )?; + report.plane_rows_examined = swept.examined; + report.plane_rows_deleted = swept.deleted; + report.plane_rows_complete = swept.complete; + if let Some(path) = plane_cursor { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + let bytes = serde_json::to_vec(&cursor).map_err(io::Error::other)?; + let temporary = path.with_extension("json.tmp"); + std::fs::write(&temporary, bytes)?; + std::fs::rename(&temporary, path)?; + } + Ok(()) +} + +type Row = ( + String, + String, + String, + String, + Option, + String, + u32, + i64, +); + +fn row_key(row: &Row) -> String { + format!("{}\u{1f}{}\u{1f}{}", row.1, row.0, row.2) +} + +/// Visits `base_refs` rows older than the sweep (`updated_epoch < S`) in key +/// order from the saved position, within the ruled row and time caps. Rows +/// written during this sweep are never visited, so never deleted by it. +fn reconcile( + registry: &mut RegistryWriter<'_>, + epoch: i64, + valid: &BTreeSet, + bounds: SweepBounds, + report: &mut V3SweepReport, +) -> Result<()> { + let started = Instant::now(); + let saved: Option = barrier(registry, |tx| { + Ok(tx.query_row( + "SELECT reconcile_cursor FROM sweep_state WHERE singleton=1", + [], + |r| r.get(0), + )?) + })?; + // One lap: from the saved position to the end, then from the start up to + // and including the saved position. + let mut after = saved.clone().unwrap_or_default(); + let mut wrapped = saved.is_none(); + let mut complete = false; + let edge_of = |row: &Row| Edge { + worker_scope: row.0.clone(), + worker_generation: row.4.clone().unwrap_or_default(), + base_scope: row.1.clone(), + membership_digest: row.3.clone(), + }; + // Any valid edge for this worker/base/membership: the repair target for a + // dead owner's row, whose generation the row itself may not record. + let repair_target = |row: &Row| -> Option { + valid + .iter() + .filter(|edge| { + edge.worker_scope == row.0 + && edge.base_scope == row.1 + && edge.membership_digest == row.3 + }) + .map(|edge| edge.worker_generation.clone()) + .next_back() + }; + while report.reconciled_rows < bounds.reconcile_rows + && started.elapsed() < bounds.reconcile_time + { + let room = (bounds.reconcile_rows - report.reconciled_rows).min(64); + let stop_at = (wrapped).then(|| saved.clone()).flatten(); + let batch_started = Instant::now(); + let done = barrier(registry, |tx| { + let rows: Vec = tx + .prepare( + "SELECT worker_scope,base_scope,nonce,membership_digest,worker_generation,state,owner_pid,owner_start FROM base_refs WHERE updated_epoch?2 ORDER BY (base_scope||char(31)||worker_scope||char(31)||nonce) LIMIT ?3", + )? + .query_map(params![epoch, after, room as i64], |r| { + Ok(( + r.get(0)?, + r.get(1)?, + r.get(2)?, + r.get(3)?, + r.get(4)?, + r.get(5)?, + r.get(6)?, + r.get(7)?, + )) + })? + .collect::>()?; + if rows.is_empty() { + return Ok(true); + } + for row in rows { + let key = row_key(&row); + if stop_at.as_deref().is_some_and(|stop| key.as_str() > stop) { + return Ok(true); + } + after = key; + report.reconciled_rows += 1; + let id = params![row.0, row.1, row.2]; + let owner_live = crate::pins::owner_is_live(&PinOwner { + pid: row.6, + start_time: row.7 as u64, + }); + match row.5.as_str() { + REF_PUBLISHED => { + let edge = edge_of(&row); + // Coalesce duplicate acquisitions of one edge: keep + // the first refreshed row, retire the rest. + let duplicate: bool = tx.query_row( + "SELECT EXISTS(SELECT 1 FROM base_refs WHERE worker_scope=?1 AND base_scope=?2 AND worker_generation=?3 AND membership_digest=?4 AND state='published' AND updated_epoch>=?5)", + params![row.0, row.1, edge.worker_generation, row.3, epoch], + |r| r.get(0), + )?; + if valid.contains(&edge) && !duplicate { + tx.execute( + "UPDATE base_refs SET updated_epoch=?4 WHERE worker_scope=?1 AND base_scope=?2 AND nonce=?3", + params![row.0, row.1, row.2, epoch], + )?; + report.kept_rows += 1; + } else { + tx.execute( + "DELETE FROM base_refs WHERE worker_scope=?1 AND base_scope=?2 AND nonce=?3", + id, + )?; + report.deleted_rows += 1; + } + } + REF_INTENT if owner_live => report.kept_rows += 1, + REF_INTENT | REF_ORPHANED => match repair_target(&row) { + Some(generation) => { + tx.execute( + "UPDATE base_refs SET state='published',worker_generation=?4,updated_epoch=?5 WHERE worker_scope=?1 AND base_scope=?2 AND nonce=?3", + params![row.0, row.1, row.2, generation, epoch], + )?; + report.repaired_rows += 1; + } + // A dead owner's intent may describe a CAS that landed + // after this sweep marked its worker. Keep it one more + // sweep, whose marking starts after the owner died. + None if row.5 == REF_INTENT => { + tx.execute( + "UPDATE base_refs SET state='orphaned',updated_epoch=?4 WHERE worker_scope=?1 AND base_scope=?2 AND nonce=?3", + params![row.0, row.1, row.2, epoch], + )?; + report.orphaned_rows += 1; + } + None => { + tx.execute( + "DELETE FROM base_refs WHERE worker_scope=?1 AND base_scope=?2 AND nonce=?3", + id, + )?; + report.deleted_rows += 1; + } + }, + // Unknown states are kept: an unknown row is a hold. + _ => report.kept_rows += 1, + } + if batch_started.elapsed() >= bounds.reconcile_time { + break; + } + } + Ok(false) + })?; + if done { + if wrapped { + complete = true; + break; + } + wrapped = true; + after.clear(); + } + } + report.reconcile_complete = complete; + let next = (!complete).then_some(after); + barrier(registry, |tx| { + tx.execute( + "UPDATE sweep_state SET reconcile_cursor=?1 WHERE singleton=1", + [next], + )?; + Ok(()) + }) +} + +/// Inserts a `published` row for every valid edge that has none, so a +/// reference whose owner died before `ReferrerPublished` (and whose intent +/// row was already retired) is held again. Dangling observations never +/// create rows. +fn repair_missing( + registry: &mut RegistryWriter<'_>, + epoch: i64, + valid: &BTreeSet, + report: &mut V3SweepReport, +) -> Result<()> { + if valid.is_empty() { + return Ok(()); + } + let me = super::registry::current_owner(); + barrier(registry, |tx| { + for edge in valid { + let present: bool = tx.query_row( + "SELECT EXISTS(SELECT 1 FROM base_refs WHERE worker_scope=?1 AND base_scope=?2 AND worker_generation=?3 AND membership_digest=?4 AND state='published')", + params![edge.worker_scope, edge.base_scope, edge.worker_generation, edge.membership_digest], + |r| r.get(0), + )?; + if present { + continue; + } + tx.execute( + "INSERT INTO base_refs(worker_scope,base_scope,nonce,membership_digest,worker_generation,state,owner_pid,owner_start,updated_epoch) VALUES(?1,?2,?3,?4,?5,'published',?6,?7,?8)", + params![ + edge.worker_scope, + edge.base_scope, + format!("repair-{}", super::base_refs::fresh_nonce()), + edge.membership_digest, + edge.worker_generation, + me.pid, + me.start_time as i64, + epoch + ], + )?; + report.repaired_rows += 1; + } + Ok(()) + }) +} + +/// Hysteresis counter and reclaim of one base. The barrier re-check is the +/// safety authority; the count only delays deletion by one sweep. +fn count_or_reclaim( + registry: &mut RegistryWriter<'_>, + member: &Member, + referenced: &BTreeSet<&str>, + observer: Option<&dyn DurabilityObserver>, + report: &mut V3SweepReport, +) -> Result<()> { + enum Step { + Held(Hold), + Counted, + Reclaim, + } + let step = barrier(registry, |tx| { + let hold = if referenced.contains(member.scope.as_str()) { + Some(Hold::Referenced) + } else { + v3_base_hold(tx, &member.scope, &member.view_dir) + }; + if let Some(hold) = hold { + tx.execute( + "DELETE FROM base_sweeps WHERE base_scope=?1", + [&member.scope], + )?; + return Ok(Step::Held(hold)); + } + let count: i64 = tx + .query_row( + "SELECT unreferenced_sweeps FROM base_sweeps WHERE base_scope=?1", + [&member.scope], + |r| r.get(0), + ) + .optional()? + .unwrap_or(0) + + 1; + if count < UNREFERENCED_SWEEPS { + tx.execute( + "INSERT INTO base_sweeps(base_scope,unreferenced_sweeps) VALUES(?1,?2) ON CONFLICT(base_scope) DO UPDATE SET unreferenced_sweeps=excluded.unreferenced_sweeps", + params![member.scope, count], + )?; + return Ok(Step::Counted); + } + // Second consecutive unreferenced sweep: commit `evicting` in the + // same transaction as the re-check. + tx.execute( + "UPDATE members SET state=?2 WHERE scope=?1 AND kind='base'", + params![member.scope, STATE_EVICTING], + )?; + Ok(Step::Reclaim) + })?; + match step { + Step::Held(hold) => report + .held_bases + .push((member.scope.clone(), hold.to_string())), + Step::Counted => { + report.counted_bases.push(member.scope.clone()); + boundary(observer, DurabilityStep::BaseUnreferencedCounted)?; + } + Step::Reclaim => { + remove_pointer(&member.view_dir)?; + boundary(observer, DurabilityStep::BaseReclaimBegun)?; + finish_base_reclaim(registry, member)?; + report.reclaimed_bases.push(member.scope.clone()); + } + } + Ok(()) +} + +/// Removes the base pointer first, so a binder that finds the directory +/// without a pointer treats the base as absent. +fn remove_pointer(view_dir: &Path) -> Result<()> { + for suffix in ["", "-wal", "-shm"] { + match std::fs::remove_file(view_dir.join(format!("{}{suffix}", super::POINTER_DATABASE))) { + Ok(()) => {} + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(error) => return Err(error.into()), + } + } + Ok(()) +} + +/// Removes an `evicting` base: pointer, directory, then its rows and member +/// row in one transaction. Safe to repeat after a crash. +fn finish_base_reclaim(registry: &mut RegistryWriter<'_>, member: &Member) -> Result<()> { + remove_pointer(&member.view_dir)?; + super::registry::remove_view_dir(&member.view_dir)?; + barrier(registry, |tx| { + delete_base_rows(tx, &member.scope)?; + Ok(()) + }) +} + +pub(crate) fn delete_base_rows(tx: &Transaction<'_>, scope: &str) -> rusqlite::Result<()> { + tx.execute("DELETE FROM base_refs WHERE base_scope=?1", [scope])?; + tx.execute("DELETE FROM base_builds WHERE base_scope=?1", [scope])?; + tx.execute("DELETE FROM base_sweeps WHERE base_scope=?1", [scope])?; + tx.execute( + "DELETE FROM members WHERE scope=?1 AND state=?2", + params![scope, STATE_EVICTING], + )?; + Ok(()) +} + +#[cfg(test)] +#[path = "base_sweep_tests.rs"] +mod tests; diff --git a/crates/aft/src/views/base_sweep_tests.rs b/crates/aft/src/views/base_sweep_tests.rs new file mode 100644 index 0000000000..5c2773d8cb --- /dev/null +++ b/crates/aft/src/views/base_sweep_tests.rs @@ -0,0 +1,566 @@ +//! Safety tests of the v3 base-reference sweep: referenced bases survive, +//! unreferenced bases go after two sweeps, dangling edges never abort, dead +//! owners' rows are retired or repaired, rows do not grow with +//! republication, and plane rows follow the marked closure. + +use std::path::Path; + +use super::*; +use crate::views::base_refs::tests::{ + base_manifest, bind, entry, frontier, header, key_of, path, plane_has, publish_test_base, + published, put_rows, refs, worker_delta, FAMILY, +}; +use crate::views::base_refs::{with_base_barrier, BaseIdentity, WorkerOutcome}; +use crate::views::manifest_v2::{GenerationName, PublishV2}; + +fn sweep(storage: &Path) -> V3SweepReport { + sweep_family(storage, FAMILY, None).unwrap() +} + +fn base_dir(storage: &Path, identity: &BaseIdentity) -> PathBuf { + v3::view_dir(storage, FAMILY, &identity.scope).unwrap() +} + +fn base_registered(storage: &Path, identity: &BaseIdentity) -> bool { + v3::LedgerReader::open_existing(storage, FAMILY) + .unwrap() + .member(&identity.scope) + .unwrap() + .is_some() +} + +/// Publishes a self-contained v3 generation for `scope`, dropping its base +/// reference (a worker that rebuilt on the strict path). +fn republish_without_base(storage: &Path, scope: &str, previous: &str) -> String { + let dir = v3::view_dir(storage, FAMILY, scope).unwrap(); + let store = ViewStore::open_dir(dir).unwrap(); + let manifest = base_manifest(&[("own.rs", b"own")]); + put_rows(storage, &[b"own"]); + let name = GenerationName::for_manifest(&manifest).unwrap(); + let prepared = store + .prepare_v2(&name, Some(previous), &manifest, None) + .unwrap(); + assert_eq!( + store.commit_v2(prepared, None).unwrap(), + PublishV2::Published + ); + crate::views::wait_for_generation_maintenance_for_test(store.view_dir()); + name.to_string() +} + +#[test] +fn a_referenced_base_survives_every_sweep_until_its_last_reference_goes() { + let _serial = v3::serial_for_test(); + let storage = tempfile::tempdir().unwrap(); + let (identity, generation) = publish_test_base(storage.path(), &[("src/a.rs", b"alpha")]); + put_rows(storage.path(), &[b"new"]); + let (worker_generation, composed) = published( + bind( + storage.path(), + "worker-a", + &identity, + &generation, + worker_delta(&identity, &generation, b"new"), + None, + ) + .unwrap(), + ); + drop(composed); + for _ in 0..4 { + let report = sweep(storage.path()); + assert!(report.reclaimed_bases.is_empty(), "{report:?}"); + assert!(report.counted_bases.is_empty(), "{report:?}"); + assert_eq!(report.valid_edges, 1, "{report:?}"); + assert!(plane_has(storage.path(), b"alpha")); + assert!(plane_has(storage.path(), b"new")); + } + // The worker rebuilds without the base: one sweep counts, the next + // reclaims, and never in a single sweep. + republish_without_base(storage.path(), "worker-a", &worker_generation); + let first = sweep(storage.path()); + assert_eq!( + first.counted_bases, + vec![identity.scope.clone()], + "{first:?}" + ); + assert!(first.reclaimed_bases.is_empty()); + assert!(base_registered(storage.path(), &identity)); + assert!(base_dir(storage.path(), &identity).exists()); + let second = sweep(storage.path()); + assert_eq!( + second.reclaimed_bases, + vec![identity.scope.clone()], + "{second:?}" + ); + assert!(!base_registered(storage.path(), &identity)); + assert!(!base_dir(storage.path(), &identity).exists()); + assert!(refs(storage.path()).is_empty()); + // Its rows went with the plane sweep of the next pass; the worker's own + // rows stay. + sweep(storage.path()); + assert!(!plane_has(storage.path(), b"alpha")); + assert!(plane_has(storage.path(), b"own")); + assert!(!storage.path().join("views/v3").exists()); +} + +#[test] +fn an_unreferenced_base_is_reclaimed_after_two_consecutive_sweeps_and_a_hold_resets_the_count() { + let _serial = v3::serial_for_test(); + let storage = tempfile::tempdir().unwrap(); + let (identity, generation) = publish_test_base(storage.path(), &[("a.rs", b"alpha")]); + assert_eq!( + sweep(storage.path()).counted_bases, + vec![identity.scope.clone()] + ); + // A reader appears between the sweeps: the count resets. + let marker = + crate::root_cache::ReadMarker::create(&base_dir(storage.path(), &identity), &generation) + .unwrap(); + let held = sweep(storage.path()); + assert!( + held.reclaimed_bases.is_empty() && held.counted_bases.is_empty(), + "{held:?}" + ); + drop(marker); + assert_eq!( + sweep(storage.path()).counted_bases, + vec![identity.scope.clone()] + ); + assert!(base_registered(storage.path(), &identity)); + assert_eq!( + sweep(storage.path()).reclaimed_bases, + vec![identity.scope.clone()] + ); +} + +#[test] +fn dangling_edges_are_counted_and_recorded_and_the_sweep_continues() { + let _serial = v3::serial_for_test(); + let storage = tempfile::tempdir().unwrap(); + let (identity, generation) = publish_test_base(storage.path(), &[("a.rs", b"alpha")]); + put_rows(storage.path(), &[b"garbage"]); + // A worker whose manifest names a base that does not exist. + let ghost = BaseIdentity { + scope: crate::views::base_refs::base_scope_for(&[8; 32]), + membership: [8; 32], + identity: [8; 32], + }; + let dir = with_base_barrier(storage.path(), FAMILY, |tx| { + crate::views::base_refs::register_view_in( + tx, + storage.path(), + FAMILY, + "dangling", + storage.path(), + ) + }) + .unwrap(); + let store = ViewStore::open_dir(dir).unwrap(); + let delta = worker_delta(&ghost, "g", b"alpha"); + let name = GenerationName::for_manifest(&delta).unwrap(); + let prepared = store.prepare_v2(&name, None, &delta, None).unwrap(); + store.commit_v2(prepared, None).unwrap(); + let report = sweep(storage.path()); + assert_eq!(report.dangling_base_edges, 1, "{report:?}"); + assert_eq!(report.valid_edges, 0); + // The rest of the sweep ran: the unreferenced base was counted and the + // garbage row deleted, while the dangling worker's own key survived. + assert_eq!(report.counted_bases, vec![identity.scope.clone()]); + assert!(!plane_has(storage.path(), b"garbage")); + assert!(plane_has(storage.path(), b"alpha")); + let recorded: Vec<(String, String, String)> = + v3::LedgerReader::open_existing(storage.path(), FAMILY) + .unwrap() + .read(|c| { + Ok( + c.prepare("SELECT worker_scope,worker_generation,reason FROM strict_rebuild")? + .query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)))? + .collect::>>()?, + ) + }) + .unwrap(); + assert_eq!( + recorded, + vec![( + "dangling".into(), + name.to_string(), + "base_not_registered".into() + )] + ); + // A dangling observation never creates a hold. + assert!(refs(storage.path()).is_empty()); + let _ = generation; +} + +/// Inserts an `intent` row owned by a process that is certainly gone. +fn dead_intent(storage: &Path, worker: &str, identity: &BaseIdentity) { + with_base_barrier(storage, FAMILY, |tx| { + tx.execute( + "INSERT INTO base_refs VALUES(?1,?2,'dead-nonce',?3,NULL,'intent',?4,1,?5)", + params![ + worker, + identity.scope, + crate::blob_store::v2::to_hex(&identity.membership), + i32::MAX as u32 - 7, + gc_epoch(tx)? + ], + )?; + Ok(()) + }) + .unwrap(); +} + +#[test] +fn a_dead_owners_intent_is_orphaned_then_retired_and_holds_the_base_meanwhile() { + let _serial = v3::serial_for_test(); + let storage = tempfile::tempdir().unwrap(); + let (identity, _generation) = publish_test_base(storage.path(), &[("a.rs", b"alpha")]); + dead_intent(storage.path(), "gone-worker", &identity); + let first = sweep(storage.path()); + assert_eq!(first.orphaned_rows, 1, "{first:?}"); + assert!( + first.counted_bases.is_empty(), + "the orphaned row still holds: {first:?}" + ); + assert_eq!(refs(storage.path())[0].2, REF_ORPHANED); + let second = sweep(storage.path()); + assert_eq!(second.deleted_rows, 1, "{second:?}"); + assert_eq!(second.counted_bases, vec![identity.scope.clone()]); + assert!(base_registered(storage.path(), &identity)); + assert_eq!( + sweep(storage.path()).reclaimed_bases, + vec![identity.scope.clone()] + ); +} + +#[test] +fn an_owner_that_died_between_its_cas_and_referrer_published_is_repaired() { + let _serial = v3::serial_for_test(); + let storage = tempfile::tempdir().unwrap(); + let (identity, generation) = publish_test_base(storage.path(), &[("a.rs", b"alpha")]); + // The pointer landed with the base header, but the row is still the dead + // owner's intent. + let dir = with_base_barrier(storage.path(), FAMILY, |tx| { + crate::views::base_refs::register_view_in( + tx, + storage.path(), + FAMILY, + "worker-d", + storage.path(), + ) + }) + .unwrap(); + let store = ViewStore::open_dir(dir).unwrap(); + let delta = worker_delta(&identity, &generation, b"alpha"); + let name = GenerationName::for_manifest(&delta).unwrap(); + let prepared = store.prepare_v2(&name, None, &delta, None).unwrap(); + store.commit_v2(prepared, None).unwrap(); + dead_intent(storage.path(), "worker-d", &identity); + for _ in 0..3 { + let report = sweep(storage.path()); + assert!( + report.reclaimed_bases.is_empty() && report.counted_bases.is_empty(), + "{report:?}" + ); + } + assert_eq!( + refs(storage.path()), + vec![( + "worker-d".into(), + identity.scope.clone(), + REF_PUBLISHED.into(), + Some(name.to_string()) + )] + ); +} + +#[test] +fn repeated_same_base_republications_do_not_grow_the_rows() { + let _serial = v3::serial_for_test(); + let storage = tempfile::tempdir().unwrap(); + let (identity, generation) = publish_test_base(storage.path(), &[("src/a.rs", b"alpha")]); + let mut previous = None; + for round in 0..6u8 { + let bytes = [b'v', round]; + put_rows(storage.path(), &[&bytes]); + let (worker_generation, composed) = published( + bind( + storage.path(), + "worker-a", + &identity, + &generation, + worker_delta(&identity, &generation, &bytes), + previous.clone(), + ) + .unwrap(), + ); + drop(composed); + previous = Some(worker_generation); + } + crate::views::wait_for_generation_maintenance_for_test( + &v3::view_dir(storage.path(), FAMILY, "worker-a").unwrap(), + ); + assert_eq!(refs(storage.path()).len(), 6); + let report = sweep(storage.path()); + assert!(report.reconcile_complete, "{report:?}"); + let rows = refs(storage.path()); + assert_eq!(rows.len(), 1, "{rows:?}"); + assert_eq!(rows[0].3, previous); +} + +#[test] +fn reconciliation_stays_within_its_row_cap_and_resumes_on_the_next_pass() { + let _serial = v3::serial_for_test(); + let storage = tempfile::tempdir().unwrap(); + let (identity, _generation) = publish_test_base(storage.path(), &[("a.rs", b"alpha")]); + with_base_barrier(storage.path(), FAMILY, |tx| { + for index in 0..10 { + tx.execute( + "INSERT INTO base_refs VALUES(?1,?2,?3,'00','g-superseded','published',1,1,0)", + params![ + format!("worker-{index}"), + identity.scope, + format!("n{index}") + ], + )?; + } + Ok(()) + }) + .unwrap(); + let lock = ReservationLock::acquire(storage.path()).unwrap(); + let bounds = SweepBounds { + reconcile_rows: 4, + // Only the row cap may cut these passes. + reconcile_time: Duration::from_secs(60), + ..SweepBounds::default() + }; + let mut passes = 0; + loop { + let report = + sweep_family_locked(storage.path(), FAMILY, &lock, None, bounds, None).unwrap(); + passes += 1; + assert!(report.reconciled_rows <= 4, "{report:?}"); + if refs(storage.path()).is_empty() { + // Until the last stale row went, the base stayed held. + assert!(report.counted_bases.is_empty() || report.reconcile_complete); + break; + } + assert!( + report.counted_bases.is_empty(), + "held while rows remain: {report:?}" + ); + assert!(passes < 10); + } + assert_eq!(passes, 3); +} + +#[test] +fn the_plane_row_sweep_reclaims_unreferenced_rows_and_keeps_referenced_ones() { + let _serial = v3::serial_for_test(); + let storage = tempfile::tempdir().unwrap(); + let (_identity, _generation) = publish_test_base(storage.path(), &[("a.rs", b"referenced")]); + put_rows(storage.path(), &[b"unreferenced"]); + let report = sweep(storage.path()); + assert!(report.plane_rows_complete, "{report:?}"); + assert_eq!(report.plane_rows_deleted, 1, "{report:?}"); + assert!(plane_has(storage.path(), b"referenced")); + assert!(!plane_has(storage.path(), b"unreferenced")); +} + +#[test] +fn override_ready_key_survives_fold_and_gc() { + let _serial = v3::serial_for_test(); + let storage = tempfile::tempdir().unwrap(); + let (identity, generation) = publish_test_base(storage.path(), &[("src/a.rs", b"alpha")]); + // A checkout-specific fill of an inherited path: its key lives only in + // the worker's live pin until a published generation protects it. + let fill = crate::blob_store::v2::FamilyKey::new( + crate::blob_store::v2::FamilyPlane::Semantic, + [42; 32], + ); + let (first, composed) = published( + bind( + storage.path(), + "worker-a", + &identity, + &generation, + worker_delta(&identity, &generation, b"alpha"), + None, + ) + .unwrap(), + ); + drop(composed); + let worker_dir = v3::view_dir(storage.path(), FAMILY, "worker-a").unwrap(); + let mut live = crate::pins::LivePin::create_v3(storage.path(), FAMILY, "worker-a").unwrap(); + live.protect(&[fill]).unwrap(); + v3::publish(storage.path(), FAMILY, 1 << 20, None, |publication| { + publication.put(&fill, b"fill", 1) + }) + .unwrap(); + let fill_present = || { + v3::PlaneReader::open_existing( + storage.path(), + FAMILY, + crate::blob_store::v2::FamilyPlane::Semantic, + ) + .unwrap() + .get(&fill) + .unwrap() + .is_some() + }; + sweep(storage.path()); + assert!(fill_present(), "the live pin protects the unpublished fill"); + // Fold: the next worker generation carries the override. + let mut delta = worker_delta(&identity, &generation, b"alpha"); + delta + .set_readiness_override( + path("src/a.rs"), + crate::views::manifest_v2::EntryPlanes { + semantic: Some(crate::views::readiness::PlaneState::ready(&fill)), + ..Default::default() + }, + ) + .unwrap(); + let (_second, composed) = published( + bind( + storage.path(), + "worker-a", + &identity, + &generation, + delta, + Some(first), + ) + .unwrap(), + ); + // Post-fold trimming: the override key leaves the pin only because the + // published generation's ready keys (entries and overrides) protect it. + let published_keys = composed + .ready_keys() + .into_iter() + .map(|key| key.to_hex()) + .collect(); + live.trim_published(&published_keys, |_| false).unwrap(); + assert!(live.is_empty(), "the published override key was trimmed"); + drop(composed); + crate::views::wait_for_generation_maintenance_for_test(&worker_dir); + for _ in 0..2 { + sweep(storage.path()); + assert!(fill_present(), "the published override key was collected"); + } + let reader = crate::views::base_refs::open_foreign_v3( + storage.path(), + FAMILY, + "worker-a", + &header().producers, + ) + .unwrap() + .unwrap(); + assert_eq!( + reader.plane_readiness( + &path("src/a.rs"), + crate::blob_store::v2::FamilyPlane::Semantic + ), + Some(&crate::views::readiness::PlaneState::ready(&fill)) + ); + let _ = (key_of(b"alpha"), entry(b"alpha"), frontier()); +} + +#[test] +fn concurrent_binders_in_one_process_keep_every_published_reference() { + let _serial = v3::serial_for_test(); + let storage = tempfile::tempdir().unwrap(); + let (identity, generation) = publish_test_base(storage.path(), &[("src/a.rs", b"alpha")]); + let payloads: Vec> = (0..4u8).map(|index| vec![b'w', index]).collect(); + put_rows( + storage.path(), + &payloads.iter().map(Vec::as_slice).collect::>(), + ); + let outcomes: Vec<_> = std::thread::scope(|scope| { + let handles: Vec<_> = (0..4u8) + .map(|index| { + let storage = storage.path(); + let identity = identity.clone(); + let generation = generation.clone(); + let bytes = payloads[index as usize].clone(); + scope.spawn(move || { + let mut forged = identity.clone(); + // One binder aborts at verification. + if index == 3 { + forged.identity = [0; 32]; + } + bind( + storage, + &format!("worker-{index}"), + &forged, + &generation, + worker_delta(&identity, &generation, &bytes), + None, + ) + }) + }) + .collect(); + handles + .into_iter() + .map(|handle| handle.join().unwrap()) + .collect() + }); + let mut kept = Vec::new(); + for (index, outcome) in outcomes.into_iter().enumerate() { + match (index, outcome) { + (3, Err(_)) => {} + (_, Ok(WorkerOutcome::Published { composed, .. })) => kept.push(composed), + (index, other) => panic!("binder {index}: {other:?}"), + } + } + let rows = refs(storage.path()); + assert_eq!(rows.len(), 3, "{rows:?}"); + assert!(rows + .iter() + .all(|row| row.2 == REF_PUBLISHED && row.0 != "worker-3")); + drop(kept); + for _ in 0..3 { + let report = sweep(storage.path()); + assert!( + report.reclaimed_bases.is_empty() && report.counted_bases.is_empty(), + "{report:?}" + ); + assert_eq!(report.valid_edges, 3); + } + assert!(plane_has(storage.path(), b"alpha")); +} + +#[test] +fn an_injected_failure_at_each_sweep_boundary_never_reclaims_early_and_recovers() { + use crate::views::contracts::{inject_fault_at, InjectedFault}; + for step in [ + DurabilityStep::BaseUnreferencedCounted, + DurabilityStep::BaseReclaimBegun, + ] { + for fault in InjectedFault::ALL { + let _serial = v3::serial_for_test(); + let storage = tempfile::tempdir().unwrap(); + let (identity, _) = publish_test_base(storage.path(), &[("a.rs", b"alpha")]); + if step == DurabilityStep::BaseReclaimBegun { + sweep(storage.path()); + } + inject_fault_at(step, fault); + assert!( + sweep_family(storage.path(), FAMILY, None).is_err(), + "{step:?} {fault:?}" + ); + // The failed sweep released its lease; later sweeps finish the work + // without ever reclaiming before the second unreferenced sweep. + let mut reclaimed = false; + for _ in 0..3 { + let report = sweep(storage.path()); + if report.reclaimed_bases.contains(&identity.scope) { + reclaimed = true; + break; + } + } + assert!(reclaimed, "{step:?} {fault:?}"); + assert!(!base_registered(storage.path(), &identity)); + assert!(!base_dir(storage.path(), &identity).exists()); + } + } +} diff --git a/crates/aft/src/views/contracts.rs b/crates/aft/src/views/contracts.rs index 96824547e9..2556c1aa71 100644 --- a/crates/aft/src/views/contracts.rs +++ b/crates/aft/src/views/contracts.rs @@ -59,11 +59,36 @@ pub enum DurabilityStep { V3FloorRaised, /// All plane writes in a v3 publication are durable before its ledger commit. ClosureFlushed, + /// A `base_builds` claim row names this process as the base's builder. + BaseClaimed, + /// The base's member row (`kind = base`, no pointer yet) is committed. + BaseRegistered, + /// The base pointer moved to a readiness successor with the same membership. + BaseSuccessorPublished, + /// A worker's per-acquisition marker and `intent` referrer row exist. + BaseRefMarked, + /// The worker's referrer row is `published` with its worker generation. + ReferrerPublished, + /// A sweep found the base unreferenced and set its counter to 1. + BaseUnreferencedCounted, + /// A sweep committed `evicting` for an unreferenced base before removing it. + BaseReclaimBegun, } impl DurabilityStep { pub const V3_ONLY: [Self; 2] = [Self::V3FloorRaised, Self::ClosureFlushed]; - pub const ALL: [Self; 10] = [ + /// Boundaries of the base-reference protocol, in the order one base build, + /// worker bind and reclaim reaches them. + pub const BASE_PATH: [Self; 7] = [ + Self::BaseClaimed, + Self::BaseRegistered, + Self::BaseSuccessorPublished, + Self::BaseRefMarked, + Self::ReferrerPublished, + Self::BaseUnreferencedCounted, + Self::BaseReclaimBegun, + ]; + pub const ALL: [Self; 17] = [ Self::BlobCommitted, Self::SegmentRowRecorded, Self::SegmentFileSynced, @@ -74,6 +99,13 @@ impl DurabilityStep { Self::PointerSynced, Self::V3FloorRaised, Self::ClosureFlushed, + Self::BaseClaimed, + Self::BaseRegistered, + Self::BaseSuccessorPublished, + Self::BaseRefMarked, + Self::ReferrerPublished, + Self::BaseUnreferencedCounted, + Self::BaseReclaimBegun, ]; pub const fn as_str(self) -> &'static str { @@ -88,6 +120,13 @@ impl DurabilityStep { Self::PointerSynced => "pointer-synced", Self::V3FloorRaised => "v3-floor-raised", Self::ClosureFlushed => "closure-flushed", + Self::BaseClaimed => "base-claimed", + Self::BaseRegistered => "base-registered", + Self::BaseSuccessorPublished => "base-successor-published", + Self::BaseRefMarked => "base-ref-marked", + Self::ReferrerPublished => "referrer-published", + Self::BaseUnreferencedCounted => "base-unreferenced-counted", + Self::BaseReclaimBegun => "base-reclaim-begun", } } @@ -107,6 +146,60 @@ pub(crate) fn observe(observer: Option<&dyn DurabilityObserver>, step: Durabilit } } +/// A failure a test injects at one durability boundary. The boundary's +/// durable action has already happened; the caller sees the error a failed +/// `fsync`, a failed `rename` or a full disk would give it right after. +#[cfg(test)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum InjectedFault { + Fsync, + Rename, + NoSpace, +} + +#[cfg(test)] +impl InjectedFault { + pub(crate) const ALL: [Self; 3] = [Self::Fsync, Self::Rename, Self::NoSpace]; + fn error(self) -> std::io::Error { + match self { + // EIO, EXDEV and ENOSPC on unix. Windows maps these codes to other + // errors; callers handle every boundary failure the same way. + Self::Fsync => std::io::Error::from_raw_os_error(5), + Self::Rename => std::io::Error::from_raw_os_error(18), + Self::NoSpace => std::io::Error::from_raw_os_error(28), + } + } +} + +#[cfg(test)] +thread_local! { + static INJECTED: std::cell::Cell> = + const { std::cell::Cell::new(None) }; +} + +/// Makes the next [`boundary`] at `step` on this thread fail with `fault`. +#[cfg(test)] +pub(crate) fn inject_fault_at(step: DurabilityStep, fault: InjectedFault) { + INJECTED.with(|slot| slot.set(Some((step, fault)))); +} + +/// [`observe`], for boundaries whose callers handle a failure there. Tests can +/// make one boundary fail with [`inject_fault_at`]; production never fails here. +pub(crate) fn boundary( + observer: Option<&dyn DurabilityObserver>, + step: DurabilityStep, +) -> std::io::Result<()> { + observe(observer, step); + #[cfg(test)] + if let Some((at, fault)) = INJECTED.with(|slot| slot.get()) { + if at == step { + INJECTED.with(|slot| slot.set(None)); + return Err(fault.error()); + } + } + Ok(()) +} + /// Who is using a view. #[derive(Clone, Debug)] pub enum ViewAccess { diff --git a/crates/aft/src/views/eviction.rs b/crates/aft/src/views/eviction.rs index de87445e4a..e688b75fb8 100644 --- a/crates/aft/src/views/eviction.rs +++ b/crates/aft/src/views/eviction.rs @@ -112,6 +112,9 @@ pub enum Hold { OpenInProcess, /// Its sessions, protection or files could not be read with certainty. Uncertain(String), + /// A v3 base that a `base_refs` row, a live `base_builds` claim or this + /// sweep's marked worker edges still reference. + Referenced, } impl fmt::Display for Hold { @@ -121,6 +124,7 @@ impl fmt::Display for Hold { Self::Protected => write!(f, "protected generation"), Self::OpenInProcess => write!(f, "open in this process"), Self::Uncertain(reason) => write!(f, "uncertain: {reason}"), + Self::Referenced => write!(f, "referenced base"), } } } @@ -689,6 +693,56 @@ fn hold_inside_barrier( } } +/// Why the v3 base `scope` cannot be reclaimed now, read inside the v3 +/// registry barrier `tx`. Every deleter of a v3 base (the reference sweep's +/// two-step reclaim, the retention collector and any future LRU eviction) +/// goes through this check; none parses a manifest inside the barrier. +/// +/// One indexed `base_refs` query, the `base_builds` claim, then the bounded +/// `readers/`/`pins/`/session listing. A query error or an over-long listing +/// is [`Hold::Uncertain`]. +pub(crate) fn v3_base_hold( + tx: &rusqlite::Transaction<'_>, + scope: &str, + view_dir: &Path, +) -> Option { + use rusqlite::OptionalExtension; + let referenced = tx + .query_row( + "SELECT 1 FROM base_refs WHERE base_scope=?1 LIMIT 1", + [scope], + |_| Ok(()), + ) + .optional(); + match referenced { + Ok(Some(())) => return Some(Hold::Referenced), + Ok(None) => {} + Err(error) => return Some(Hold::Uncertain(error.to_string())), + } + let claim = tx + .query_row( + "SELECT owner_pid,owner_start FROM base_builds WHERE base_scope=?1", + [scope], + |r| { + Ok(crate::pins::PinOwner { + pid: r.get(0)?, + start_time: r.get(1)?, + }) + }, + ) + .optional(); + match claim { + Ok(Some(owner)) if crate::pins::owner_is_live(&owner) => return Some(Hold::Referenced), + Ok(_) => {} + Err(error) => return Some(Hold::Uncertain(error.to_string())), + } + match crate::storage_retention::v3_member_protected(tx, scope, view_dir) { + Ok(false) => None, + Ok(true) => Some(Hold::Protected), + Err(reason) => Some(Hold::Uncertain(reason)), + } +} + fn hold_of( registry: &FamilyRegistry, member: &MemberRecord, diff --git a/crates/aft/src/views/manifest_v2.rs b/crates/aft/src/views/manifest_v2.rs index 09bcf46e44..48031a6845 100644 --- a/crates/aft/src/views/manifest_v2.rs +++ b/crates/aft/src/views/manifest_v2.rs @@ -232,6 +232,17 @@ impl ManifestV2 { pub fn base_header(&self) -> Option<&BaseHeader> { self.base.as_ref() } + /// Rebinds a v3 worker delta to `header`. The worker publisher calls this + /// with the header of the base it authenticated, never a caller's string. + pub fn set_base_header(&mut self, header: BaseHeader) -> Result<()> { + if !self.v3 || self.base.is_none() { + return Err(ViewError::InvalidManifest( + "only a v3 worker delta can be rebound to a base".into(), + )); + } + self.base = Some(header); + Ok(()) + } pub fn published(&self) -> Option<&PublishedFrontier> { self.published.as_ref() } diff --git a/crates/aft/src/views/mod.rs b/crates/aft/src/views/mod.rs index 70b21a2af5..ac5b1d59ee 100644 --- a/crates/aft/src/views/mod.rs +++ b/crates/aft/src/views/mod.rs @@ -11,6 +11,9 @@ use base64::{engine::general_purpose::STANDARD, Engine as _}; use rusqlite::{params, Connection, TransactionBehavior}; use serde::{de::Error as _, Deserialize, Deserializer, Serialize, Serializer}; pub mod assembly; +pub mod base_builder; +pub mod base_refs; +pub mod base_sweep; mod generation; #[cfg(test)] pub(crate) use generation::{ diff --git a/crates/aft/src/views/read.rs b/crates/aft/src/views/read.rs index 96e7b7e207..ce019fcd58 100644 --- a/crates/aft/src/views/read.rs +++ b/crates/aft/src/views/read.rs @@ -590,9 +590,13 @@ pub fn open_foreign_generation( let manifest = store.load_manifest_v2(protected.generation())?; manifest.ensure_producers(producers)?; if manifest.base_header().is_some() { - return Err(super::ViewError::InvalidManifest( - "durable v3 base resolution is not wired".into(), - )); + // A base reference is valid only in a v3 worker manifest under + // `blobs/v3//views/`, opened through + // `open_foreign_v3_generation`. A v2 view naming a base is a corrupt + // edge: its header cannot be authenticated, and it is never trusted. + return Err(super::ViewError::InvalidManifest(format!( + "foreign view {scope} names a base outside the v3 namespace; refusing an unauthenticated base header" + ))); } Ok(Some(std::sync::Arc::new( super::snapshot::ComposedGeneration::new( @@ -603,6 +607,25 @@ pub fn open_foreign_generation( ))) } +/// Opens a v3 worker's published generation for a foreign or parent-folder +/// reader: a dual pin-then-verify of the worker and, through the authenticated +/// base resolver, of the base it names. The bundle owns both markers and its +/// answers carry the worker's `PublishedFrontier`. A base that stays missing or +/// moving through every attempt is reported unavailable, never replaced. +// No production caller until the parent-folder and foreign-reader routing +// slices select v3 workers; tests exercise it now. +#[cfg_attr(not(test), allow(dead_code))] +pub fn open_foreign_v3_generation( + storage: &std::path::Path, + family: &str, + scope: &str, + producers: &super::manifest_v2::Producers, +) -> super::Result>> { + super::base_refs::open_foreign_v3(storage, family, scope, producers).map_err(|error| { + super::ViewError::InvalidManifest(format!("foreign view {scope} unavailable: {error}")) + }) +} + #[cfg(test)] mod producer_tests { @@ -652,7 +675,7 @@ mod producer_tests { assert!( error .to_string() - .contains("durable v3 base resolution is not wired"), + .contains("refusing an unauthenticated base header"), "{error}" ); assert_eq!( diff --git a/crates/aft/tests/integration/per_checkout_registry.rs b/crates/aft/tests/integration/per_checkout_registry.rs index 05fa3955b3..612cad2d79 100644 --- a/crates/aft/tests/integration/per_checkout_registry.rs +++ b/crates/aft/tests/integration/per_checkout_registry.rs @@ -1100,7 +1100,8 @@ fn a_publisher_killed_at_any_durability_boundary_leaves_protected_bytes_readable v2_steps.to_vec(), DurabilityStep::ALL .into_iter() - .filter(|step| !DurabilityStep::V3_ONLY.contains(step)) + .filter(|step| !DurabilityStep::V3_ONLY.contains(step) + && !DurabilityStep::BASE_PATH.contains(step)) .collect::>() ); for step in v2_steps { From e0e7e85ee0697d639106f23f6f322653db9042ce Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Mon, 26 Oct 2026 16:19:48 +0100 Subject: [PATCH 003/100] mason: base build claim, live-base cap and v3 DiskBudget admission base_builds claim with the owner-token CAS: insert when absent, Busy while the owner lives (running, stopped or uncertain), takeover of a dead owner with attempt+1 and a fresh token, after which the dead attempt's staging/a--* directories are removed. Staging and derived names carry (attempt, owner_token). The live-base cap (64 per family, counting claimed-but-unregistered bases) refuses new claims and counts the hit in sweep_state.live_base_cap_hits. plan_binder: only the claim owner builds; others wait (ACK + direct scan) and go strict only on a failed claim, the cap, or the ruled 600 s wait. initial_reservation implements the ruled formula with the 1 GiB cap. DiskBudget::admit_v3/extend_v3 admit v3 reservations against the family hard limit (refused as storage_gap:v3_family_hard_limit, no legacy fallback) and flag usage over the 2 GiB target; RegistryWriter::extend grows a live reservation under the floor and namespace checks. No limit or budget constant changes. Co-authored-by: alfonso-aft <289616620+alfonso-aft@users.noreply.github.com> --- crates/aft/src/blob_store/v3.rs | 45 ++++ crates/aft/src/views/base_builder.rs | 299 +++++++++++++++++++++ crates/aft/src/views/base_builder_tests.rs | 247 +++++++++++++++++ crates/aft/src/views/eviction.rs | 93 +++++++ 4 files changed, 684 insertions(+) create mode 100644 crates/aft/src/views/base_builder_tests.rs diff --git a/crates/aft/src/blob_store/v3.rs b/crates/aft/src/blob_store/v3.rs index 9f53cd3e10..c153892f54 100644 --- a/crates/aft/src/blob_store/v3.rs +++ b/crates/aft/src/blob_store/v3.rs @@ -186,6 +186,11 @@ pub fn set_free_space_for_test(free: u64, capacity: u64) { .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) = Some((free, capacity)); } +/// The platform probe itself, bypassing the test override. +#[cfg(test)] +pub(crate) fn probe_free_space_for_test(path: &Path) -> Option<(u64, u64)> { + probe_free_space(path) +} fn free_space(path: &Path) -> Option<(u64, u64)> { if let Some(value) = *FREE_SPACE .lock() @@ -1100,6 +1105,45 @@ impl<'a> RegistryWriter<'a> { registry, }) } + /// Grows a live reservation by `additional` bytes under the same + /// admission checks as [`Self::reserve`]: the device floor and the + /// namespace budget. An owner extends before it writes past its + /// reservation, never after. + pub fn extend(&mut self, reservation: &Reservation, additional: u64) -> Result<()> { + if reservation.registry != self.registry_identity()? { + return Err(Error::ReservationRegistryMismatch); + } + let storage = self.lock.storage.clone(); + let path = self.dir.join("members.sqlite"); + let id = reservation.id; + self.with_barrier(|tx| { + check_floor(&storage, additional, Some((&path, tx)))?; + if accounting(tx)? + .usage() + .checked_add(additional) + .is_none_or(|n| n > NAMESPACE_BUDGET) + { + return Err(Error::Refused("v3_namespace_budget")); + } + let updated = tx.execute( + "UPDATE reservations SET reserved_bytes=reserved_bytes+?1 WHERE id=?2 AND state='reserved'", + params![additional, id], + )?; + if updated != 1 { + return Err(Error::Refused("reservation_not_live")); + } + Ok(()) + }) + } + /// Bytes still reserved by a live reservation (0 once committed or + /// released). + pub fn reserved_bytes(&self, reservation: &Reservation) -> Result { + Ok(self.connection()?.query_row( + "SELECT CASE WHEN state='reserved' THEN reserved_bytes ELSE 0 END FROM reservations WHERE id=?1", + [reservation.id], + |r| r.get(0), + )?) + } fn registry_identity(&self) -> Result { let path = fs::canonicalize(self.dir.join("members.sqlite"))?; Ok(RegistryIdentity { @@ -1113,6 +1157,7 @@ struct RegistryIdentity { path: PathBuf, file: Option, } +#[derive(Debug)] pub struct Reservation { id: i64, before: u64, diff --git a/crates/aft/src/views/base_builder.rs b/crates/aft/src/views/base_builder.rs index 4299a4d471..92cb552df3 100644 --- a/crates/aft/src/views/base_builder.rs +++ b/crates/aft/src/views/base_builder.rs @@ -164,3 +164,302 @@ pub fn publish_base( }), } } + +/// Ruled live-claim wait (item 24): a binder that finds a live claim waits at +/// most this long, from first encountering it, for the base's first +/// lexical-ready publication, then takes the strict path. Heartbeats and +/// takeovers never reset that clock. +pub const LIVE_CLAIM_WAIT: std::time::Duration = std::time::Duration::from_secs(600); +/// Builder heartbeat cadence and the age at which a binder inspects the +/// owner's liveness. Staleness alone never permits a takeover. +pub const CLAIM_HEARTBEAT: std::time::Duration = std::time::Duration::from_secs(5); +pub const CLAIM_STALE: std::time::Duration = std::time::Duration::from_secs(15); +/// Ruled cap of live bases per family, counting claimed or registered but +/// unpublished bases. +pub const LIVE_BASES_PER_FAMILY: i64 = 64; +/// Initial build reservation cap and extension step (pre-slice-4 ruling). +pub const INITIAL_RESERVATION_CAP: u64 = 1 << 30; +pub const RESERVATION_STEP: u64 = 256 << 20; + +/// The ruled initial reservation for a base of `files` members and +/// `source_bytes` bytes: `ceil_MiB(64 MiB + max(64 KiB × N, 2 × B, +/// 1.25 × P × max(N/Np, B/Bp)))`, capped at 1 GiB. `history` is a comparable +/// base's `(peak allocated build bytes P, Np, Bp)`; it is ignored when absent +/// or when `Np` or `Bp` is zero. +pub fn initial_reservation(files: u64, source_bytes: u64, history: Option<(u64, u64, u64)>) -> u64 { + const MIB: u128 = 1 << 20; + let mut term = (64u128 * 1024 * u128::from(files)).max(2 * u128::from(source_bytes)); + if let Some((peak, np, bp)) = history.filter(|(_, np, bp)| *np > 0 && *bp > 0) { + // 1.25 × P × max(N/Np, B/Bp), in exact integer arithmetic. + let by_files = 5 * u128::from(peak) * u128::from(files) / (4 * u128::from(np)); + let by_bytes = 5 * u128::from(peak) * u128::from(source_bytes) / (4 * u128::from(bp)); + term = term.max(by_files).max(by_bytes); + } + let total = 64 * MIB + term; + let rounded = total.div_ceil(MIB) * MIB; + rounded.min(u128::from(INITIAL_RESERVATION_CAP)) as u64 +} + +/// The holder of a base's build claim. Every staging and derived name it +/// creates includes `(attempt, owner_token)`, so a takeover never reuses a +/// dead attempt's names. +#[derive(Debug)] +pub struct BuildClaim { + storage: PathBuf, + family: String, + scope: String, + token: String, + attempt: i64, +} + +/// What [`claim_base`] found. +#[derive(Debug)] +pub enum ClaimOutcome { + /// This process now owns the build, at `attempt`. + Claimed(BuildClaim), + /// A live owner (running, stopped, or of uncertain liveness) holds it. + Busy { owner_pid: u32, attempt: i64 }, + /// The family already has the ruled number of live bases; the cap hit is + /// counted in `sweep_state.live_base_cap_hits`. + CapReached { live_bases: i64 }, +} + +fn live_base_count(tx: &Transaction<'_>) -> rusqlite::Result { + tx.query_row( + "SELECT (SELECT COUNT(*) FROM members WHERE kind='base' AND state!='evicting') + (SELECT COUNT(*) FROM base_builds WHERE base_scope NOT IN (SELECT scope FROM members))", + [], + |r| r.get(0), + ) +} + +/// Claims the build of base `scope` with the import ledger's owner-token CAS: +/// insert when absent; `Busy` while the owner lives; a dead owner's row is +/// taken over with `attempt + 1` and a fresh token, and the dead attempt's +/// staging becomes reclaimable. +pub fn claim_base( + storage: &Path, + family: &str, + scope: &str, + observer: Option<&dyn DurabilityObserver>, +) -> Result { + let me = super::registry::current_owner(); + let token = super::base_refs::fresh_nonce(); + let (outcome, dead_attempt) = with_base_barrier(storage, family, |tx| { + let existing: Option<(u32, i64, String, i64)> = tx + .query_row( + "SELECT owner_pid,owner_start,owner_token,attempt FROM base_builds WHERE base_scope=?1", + [scope], + |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)), + ) + .optional()?; + let now = crate::pins::now_ms() as i64; + match existing { + None => { + let registered: bool = tx.query_row( + "SELECT EXISTS(SELECT 1 FROM members WHERE scope=?1 AND state!='evicting')", + [scope], + |r| r.get(0), + )?; + let live = live_base_count(tx)?; + if !registered && live >= LIVE_BASES_PER_FAMILY { + tx.execute( + "UPDATE sweep_state SET live_base_cap_hits=live_base_cap_hits+1 WHERE singleton=1", + [], + )?; + return Ok((ClaimOutcome::CapReached { live_bases: live }, None)); + } + tx.execute( + "INSERT INTO base_builds VALUES(?1,?2,?3,?4,1,'building',?5)", + params![scope, me.pid, me.start_time as i64, token, now], + )?; + Ok(( + ClaimOutcome::Claimed(BuildClaim::new(storage, family, scope, &token, 1)), + None, + )) + } + Some((pid, start, old_token, attempt)) => { + let owner = crate::pins::PinOwner { + pid, + start_time: start as u64, + }; + if crate::pins::owner_is_live(&owner) { + return Ok(( + ClaimOutcome::Busy { + owner_pid: pid, + attempt, + }, + None, + )); + } + let updated = tx.execute( + "UPDATE base_builds SET owner_pid=?1,owner_start=?2,owner_token=?3,attempt=attempt+1,state='building',heartbeat_at_ms=?4 WHERE base_scope=?5 AND owner_token=?6", + params![me.pid, me.start_time as i64, token, now, scope, old_token], + )?; + if updated != 1 { + return Err(BaseRefError::Unavailable(format!("claim of {scope} moved"))); + } + Ok(( + ClaimOutcome::Claimed(BuildClaim::new( + storage, + family, + scope, + &token, + attempt + 1, + )), + Some((attempt, old_token)), + )) + } + } + })?; + if let ClaimOutcome::Claimed(claim) = &outcome { + if let Some((attempt, token)) = dead_attempt { + claim.remove_dead_staging(attempt, &token)?; + } + boundary(observer, DurabilityStep::BaseClaimed)?; + } + Ok(outcome) +} + +impl BuildClaim { + fn new(storage: &Path, family: &str, scope: &str, token: &str, attempt: i64) -> Self { + Self { + storage: storage.to_path_buf(), + family: family.to_owned(), + scope: scope.to_owned(), + token: token.to_owned(), + attempt, + } + } + pub fn scope(&self) -> &str { + &self.scope + } + pub fn attempt(&self) -> i64 { + self.attempt + } + pub fn token(&self) -> &str { + &self.token + } + /// `M/staging/a--/`, created fresh. + pub fn staging_dir(&self) -> Result { + let dir = v3::view_dir(&self.storage, &self.family, &self.scope)? + .join("staging") + .join(format!( + "a{}-{}-{}", + self.attempt, + self.token, + super::base_refs::fresh_nonce() + )); + std::fs::create_dir_all(&dir)?; + Ok(dir) + } + /// `derived--a-.sqlite`. + pub fn derived_name(&self, generation: &str) -> String { + format!( + "derived-{generation}-a{}-{}.sqlite", + self.attempt, self.token + ) + } + /// Deletes the staging directories of a dead attempt. Called only after + /// the claim CAS confirmed that attempt's owner dead. + fn remove_dead_staging(&self, attempt: i64, token: &str) -> Result<()> { + let staging = v3::view_dir(&self.storage, &self.family, &self.scope)?.join("staging"); + let prefix = format!("a{attempt}-{token}-"); + let entries = match std::fs::read_dir(&staging) { + Ok(entries) => entries, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(error) => return Err(error.into()), + }; + for entry in entries { + let entry = entry?; + if entry.file_name().to_string_lossy().starts_with(&prefix) { + std::fs::remove_dir_all(entry.path())?; + } + } + Ok(()) + } + /// True while the claim row still carries this owner's token. + pub fn verify_held(&self) -> Result { + with_base_barrier(&self.storage, &self.family, |tx| { + Ok(tx.query_row( + "SELECT EXISTS(SELECT 1 FROM base_builds WHERE base_scope=?1 AND owner_token=?2)", + params![self.scope, self.token], + |r| r.get(0), + )?) + }) + } + pub fn heartbeat(&self) -> Result<()> { + with_base_barrier(&self.storage, &self.family, |tx| { + let updated = tx.execute( + "UPDATE base_builds SET heartbeat_at_ms=?1 WHERE base_scope=?2 AND owner_token=?3", + params![crate::pins::now_ms() as i64, self.scope, self.token], + )?; + if updated != 1 { + return Err(BaseRefError::Unavailable( + "the build claim was taken over".into(), + )); + } + Ok(()) + }) + } + /// Marks the base serving after its first publication. The owner keeps + /// the claim, and with it the base's readiness successors, until + /// [`Self::release`]. + pub fn mark_serving(&self) -> Result<()> { + with_base_barrier(&self.storage, &self.family, |tx| { + tx.execute( + "UPDATE base_builds SET state='serving' WHERE base_scope=?1 AND owner_token=?2", + params![self.scope, self.token], + )?; + Ok(()) + }) + } + /// Releases the claim once the base is complete. + pub fn release(self) -> Result<()> { + with_base_barrier(&self.storage, &self.family, |tx| { + tx.execute( + "DELETE FROM base_builds WHERE base_scope=?1 AND owner_token=?2", + params![self.scope, self.token], + )?; + Ok(()) + }) + } +} + +/// What a binder that needs base `scope` does next. +#[derive(Debug)] +pub enum BinderPlan { + /// Build the base under this claim. + Build(BuildClaim), + /// Another owner is building it: ACK the bind at once, serve the bounded + /// direct scan with honest coverage, and wait for the first publication. + Wait { remaining: std::time::Duration }, + /// Take the strict path to an own generation, for this reason. + Strict(&'static str), +} + +/// The binder decision of the views design: only the claim owner builds; +/// everyone else waits for the first lexical-ready publication, and goes +/// strict only on a failed claim, a refused admission (decided by the caller +/// after `Build`), the live-base cap, or an exhausted wait. +pub fn plan_binder( + storage: &Path, + family: &str, + scope: &str, + waited: std::time::Duration, +) -> BinderPlan { + match claim_base(storage, family, scope, None) { + Ok(ClaimOutcome::Claimed(claim)) => BinderPlan::Build(claim), + Ok(ClaimOutcome::Busy { .. }) if waited >= LIVE_CLAIM_WAIT => { + BinderPlan::Strict("live_claim_wait_exhausted") + } + Ok(ClaimOutcome::Busy { .. }) => BinderPlan::Wait { + remaining: LIVE_CLAIM_WAIT - waited, + }, + Ok(ClaimOutcome::CapReached { .. }) => BinderPlan::Strict("live_base_cap"), + Err(_) => BinderPlan::Strict("claim_failed"), + } +} + +#[cfg(test)] +#[path = "base_builder_tests.rs"] +mod tests; diff --git a/crates/aft/src/views/base_builder_tests.rs b/crates/aft/src/views/base_builder_tests.rs new file mode 100644 index 0000000000..ed0cd6c91c --- /dev/null +++ b/crates/aft/src/views/base_builder_tests.rs @@ -0,0 +1,247 @@ +//! Build claim, live-base cap, binder plan, reservation sizing and v3 +//! disk-budget admission. + +use super::*; +use crate::views::base_refs::tests::{publish_test_base, FAMILY}; +use crate::views::eviction::{DiskBudget, V3Refusal, V3_HARD_LIMIT_GAP}; + +const MIB: u64 = 1 << 20; + +fn scope(byte: u8) -> String { + crate::views::base_refs::base_scope_for(&[byte; 32]) +} + +fn dead_claim(storage: &Path, scope: &str, token: &str, attempt: i64) { + with_base_barrier(storage, FAMILY, |tx| { + tx.execute( + "INSERT INTO base_builds VALUES(?1,?2,1,?3,?4,'building',0)", + params![scope, i32::MAX as u32 - 9, token, attempt], + )?; + Ok(()) + }) + .unwrap(); +} + +#[test] +fn the_initial_reservation_follows_the_ruled_formula_and_cap() { + // The ruling's AFT fixture: 5,377 files / 87,031,923 bytes, no history. + assert_eq!(initial_reservation(5_377, 87_031_923, None), 401 * MIB); + // A 200,000-file repository is capped at 1 GiB, not refused. + assert_eq!( + initial_reservation(200_000, 4 << 30, None), + INITIAL_RESERVATION_CAP + ); + // History scales a comparable base's measured peak; zero inventory drops it. + assert_eq!( + initial_reservation(100, 1_000, Some((200 * MIB, 50, 1_000))), + 64 * MIB + 500 * MIB + ); + assert_eq!( + initial_reservation(100, 1_000, Some((400 * MIB, 0, 1_000))), + initial_reservation(100, 1_000, None) + ); +} + +#[test] +fn two_claimants_of_one_base_produce_exactly_one_build() { + let _serial = v3::serial_for_test(); + let storage = tempfile::tempdir().unwrap(); + let target = scope(1); + let builds = std::sync::atomic::AtomicUsize::new(0); + std::thread::scope(|threads| { + for _ in 0..4 { + threads.spawn( + || match claim_base(storage.path(), FAMILY, &target, None).unwrap() { + ClaimOutcome::Claimed(_claim) => { + builds.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + } + ClaimOutcome::Busy { .. } => {} + other => panic!("{other:?}"), + }, + ); + } + }); + assert_eq!(builds.load(std::sync::atomic::Ordering::SeqCst), 1); +} + +#[test] +fn a_dead_builder_is_taken_over_once_with_attempt_plus_one_and_fresh_names() { + let _serial = v3::serial_for_test(); + let storage = tempfile::tempdir().unwrap(); + let target = scope(2); + dead_claim(storage.path(), &target, "deadtoken", 1); + let staging = v3::view_dir(storage.path(), FAMILY, &target) + .unwrap() + .join("staging/a1-deadtoken-x"); + std::fs::create_dir_all(&staging).unwrap(); + let ClaimOutcome::Claimed(claim) = claim_base(storage.path(), FAMILY, &target, None).unwrap() + else { + panic!("a dead owner's claim must be taken over"); + }; + assert_eq!(claim.attempt(), 2); + assert_ne!(claim.token(), "deadtoken"); + assert!(!staging.exists(), "the dead attempt's staging is reclaimed"); + let fresh = claim.staging_dir().unwrap(); + assert!(fresh + .file_name() + .unwrap() + .to_string_lossy() + .starts_with(&format!("a2-{}-", claim.token()))); + assert!(claim + .derived_name("g") + .ends_with(&format!("-a2-{}.sqlite", claim.token()))); + // The survivor is now the live owner: nobody else takes over. + assert!(matches!( + claim_base(storage.path(), FAMILY, &target, None).unwrap(), + ClaimOutcome::Busy { attempt: 2, .. } + )); + assert!(claim.verify_held().unwrap()); + claim.heartbeat().unwrap(); + claim.release().unwrap(); + assert!(matches!( + claim_base(storage.path(), FAMILY, &target, None).unwrap(), + ClaimOutcome::Claimed(_) + )); +} + +#[test] +fn a_live_claim_holds_an_empty_registered_base_through_sweeps() { + let _serial = v3::serial_for_test(); + let storage = tempfile::tempdir().unwrap(); + let target = scope(3); + let ClaimOutcome::Claimed(claim) = claim_base(storage.path(), FAMILY, &target, None).unwrap() + else { + panic!() + }; + with_base_barrier(storage.path(), FAMILY, |tx| { + register_base_in(tx, storage.path(), FAMILY, &target).map(|_| ()) + }) + .unwrap(); + for _ in 0..3 { + let report = crate::views::base_sweep::sweep_family(storage.path(), FAMILY, None).unwrap(); + assert!( + report.counted_bases.is_empty() && report.reclaimed_bases.is_empty(), + "{report:?}" + ); + } + claim.release().unwrap(); + crate::views::base_sweep::sweep_family(storage.path(), FAMILY, None).unwrap(); + let report = crate::views::base_sweep::sweep_family(storage.path(), FAMILY, None).unwrap(); + assert_eq!(report.reclaimed_bases, vec![target]); +} + +#[test] +fn the_live_base_cap_counts_claims_and_registered_bases_and_records_hits() { + let _serial = v3::serial_for_test(); + let storage = tempfile::tempdir().unwrap(); + with_base_barrier(storage.path(), FAMILY, |tx| { + for index in 0..LIVE_BASES_PER_FAMILY - 1 { + register_base_in( + tx, + storage.path(), + FAMILY, + &format!("{}{index:02}", scope(4)), + )?; + } + Ok(()) + }) + .unwrap(); + // One claimed-but-unregistered base fills the cap. + assert!(matches!( + claim_base(storage.path(), FAMILY, &scope(5), None).unwrap(), + ClaimOutcome::Claimed(_) + )); + assert!(matches!( + claim_base(storage.path(), FAMILY, &scope(6), None).unwrap(), + ClaimOutcome::CapReached { live_bases } if live_bases == LIVE_BASES_PER_FAMILY + )); + let hits: i64 = v3::LedgerReader::open_existing(storage.path(), FAMILY) + .unwrap() + .read(|c| { + Ok( + c.query_row("SELECT live_base_cap_hits FROM sweep_state", [], |r| { + r.get(0) + })?, + ) + }) + .unwrap(); + assert_eq!(hits, 1); + assert!(matches!( + plan_binder(storage.path(), FAMILY, &scope(6), std::time::Duration::ZERO), + BinderPlan::Strict("live_base_cap") + )); +} + +#[test] +fn binders_wait_for_a_live_claim_until_the_ruled_budget_then_go_strict() { + let _serial = v3::serial_for_test(); + let storage = tempfile::tempdir().unwrap(); + let target = scope(7); + let BinderPlan::Build(_owner) = + plan_binder(storage.path(), FAMILY, &target, std::time::Duration::ZERO) + else { + panic!("the first binder builds"); + }; + assert!(matches!( + plan_binder(storage.path(), FAMILY, &target, std::time::Duration::from_secs(1)), + BinderPlan::Wait { remaining } if remaining == LIVE_CLAIM_WAIT - std::time::Duration::from_secs(1) + )); + assert!(matches!( + plan_binder(storage.path(), FAMILY, &target, LIVE_CLAIM_WAIT), + BinderPlan::Strict("live_claim_wait_exhausted") + )); +} + +#[test] +fn v3_admission_refuses_past_the_hard_limit_with_the_named_gap_and_no_fallback() { + let _serial = v3::serial_for_test(); + let storage = tempfile::tempdir().unwrap(); + publish_test_base(storage.path(), &[("a.rs", b"alpha")]); + let lock = v3::ReservationLock::acquire(storage.path()).unwrap(); + let mut registry = v3::RegistryWriter::open_existing(storage.path(), FAMILY, &lock).unwrap(); + let usage = registry.accounting().unwrap().usage(); + let budget = DiskBudget { + family_store_hard: usage + 8 * MIB, + family_payload: usage + 2 * MIB, + ..DiskBudget::default() + }; + let before = registry.accounting().unwrap(); + let refused = budget.admit_v3(&mut registry, 9 * MIB).unwrap_err(); + assert!(matches!(refused, V3Refusal::HardLimit { .. })); + assert!( + refused.to_string().starts_with(V3_HARD_LIMIT_GAP), + "{refused}" + ); + assert_eq!( + registry.accounting().unwrap(), + before, + "a refusal reserves nothing" + ); + let admitted = budget.admit_v3(&mut registry, 4 * MIB).unwrap(); + assert!(admitted.over_target); + assert_eq!( + registry.reserved_bytes(&admitted.reservation).unwrap(), + 4 * MIB + ); + // Growth past the hard limit is refused before any byte is written. + assert!(matches!( + budget.extend_v3(&mut registry, &admitted, 5 * MIB), + Err(V3Refusal::HardLimit { .. }) + )); + budget.extend_v3(&mut registry, &admitted, 2 * MIB).unwrap(); + assert_eq!( + registry.reserved_bytes(&admitted.reservation).unwrap(), + 6 * MIB + ); + registry.release(admitted.reservation).unwrap(); + assert!(!storage.path().join("blobs/v2").exists()); +} + +#[cfg(any(unix, windows))] +#[test] +fn the_real_free_space_probe_reports_this_volume() { + let storage = tempfile::tempdir().unwrap(); + let (free, capacity) = + v3::probe_free_space_for_test(storage.path()).expect("supported platform"); + assert!(capacity > 0 && free <= capacity, "{free} / {capacity}"); +} diff --git a/crates/aft/src/views/eviction.rs b/crates/aft/src/views/eviction.rs index e688b75fb8..0927ec29ee 100644 --- a/crates/aft/src/views/eviction.rs +++ b/crates/aft/src/views/eviction.rs @@ -78,6 +78,99 @@ const MAX_WALK_ENTRIES: usize = 200_000; /// `pins/` and `readers/`, then the generation directories under `readers/`. const MAX_WALK_DEPTH: usize = 3; +/// The G4c storage gap a refused v3 admission reports. A refusal has no +/// legacy fallback: the caller serves what v3 already holds and names the gap. +pub const V3_HARD_LIMIT_GAP: &str = "storage_gap:v3_family_hard_limit"; + +/// Why a v3 reservation was refused. +#[derive(Debug)] +pub enum V3Refusal { + /// Committed usage plus the request would cross the family hard limit. + HardLimit { + usage: u64, + request: u64, + limit: u64, + }, + /// The ledger refused it (device floor, namespace budget) or failed. + Ledger(crate::blob_store::v3::Error), +} + +impl fmt::Display for V3Refusal { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::HardLimit { + usage, + request, + limit, + } => write!( + f, + "{V3_HARD_LIMIT_GAP}: {usage} used + {request} requested > {limit}" + ), + Self::Ledger(error) => write!(f, "storage_gap:{error}"), + } + } +} + +impl std::error::Error for V3Refusal {} + +/// An admitted v3 reservation. +#[derive(Debug)] +pub struct V3Admission { + pub reservation: crate::blob_store::v3::Reservation, + /// Usage after this reservation is above the family target: the caller + /// runs the family's v3 sweep so reclaimable bytes go before the next + /// admission. + pub over_target: bool, +} + +impl DiskBudget { + /// Admits a v3 reservation of `request` bytes against this family's + /// limits: the hard limit refuses, the target (the payload budget) only + /// asks for a sweep. Usage is the ledger's committed usage (last covering + /// measurement, committed charges since, and every outstanding + /// reservation), so concurrent builds are charged before they write. + pub fn admit_v3( + &self, + registry: &mut crate::blob_store::v3::RegistryWriter<'_>, + request: u64, + ) -> Result { + let usage = registry.accounting().map_err(V3Refusal::Ledger)?.usage(); + if usage.saturating_add(request) > self.family_store_hard { + return Err(V3Refusal::HardLimit { + usage, + request, + limit: self.family_store_hard, + }); + } + let reservation = registry.reserve(request).map_err(V3Refusal::Ledger)?; + Ok(V3Admission { + reservation, + over_target: usage.saturating_add(request) > self.family_payload, + }) + } + + /// Extends an admitted reservation before writing past it, under the same + /// hard limit. + pub fn extend_v3( + &self, + registry: &mut crate::blob_store::v3::RegistryWriter<'_>, + admission: &V3Admission, + additional: u64, + ) -> Result<(), V3Refusal> { + let usage = registry.accounting().map_err(V3Refusal::Ledger)?.usage(); + if usage.saturating_add(additional) > self.family_store_hard { + return Err(V3Refusal::HardLimit { + usage, + request: additional, + limit: self.family_store_hard, + }); + } + registry + .extend(&admission.reservation, additional) + .map_err(V3Refusal::Ledger) + } +} + /// The limits applied to one family. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct DiskBudget { From 4147ecee8ebe8af269c3f280ba0307ad3f2a1529 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Mon, 26 Oct 2026 16:29:36 +0100 Subject: [PATCH 004/100] mason: base builder from strict reads with v3 lexical materialization build_base reads the walker's membership with stability-checked reads (equal size, mtime and device/inode before and after, three attempts), builds a membership-keyed manifest (head_tree None, no git_oid), claims the base, admits the ruled initial reservation through DiskBudget::admit_v3 (a refusal releases the claim and returns the storage gap for the strict path), registers the base, writes the lexical plane into the v3 trigram store (trigram::materialize_v3, keys protected by a v3 live pin first) and first-publishes with graph and semantic planes pending. publish_successor lets only the live claim owner publish monotone readiness successors; a model outage is recorded as Failed while lexical serving continues. v3 generations carry no trigram segment: v3 has no segment rows or segment collection yet, so readers build from payloads. Co-authored-by: alfonso-aft <289616620+alfonso-aft@users.noreply.github.com> --- crates/aft/src/blob_store/v2.rs | 2 +- crates/aft/src/views/base_builder.rs | 224 ++++++++++++++ crates/aft/src/views/base_builder_tests.rs | 343 +++++++++++++++++++++ crates/aft/src/views/trigram.rs | 66 ++++ 4 files changed, 634 insertions(+), 1 deletion(-) diff --git a/crates/aft/src/blob_store/v2.rs b/crates/aft/src/blob_store/v2.rs index 8641f7bf70..372f725051 100644 --- a/crates/aft/src/blob_store/v2.rs +++ b/crates/aft/src/blob_store/v2.rs @@ -97,7 +97,7 @@ impl FamilyPlane { } } - const fn payload_schema(self) -> u32 { + pub(crate) const fn payload_schema(self) -> u32 { match self { Self::Trigram => TRIGRAM_PAYLOAD_SCHEMA, Self::Semantic => crate::blob_store::SEMANTIC_PAYLOAD_SCHEMA, diff --git a/crates/aft/src/views/base_builder.rs b/crates/aft/src/views/base_builder.rs index 92cb552df3..f4808a0473 100644 --- a/crates/aft/src/views/base_builder.rs +++ b/crates/aft/src/views/base_builder.rs @@ -460,6 +460,230 @@ pub fn plan_binder( } } +/// Attempts per path before a strict read gives up on an unstable file. +pub const STABLE_READ_ATTEMPTS: usize = 3; + +/// Reads `path` strictly: the file's stat (size, modification time and, on +/// unix, device and inode) must be equal before and after the read, up to +/// [`STABLE_READ_ATTEMPTS`] times. An unstable file is never accepted. +pub fn read_stable(path: &Path) -> std::io::Result> { + fn stamp(metadata: &std::fs::Metadata) -> (u64, Option, u64, u64) { + #[cfg(unix)] + let identity = { + use std::os::unix::fs::MetadataExt; + (metadata.dev(), metadata.ino()) + }; + #[cfg(not(unix))] + let identity = (0, 0); + ( + metadata.len(), + metadata.modified().ok(), + identity.0, + identity.1, + ) + } + for _ in 0..STABLE_READ_ATTEMPTS { + let before = std::fs::symlink_metadata(path)?; + if !before.is_file() { + return Err(std::io::Error::other("not a regular file")); + } + let bytes = std::fs::read(path)?; + let after = std::fs::symlink_metadata(path)?; + if stamp(&before) == stamp(&after) && bytes.len() as u64 == after.len() { + return Ok(bytes); + } + } + Err(std::io::Error::other( + "file kept changing during strict reads", + )) +} + +/// Inputs of one base build: the membership the configured walker produced +/// for `root`, and the effective identity inputs. +pub struct BaseBuildInput<'a> { + pub storage: &'a Path, + pub family: &'a str, + pub root: &'a Path, + pub paths: &'a [super::RelPath], + pub policy: crate::blob_store::v2::TrigramPolicy, + pub semantic_producer: Option, + pub callgraph_producer: String, + pub ignore_fingerprint: Option, + /// A comparable base's `(peak allocated build bytes, files, source bytes)`. + pub history: Option<(u64, u64, u64)>, + pub budget: super::eviction::DiskBudget, +} + +/// What [`build_base`] did. +#[derive(Debug)] +pub enum BaseBuild { + /// This process built and first-published the base; it keeps the claim + /// and owns the readiness successors. + Published { + identity: BaseIdentity, + generation: String, + claim: BuildClaim, + }, + /// Another live owner is building it. + Busy, + /// The bind takes the strict path for this reason. + Strict(String), +} + +/// Builds a membership-keyed base from strict, stability-checked reads of +/// `input.paths`, keyed by the tagged membership-and-content digest +/// (`head_tree = None`, no `git_oid`). Single-flight through the build claim; +/// admission through the v3 disk budget with the ruled initial reservation. +/// The first publication happens once membership and the lexical plane are +/// complete; graph and semantic entries stay pending for readiness successors. +pub fn build_base( + input: BaseBuildInput<'_>, + observer: Option<&dyn DurabilityObserver>, +) -> Result { + let BaseBuildInput { + storage, + family, + root, + paths, + policy, + semantic_producer, + callgraph_producer, + ignore_fingerprint, + history, + budget, + } = input; + let mut bytes = std::collections::BTreeMap::new(); + let mut source_bytes = 0u64; + let header = super::manifest_v2::ManifestHeader { + producers: super::manifest_v2::Producers { + trigram: policy.fingerprint_hex(), + semantic: semantic_producer.clone(), + callgraph: callgraph_producer, + }, + head_tree: None, + ignore_fingerprint, + segment: None, + }; + let mut manifest = ManifestV2::new_v3( + header, + None, + super::manifest_v2::PublishedFrontier::default(), + ); + for path in paths { + #[cfg(unix)] + let relative = { + use std::os::unix::ffi::OsStrExt; + std::ffi::OsStr::from_bytes(path.as_bytes()).to_owned() + }; + #[cfg(not(unix))] + let relative = + std::ffi::OsString::from(String::from_utf8_lossy(path.as_bytes()).into_owned()); + let source = read_stable(&root.join(relative))?; + source_bytes += source.len() as u64; + let planes = super::manifest_v2::EntryPlanes { + trigram: Some(PlaneState::pending("lexical plane not built")), + semantic: semantic_producer + .as_ref() + .filter(|_| super::semantic::applies_to(path)) + .map(|_| PlaneState::pending("awaiting the base owner")), + callgraph: Some(PlaneState::pending("awaiting the base owner")), + }; + manifest.insert( + path.clone(), + super::manifest_v2::EntryV2::regular( + crate::blob_store::v2::ContentHash::of(&source), + source.len() as u64, + planes, + ), + )?; + bytes.insert(path.clone(), source); + } + let identity = BaseIdentity::of(family, &manifest)?; + let claim = match claim_base(storage, family, &identity.scope, observer)? { + ClaimOutcome::Claimed(claim) => claim, + ClaimOutcome::Busy { .. } => return Ok(BaseBuild::Busy), + ClaimOutcome::CapReached { .. } => return Ok(BaseBuild::Strict("live_base_cap".into())), + }; + // Admission first: the reservation charges the build before any byte. + let request = initial_reservation(paths.len() as u64, source_bytes, history); + let admitted = { + let lock = v3::ReservationLock::acquire(storage)?; + let mut registry = v3::open_base_refs_registry(storage, family, &lock, None)?; + match budget.admit_v3(&mut registry, request) { + Ok(admitted) => admitted.reservation, + Err(refusal) => { + drop(registry); + drop(lock); + claim.release()?; + return Ok(BaseBuild::Strict(refusal.to_string())); + } + } + }; + let release = |reservation: v3::Reservation| -> Result<()> { + let lock = v3::ReservationLock::acquire(storage)?; + let mut registry = v3::RegistryWriter::open_for_barrier(storage, family, &lock)?; + registry.release(reservation)?; + Ok(()) + }; + let built = (|| -> Result { + let registered = with_base_barrier(storage, family, |tx| { + register_base_in(tx, storage, family, &identity.scope) + })?; + if registered.1 { + boundary(observer, DurabilityStep::BaseRegistered)?; + } + let mut live = crate::pins::LivePin::create_v3(storage, family, &identity.scope) + .map_err(|error| BaseRefError::Io(std::io::Error::other(error.to_string())))?; + super::trigram::materialize_v3(storage, family, &mut manifest, &bytes, &mut live, policy) + .map_err(|error| BaseRefError::Io(std::io::Error::other(error.reason)))?; + if !claim.verify_held()? { + return Err(BaseRefError::Unavailable( + "the build claim was taken over".into(), + )); + } + match publish_base(storage, family, &manifest, observer)? { + BasePublication::Published { generation, .. } => Ok(generation), + BasePublication::Lost { current } => Err(BaseRefError::Unavailable(format!( + "another publisher moved the base pointer to {current:?}" + ))), + } + })(); + release(admitted)?; + let generation = built?; + claim.mark_serving()?; + Ok(BaseBuild::Published { + identity, + generation, + claim, + }) +} + +/// Publishes a readiness successor of the claimed base: `update` edits a +/// copy of the current manifest (filling pending planes), and the result must +/// pass [`check_successor`]. A model outage is recorded as `Failed` here +/// without touching the lexical plane. +pub fn publish_successor( + claim: &BuildClaim, + update: impl FnOnce(&mut ManifestV2), + observer: Option<&dyn DurabilityObserver>, +) -> Result { + if !claim.verify_held()? { + return Err(BaseRefError::Unavailable( + "the build claim was taken over".into(), + )); + } + let base = super::base_refs::resolve_base(&claim.storage, &claim.family, &claim.scope)?; + let mut next = base.manifest().clone(); + drop(base); + update(&mut next); + match publish_base(&claim.storage, &claim.family, &next, observer)? { + BasePublication::Published { generation, .. } => Ok(generation), + BasePublication::Lost { current } => Err(BaseRefError::Unavailable(format!( + "the base pointer moved to {current:?}" + ))), + } +} + #[cfg(test)] #[path = "base_builder_tests.rs"] mod tests; diff --git a/crates/aft/src/views/base_builder_tests.rs b/crates/aft/src/views/base_builder_tests.rs index ed0cd6c91c..bf5fc87758 100644 --- a/crates/aft/src/views/base_builder_tests.rs +++ b/crates/aft/src/views/base_builder_tests.rs @@ -245,3 +245,346 @@ fn the_real_free_space_probe_reports_this_volume() { v3::probe_free_space_for_test(storage.path()).expect("supported platform"); assert!(capacity > 0 && free <= capacity, "{free} / {capacity}"); } + +fn policy() -> crate::blob_store::v2::TrigramPolicy { + crate::blob_store::v2::TrigramPolicy { + max_file_size: 1 << 20, + } +} + +fn checkout(files: &[(&str, &[u8])]) -> (tempfile::TempDir, Vec) { + let root = tempfile::tempdir().unwrap(); + let mut paths = Vec::new(); + for (name, bytes) in files { + let path = root.path().join(name); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(&path, bytes).unwrap(); + paths.push(crate::views::RelPath::new(name.as_bytes().to_vec()).unwrap()); + } + (root, paths) +} + +fn input<'a>( + storage: &'a Path, + root: &'a Path, + paths: &'a [crate::views::RelPath], + budget: DiskBudget, +) -> BaseBuildInput<'a> { + BaseBuildInput { + storage, + family: FAMILY, + root, + paths, + policy: policy(), + semantic_producer: Some("sem".into()), + callgraph_producer: "graph".into(), + ignore_fingerprint: None, + history: None, + budget, + } +} + +const FILES: &[(&str, &[u8])] = &[ + ("src/a.rs", b"fn alpha() {}"), + ("src/b.rs", b"fn beta() {}"), +]; + +#[test] +fn a_base_is_built_from_strict_reads_and_first_published_with_its_lexical_plane() { + let _serial = v3::serial_for_test(); + let storage = tempfile::tempdir().unwrap(); + let (root, paths) = checkout(FILES); + let BaseBuild::Published { + identity, + generation, + claim, + } = build_base( + input(storage.path(), root.path(), &paths, DiskBudget::default()), + None, + ) + .unwrap() + else { + panic!("the only builder publishes"); + }; + assert_eq!(claim.attempt(), 1); + let base = + crate::views::base_refs::resolve_base(storage.path(), FAMILY, &identity.scope).unwrap(); + assert_eq!(base.generation(), generation); + assert!(base.manifest().header().head_tree.is_none()); + let reader = + v3::PlaneReader::open_existing(storage.path(), FAMILY, FamilyPlane::Trigram).unwrap(); + for (_, entry) in base.manifest().entries() { + let Some(PlaneState::Ready { key }) = entry.plane_state(FamilyPlane::Trigram) else { + panic!("lexical plane incomplete at first publication"); + }; + let key = crate::blob_store::v2::FamilyKey::new( + FamilyPlane::Trigram, + crate::blob_store::v2::parse_hex32(key).unwrap(), + ); + assert!(reader.get(&key).unwrap().is_some()); + assert!(entry + .plane_state(FamilyPlane::Callgraph) + .unwrap() + .is_pending()); + } + // The admission reservation was released; only measured charges remain. + let ledger = v3::LedgerReader::open_existing(storage.path(), FAMILY).unwrap(); + assert_eq!(ledger.accounting().unwrap().reserved_bytes, 0); + // A second builder of the same identity, while the claim lives, does not build. + assert!(matches!( + build_base( + input(storage.path(), root.path(), &paths, DiskBudget::default()), + None + ) + .unwrap(), + BaseBuild::Busy + )); + assert!(!storage.path().join("views/v3").exists()); + assert!(!storage.path().join("blobs/v2").exists()); +} + +#[test] +fn concurrent_builders_of_one_base_build_it_exactly_once() { + let _serial = v3::serial_for_test(); + let storage = tempfile::tempdir().unwrap(); + let (root, paths) = checkout(FILES); + let results: Vec<_> = std::thread::scope(|threads| { + let handles: Vec<_> = (0..3) + .map(|_| { + threads.spawn(|| { + build_base( + input(storage.path(), root.path(), &paths, DiskBudget::default()), + None, + ) + .unwrap() + }) + }) + .collect(); + handles + .into_iter() + .map(|handle| handle.join().unwrap()) + .collect() + }); + assert_eq!( + results + .iter() + .filter(|built| matches!(built, BaseBuild::Published { .. })) + .count(), + 1, + "{results:?}" + ); + assert!(results + .iter() + .all(|built| matches!(built, BaseBuild::Published { .. } | BaseBuild::Busy))); +} + +#[test] +fn a_refused_admission_sends_the_bind_strict_with_the_named_gap_and_frees_the_claim() { + let _serial = v3::serial_for_test(); + let storage = tempfile::tempdir().unwrap(); + let (root, paths) = checkout(FILES); + let tiny = DiskBudget { + family_store_hard: MIB, + ..DiskBudget::default() + }; + let BaseBuild::Strict(reason) = + build_base(input(storage.path(), root.path(), &paths, tiny), None).unwrap() + else { + panic!("over the hard limit"); + }; + assert!(reason.starts_with(V3_HARD_LIMIT_GAP), "{reason}"); + assert!(matches!( + build_base( + input(storage.path(), root.path(), &paths, DiskBudget::default()), + None + ) + .unwrap(), + BaseBuild::Published { .. } + )); +} + +#[test] +fn a_dead_builder_after_binds_is_taken_over_once_and_its_successor_is_cold_equivalent() { + use crate::views::base_refs::tests::{bind, published}; + let _serial = v3::serial_for_test(); + let storage = tempfile::tempdir().unwrap(); + let (root, paths) = checkout(FILES); + let BaseBuild::Published { + identity, + generation, + claim, + } = build_base( + input(storage.path(), root.path(), &paths, DiskBudget::default()), + None, + ) + .unwrap() + else { + panic!() + }; + // Workers bind to the lexical-ready base. + let base = + crate::views::base_refs::resolve_base(storage.path(), FAMILY, &identity.scope).unwrap(); + let header = base.manifest().header().clone(); + drop(base); + let delta = |scope_generation: &str| { + crate::views::manifest_v2::ManifestV2::new_v3( + header.clone(), + Some(identity.header(scope_generation)), + crate::views::manifest_v2::PublishedFrontier::default(), + ) + }; + let mut workers = Vec::new(); + for scope in ["worker-1", "worker-2"] { + let (_, composed) = published( + bind( + storage.path(), + scope, + &identity, + &generation, + delta(&generation), + None, + ) + .unwrap(), + ); + drop(composed); + workers.push(scope); + } + // The builder dies with graph and semantic planes pending: its claim row + // now names a process that is gone. + let dead_token = claim.token().to_owned(); + with_base_barrier(storage.path(), FAMILY, |tx| { + tx.execute( + "UPDATE base_builds SET owner_pid=?1,owner_start=1 WHERE owner_token=?2", + params![i32::MAX as u32 - 11, dead_token], + )?; + Ok(()) + }) + .unwrap(); + drop(claim); + // Lexical service continues before the takeover. + let lexical = |scope: &str| { + let reader = crate::views::base_refs::open_foreign_v3( + storage.path(), + FAMILY, + scope, + &header.producers, + ) + .unwrap() + .unwrap(); + reader + .entries() + .filter(|(_, entry)| { + matches!( + entry.plane_state(FamilyPlane::Trigram), + Some(PlaneState::Ready { .. }) + ) + }) + .count() + }; + assert_eq!(lexical("worker-1"), FILES.len()); + // With no new binds, the surviving workers race for the claim: exactly + // one takes it over with attempt + 1. + let outcomes: Vec<_> = std::thread::scope(|threads| { + let handles: Vec<_> = workers + .iter() + .map(|_| { + threads.spawn(|| claim_base(storage.path(), FAMILY, &identity.scope, None).unwrap()) + }) + .collect(); + handles + .into_iter() + .map(|handle| handle.join().unwrap()) + .collect() + }); + let mut takeovers = outcomes.into_iter().filter_map(|outcome| match outcome { + ClaimOutcome::Claimed(claim) => Some(claim), + _ => None, + }); + let survivor = takeovers.next().expect("one survivor takes over"); + assert!(takeovers.next().is_none(), "exactly one takeover"); + assert_eq!(survivor.attempt(), 2); + assert_ne!(survivor.token(), dead_token); + // The survivor completes the pending planes: graph ready, semantic hit a + // model outage. Lexical stays served throughout. + let graph_key = |path: &crate::views::RelPath| { + crate::blob_store::v2::FamilyKey::new( + FamilyPlane::Callgraph, + *blake3::hash(path.as_bytes()).as_bytes(), + ) + }; + let fill = |manifest: &mut crate::views::manifest_v2::ManifestV2| { + for (path, entry) in manifest.entries_mut() { + if let crate::views::manifest_v2::EntryV2::Regular { planes, .. } = entry { + planes.callgraph = Some(PlaneState::ready(&graph_key(path))); + if planes.semantic.is_some() { + planes.semantic = Some(PlaneState::Failed { + reason: "model backend unavailable".into(), + producer: "sem".into(), + }); + } + } + } + }; + let successor = publish_successor(&survivor, fill, None).unwrap(); + assert_eq!(lexical("worker-2"), FILES.len()); + let base = + crate::views::base_refs::resolve_base(storage.path(), FAMILY, &identity.scope).unwrap(); + assert_eq!(base.generation(), successor); + // Cold equivalence: the successor equals a base built cold over the same + // files with the same completions. + let mut cold = crate::views::manifest_v2::ManifestV2::new_v3( + header.clone(), + None, + crate::views::manifest_v2::PublishedFrontier::default(), + ); + for (path, entry) in + crate::views::base_refs::resolve_base(storage.path(), FAMILY, &identity.scope) + .unwrap() + .manifest() + .entries() + { + let crate::views::manifest_v2::EntryV2::Regular { content, size, .. } = entry else { + continue; + }; + let bytes = std::fs::read( + root.path() + .join(std::str::from_utf8(path.as_bytes()).unwrap()), + ) + .unwrap(); + assert_eq!(crate::blob_store::v2::ContentHash::of(&bytes), *content); + let key = crate::blob_store::v2::TrigramKey { + content: *content, + policy: policy(), + } + .family_key(); + cold.insert( + path.clone(), + crate::views::manifest_v2::EntryV2::regular( + *content, + *size, + crate::views::manifest_v2::EntryPlanes { + trigram: Some(PlaneState::ready(&key)), + semantic: entry.plane_state(FamilyPlane::Semantic).cloned(), + callgraph: Some(PlaneState::ready(&graph_key(path))), + }, + ), + ) + .unwrap(); + } + assert_eq!(base.manifest(), &cold); + // A forbidden transition (a Ready key changed) is refused. + drop(base); + let error = publish_successor( + &survivor, + |manifest| { + for (_, entry) in manifest.entries_mut() { + if let crate::views::manifest_v2::EntryV2::Regular { planes, .. } = entry { + planes.callgraph = Some(PlaneState::pending("regressed")); + } + } + }, + None, + ) + .unwrap_err(); + assert!(matches!(error, BaseRefError::Corrupt(_)), "{error}"); +} diff --git a/crates/aft/src/views/trigram.rs b/crates/aft/src/views/trigram.rs index 4dbbab59e0..fd48473cbf 100644 --- a/crates/aft/src/views/trigram.rs +++ b/crates/aft/src/views/trigram.rs @@ -697,6 +697,72 @@ pub fn materialize( }) } +/// The lexical plane of a v3 generation (a base, or a worker's delta): every +/// regular entry's trigram payload goes into `blobs/v3//trigram.sqlite` +/// through one v3 publication, after `live` protects every key, and the entry +/// becomes `Ready`. `bytes` holds the strictly read source of each entry; an +/// entry whose bytes no longer hash to its content fails the whole call. +/// +/// No segment is written: v3 has no segment rows or segment collection yet, +/// so v3 generations carry `segment: None` and readers build from payloads. +pub fn materialize_v3( + storage: &Path, + family: &str, + manifest: &mut super::manifest_v2::ManifestV2, + bytes: &BTreeMap>, + live: &mut crate::pins::LivePin, + policy: TrigramPolicy, +) -> Result<(), super::contracts::PlaneError> { + if manifest.header().producers.trigram != policy.fingerprint_hex() { + return Err(plane_error("trigram producer mismatch")); + } + let mut payloads = Vec::new(); + for (path, entry) in manifest.entries() { + let super::manifest_v2::EntryV2::Regular { content, size, .. } = entry else { + continue; + }; + let source = bytes + .get(path) + .ok_or_else(|| plane_error(format!("no strict read for {path:?}")))?; + if ContentHash::of(source) != *content || source.len() as u64 != *size { + return Err(plane_error("source changed during trigram construction")); + } + let key = crate::blob_store::v2::TrigramKey { + content: *content, + policy, + } + .family_key(); + payloads.push(( + path.clone(), + key, + TrigramPayload::extract(source, &policy).encode(), + )); + } + // Protect every key before the first put, as the v2 materializer does. + live.protect(&payloads.iter().map(|(_, key, _)| *key).collect::>()) + .map_err(|e| plane_error(e.to_string()))?; + let request = payloads + .iter() + .map(|(_, _, payload)| payload.len() as u64) + .sum::() + .saturating_mul(2) + .saturating_add(1 << 20); + let schema = FamilyPlane::Trigram.payload_schema(); + crate::blob_store::v3::publish(storage, family, request, None, |publication| { + for (_, key, payload) in &payloads { + publication.put(key, payload, schema)?; + } + Ok(()) + }) + .map_err(|e| plane_error(e.to_string()))?; + for (path, key, _) in &payloads { + if let Some(super::manifest_v2::EntryV2::Regular { planes, .. }) = manifest.get_mut(path) { + planes.trigram = Some(super::readiness::PlaneState::ready(key)); + } + } + Ok(()) +} + type Residents = BTreeMap<(String, String), Arc>; /// Resident immutable data shared across checkout opens. Register explicitly From c10441f72b7970c3d98759cf8447d8d6cec4f93b Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Mon, 26 Oct 2026 16:36:41 +0100 Subject: [PATCH 005/100] mason: cross-process base-reference scenarios and base-path kill test - G7: two child binders (one SIGSTOPped with its intent row across two sweeps, then killed) plus in-process binds with one aborting; the base is never counted, the stopped owner's intent is kept, then orphaned, then retired, and published references keep the base. - Kill test: a child parks at each DurabilityStep::BASE_PATH boundary (the child refuses unknown or non-base step names; the parent checks the ready file names the step), is SIGKILLed, and a fresh process sweeps: dead reservations are released, every published reference resolves, a dead BaseClaimed claim is taken over with attempt 2, and nothing appears under /views/v3. - Delete main, kill main's daemon, run GC in a standalone process, restart the worker: it composes over its base with correct contents. Co-authored-by: alfonso-aft <289616620+alfonso-aft@users.noreply.github.com> --- crates/aft/tests/integration/main.rs | 1 + crates/aft/tests/integration/v3_base_refs.rs | 600 +++++++++++++++++++ 2 files changed, 601 insertions(+) create mode 100644 crates/aft/tests/integration/v3_base_refs.rs diff --git a/crates/aft/tests/integration/main.rs b/crates/aft/tests/integration/main.rs index f231b5bbb1..5d5f1110ea 100644 --- a/crates/aft/tests/integration/main.rs +++ b/crates/aft/tests/integration/main.rs @@ -195,6 +195,7 @@ mod trace_to_symbol_test; mod typescript_native_lsp_test; mod undo_path_lifecycle_test; mod url_fetch_test; +mod v3_base_refs; mod v3_isolation; mod view_assembly_wiring_test; mod vue_test; diff --git a/crates/aft/tests/integration/v3_base_refs.rs b/crates/aft/tests/integration/v3_base_refs.rs new file mode 100644 index 0000000000..05b48f3cbe --- /dev/null +++ b/crates/aft/tests/integration/v3_base_refs.rs @@ -0,0 +1,600 @@ +//! v3 base references across real processes: concurrent binders with a +//! stopped one, a publisher killed at every base-path durability boundary, +//! and the delete-main scenario where a worker restarts and still serves from +//! its base after a foreign process collected the family. +//! +//! Child processes re-run this test binary with `--ignored --exact +//! v3_base_refs::base_ref_child`; the scenario and its pause point come from +//! `BASE_REF_*` environment variables. A child parks after writing its ready +//! file and is stopped or killed by the parent. + +#![cfg(unix)] + +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::{Child, Command, Stdio}; +use std::thread; +use std::time::{Duration, Instant}; + +use aft::blob_store::v2::{ContentHash, FamilyPlane, TrigramPolicy}; +use aft::blob_store::v3; +use aft::views::base_builder::{ + build_base, claim_base, publish_base, publish_successor, BaseBuild, BaseBuildInput, + BasePublication, ClaimOutcome, +}; +use aft::views::base_refs::{ + open_foreign_v3, publish_worker, resolve_base, with_base_barrier, BaseIdentity, WorkerOutcome, + WorkerPublication, +}; +use aft::views::base_sweep::sweep_family; +use aft::views::contracts::{DurabilityObserver, DurabilityStep}; +use aft::views::eviction::DiskBudget; +use aft::views::manifest_v2::{ + EntryPlanes, EntryV2, ManifestHeader, ManifestV2, Producers, PublishedFrontier, +}; +use aft::views::readiness::PlaneState; +use aft::views::RelPath; +use tempfile::tempdir; + +const FAMILY: &str = "00112233445566ff"; +const CHILD_TEST: &str = "v3_base_refs::base_ref_child"; +const FILES: &[(&str, &[u8])] = &[ + ("src/lib.rs", b"pub fn shared() -> u32 { 1 }"), + ("src/util.rs", b"pub fn helper() -> u32 { 2 }"), +]; + +fn policy() -> TrigramPolicy { + TrigramPolicy { + max_file_size: 1 << 20, + } +} + +fn producers() -> Producers { + Producers { + trigram: policy().fingerprint_hex(), + semantic: None, + callgraph: "graph".into(), + } +} + +fn rel(name: &str) -> RelPath { + RelPath::new(name.as_bytes().to_vec()).unwrap() +} + +fn checkout(root: &Path, files: &[(&str, &[u8])]) -> Vec { + files + .iter() + .map(|(name, bytes)| { + let path = root.join(name); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(path, bytes).unwrap(); + rel(name) + }) + .collect() +} + +fn build(storage: &Path, root: &Path, observer: Option<&dyn DurabilityObserver>) -> BaseBuild { + let paths = checkout(root, FILES); + build_base( + BaseBuildInput { + storage, + family: FAMILY, + root, + paths: &paths, + policy: policy(), + semantic_producer: None, + callgraph_producer: "graph".into(), + ignore_fingerprint: None, + history: None, + budget: DiskBudget::default(), + }, + observer, + ) + .unwrap() +} + +fn current_base(storage: &Path, scope: &str) -> (BaseIdentity, String) { + let base = resolve_base(storage, FAMILY, scope).unwrap(); + (base.identity().clone(), base.generation().to_owned()) +} + +/// A worker delta that edits nothing: it inherits every base entry. +fn bind( + storage: &Path, + scope: &str, + identity: &BaseIdentity, + generation: &str, + changed: Option<&[u8]>, + observer: Option<&dyn DurabilityObserver>, +) -> Result { + let header = ManifestHeader { + producers: producers(), + head_tree: None, + ignore_fingerprint: None, + segment: None, + }; + let mut delta = ManifestV2::new_v3( + header, + Some(identity.header(generation)), + PublishedFrontier::default(), + ); + if let Some(bytes) = changed { + delta + .insert( + rel("src/worker_only.rs"), + EntryV2::regular( + ContentHash::of(bytes), + bytes.len() as u64, + EntryPlanes::default(), + ), + ) + .unwrap(); + } + let root = storage.join("roots").join(scope); + fs::create_dir_all(&root).unwrap(); + let base = identity.header(generation); + publish_worker( + WorkerPublication { + storage, + family: FAMILY, + worker_scope: scope, + root: &root, + base: &base, + delta, + previous: None, + }, + observer, + ) +} + +fn spawn_child(scenario: &str, storage: &Path, ready: &Path, extra: &[(&str, &str)]) -> Child { + let mut command = Command::new(std::env::current_exe().unwrap()); + command + .args(["--exact", CHILD_TEST, "--ignored", "--nocapture"]) + .env("BASE_REF_SCENARIO", scenario) + .env("BASE_REF_STORAGE", storage) + .env("BASE_REF_READY", ready) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + for (name, value) in extra { + command.env(name, value); + } + command.spawn().unwrap() +} + +fn wait_for_file(child: &mut Child, path: &Path) { + let started = Instant::now(); + while !path.is_file() { + if let Some(status) = child.try_wait().unwrap() { + panic!("child exited before writing {path:?}: {status}"); + } + assert!( + started.elapsed() < Duration::from_secs(120), + "timed out waiting for {path:?}" + ); + thread::sleep(Duration::from_millis(10)); + } +} + +fn signal(child: &Child, signal: i32) { + assert_eq!(unsafe { libc::kill(child.id() as i32, signal) }, 0); +} + +fn kill(child: &mut Child) { + signal(child, libc::SIGKILL); + assert!(!child.wait().unwrap().success()); +} + +struct ParkAt { + step: DurabilityStep, + ready: PathBuf, +} + +impl DurabilityObserver for ParkAt { + fn reached(&self, step: DurabilityStep) { + if step == self.step { + fs::write(&self.ready, self.step.as_str()).unwrap(); + loop { + thread::sleep(Duration::from_millis(10)); + } + } + } +} + +fn park_forever(ready: &Path) -> ! { + fs::write(ready, b"ready").unwrap(); + loop { + thread::sleep(Duration::from_millis(10)); + } +} + +/// The child side of every multi-process scenario. +#[test] +#[ignore] +fn base_ref_child() { + let storage = PathBuf::from(std::env::var_os("BASE_REF_STORAGE").expect("BASE_REF_STORAGE")); + let ready = PathBuf::from(std::env::var_os("BASE_REF_READY").expect("BASE_REF_READY")); + let scenario = std::env::var("BASE_REF_SCENARIO").unwrap(); + v3::set_free_space_for_test(u64::MAX, u64::MAX / 2); + match scenario.as_str() { + "bind-and-park" => { + let scope = std::env::var("BASE_REF_WORKER").unwrap(); + let base = std::env::var("BASE_REF_BASE").unwrap(); + let (identity, generation) = current_base(&storage, &base); + let park = std::env::var("BASE_REF_STEP").ok().map(|step| ParkAt { + step: DurabilityStep::parse(&step).expect("unknown step"), + ready: ready.clone(), + }); + let outcome = bind( + &storage, + &scope, + &identity, + &generation, + Some(scope.as_bytes()), + park.as_ref().map(|park| park as &dyn DurabilityObserver), + ) + .unwrap(); + assert!(matches!(outcome, WorkerOutcome::Published { .. })); + // Keep the bundle (and its markers) for as long as this process lives. + let _held = outcome; + park_forever(&ready); + } + "build-main-and-park" => { + let root = PathBuf::from(std::env::var_os("BASE_REF_ROOT").unwrap()); + let BaseBuild::Published { + identity, + generation, + claim, + } = build(&storage, &root, None) + else { + panic!("main's daemon is the only builder"); + }; + let _main = bind(&storage, "main", &identity, &generation, None, None).unwrap(); + let _claim = claim; + park_forever(&ready); + } + "foreign-gc" => { + // A standalone process with no view session or views + // configuration: it honours references through the protocol. + for _ in 0..3 { + sweep_family(&storage, FAMILY, None).unwrap(); + } + fs::write(&ready, b"swept").unwrap(); + } + "base-path" => { + let name = std::env::var("BASE_REF_STEP").unwrap(); + let step = DurabilityStep::parse(&name) + .unwrap_or_else(|| panic!("the kill-test child refuses unknown step {name}")); + assert!( + DurabilityStep::BASE_PATH.contains(&step), + "{name} is not a base-path step" + ); + let observer = ParkAt { step, ready }; + let root = storage.join("roots/main"); + let BaseBuild::Published { + identity, + generation, + claim, + } = build(&storage, &root, Some(&observer)) + else { + panic!("the only builder publishes"); + }; + let successor = publish_successor( + &claim, + |manifest| { + for (_, entry) in manifest.entries_mut() { + if let EntryV2::Regular { planes, .. } = entry { + planes.callgraph = Some(PlaneState::Failed { + reason: "no extractor".into(), + producer: "graph".into(), + }); + } + } + }, + Some(&observer), + ) + .unwrap(); + let bound = bind( + &storage, + "worker", + &identity, + &successor, + Some(b"worker"), + Some(&observer), + ) + .unwrap(); + drop(bound); + claim.release().unwrap(); + let _ = generation; + // An unrelated, unreferenced base for the reclaim boundaries. + let other = base_manifest(&[("other.rs", b"other")]); + assert!(matches!( + publish_base(&storage, FAMILY, &other, Some(&observer)).unwrap(), + BasePublication::Published { .. } + )); + for _ in 0..2 { + sweep_family(&storage, FAMILY, Some(&observer)).unwrap(); + } + panic!("the pause point {name} was never reached"); + } + other => panic!("unknown scenario {other}"), + } +} + +/// A base manifest whose lexical plane is complete without stored payloads: +/// an empty-content file needs no trigram row for this protocol test. +fn base_manifest(files: &[(&str, &[u8])]) -> ManifestV2 { + let mut manifest = ManifestV2::new_v3( + ManifestHeader { + producers: producers(), + head_tree: None, + ignore_fingerprint: None, + segment: None, + }, + None, + PublishedFrontier::default(), + ); + for (name, bytes) in files { + manifest + .insert( + rel(name), + EntryV2::regular( + ContentHash::of(bytes), + bytes.len() as u64, + EntryPlanes::default(), + ), + ) + .unwrap(); + } + manifest +} + +fn refs(storage: &Path) -> Vec<(String, String)> { + with_base_barrier(storage, FAMILY, |tx| { + Ok(tx + .prepare("SELECT worker_scope,state FROM base_refs ORDER BY worker_scope")? + .query_map([], |r| Ok((r.get(0)?, r.get(1)?)))? + .collect::>>()?) + }) + .unwrap() +} + +fn registered(storage: &Path, scope: &str) -> bool { + v3::LedgerReader::open_existing(storage, FAMILY) + .unwrap() + .member(scope) + .unwrap() + .is_some() +} + +/// G7: two child binders plus several in-process binds, one of them aborting, +/// and one child SIGSTOPped with its intent row across two sweeps. +#[test] +fn concurrent_binders_across_processes_keep_the_base_and_a_stopped_owner_keeps_its_intent() { + v3::set_free_space_for_test(u64::MAX, u64::MAX / 2); + let storage = tempdir().unwrap(); + let root = storage.path().join("roots/main"); + let BaseBuild::Published { + identity, claim, .. + } = build(storage.path(), &root, None) + else { + panic!() + }; + claim.release().unwrap(); + let control = tempdir().unwrap(); + let ready_a = control.path().join("a"); + let mut a = spawn_child( + "bind-and-park", + storage.path(), + &ready_a, + &[ + ("BASE_REF_WORKER", "worker-a"), + ("BASE_REF_BASE", &identity.scope), + ], + ); + let ready_s = control.path().join("s"); + let mut stopped = spawn_child( + "bind-and-park", + storage.path(), + &ready_s, + &[ + ("BASE_REF_WORKER", "worker-s"), + ("BASE_REF_BASE", &identity.scope), + ("BASE_REF_STEP", DurabilityStep::BaseRefMarked.as_str()), + ], + ); + wait_for_file(&mut a, &ready_a); + wait_for_file(&mut stopped, &ready_s); + signal(&stopped, libc::SIGSTOP); + let (_, generation) = current_base(storage.path(), &identity.scope); + let mut held = Vec::new(); + for index in 0..3u8 { + let mut header = identity.clone(); + if index == 2 { + header.membership = [0; 32]; + } + match bind( + storage.path(), + &format!("local-{index}"), + &header, + &generation, + Some(&[index]), + None, + ) { + Ok(outcome) => held.push(outcome), + Err(_) => assert_eq!(index, 2, "only the forged binder aborts"), + } + } + assert_eq!(held.len(), 2); + for _ in 0..2 { + let report = sweep_family(storage.path(), FAMILY, None).unwrap(); + assert!( + report.counted_bases.is_empty() && report.reclaimed_bases.is_empty(), + "{report:?}" + ); + } + let rows = refs(storage.path()); + assert!( + rows.contains(&("worker-s".into(), "intent".into())), + "{rows:?}" + ); + assert!( + rows.contains(&("worker-a".into(), "published".into())), + "{rows:?}" + ); + assert!( + !rows.iter().any(|(worker, _)| worker == "local-2"), + "{rows:?}" + ); + // The stopped owner dies: its intent is orphaned, then retired, while + // the published references keep the base. + signal(&stopped, libc::SIGCONT); + kill(&mut stopped); + sweep_family(storage.path(), FAMILY, None).unwrap(); + assert!(refs(storage.path()).contains(&("worker-s".into(), "orphaned".into()))); + sweep_family(storage.path(), FAMILY, None).unwrap(); + assert!(!refs(storage.path()) + .iter() + .any(|(worker, _)| worker == "worker-s")); + kill(&mut a); + drop(held); + for _ in 0..2 { + let report = sweep_family(storage.path(), FAMILY, None).unwrap(); + assert!(report.reclaimed_bases.is_empty(), "{report:?}"); + } + let reader = open_foreign_v3(storage.path(), FAMILY, "worker-a", &producers()) + .unwrap() + .unwrap(); + assert!(reader.entry(&rel("src/lib.rs")).is_some()); + assert!(!storage.path().join("views/v3").exists()); +} + +/// Kill a builder/binder/sweeper at every base-path boundary. Afterwards no +/// published reference is dangling, a dead claim is taken over with +/// attempt + 1, dead reservations are released, and nothing appears under +/// `/views/v3`. +#[test] +fn a_process_killed_at_any_base_path_boundary_leaves_references_readable() { + v3::set_free_space_for_test(u64::MAX, u64::MAX / 2); + for step in DurabilityStep::BASE_PATH { + let storage = tempdir().unwrap(); + let control = tempdir().unwrap(); + let ready = control.path().join("ready"); + let mut child = spawn_child( + "base-path", + storage.path(), + &ready, + &[("BASE_REF_STEP", step.as_str())], + ); + wait_for_file(&mut child, &ready); + assert_eq!(fs::read_to_string(&ready).unwrap(), step.as_str()); + kill(&mut child); + // Recovery: a fresh process sweeps and, if the base is unfinished, + // takes over the dead claim. + for _ in 0..3 { + sweep_family(storage.path(), FAMILY, None) + .unwrap_or_else(|error| panic!("{step:?}: {error}")); + } + let lock = v3::ReservationLock::acquire(storage.path()).unwrap(); + let registry = v3::RegistryWriter::open_existing(storage.path(), FAMILY, &lock).unwrap(); + assert_eq!(registry.accounting().unwrap().reserved_bytes, 0, "{step:?}"); + drop(registry); + drop(lock); + if registered(storage.path(), "worker") { + if let Some(reader) = open_foreign_v3(storage.path(), FAMILY, "worker", &producers()) + .unwrap_or_else(|error| panic!("{step:?}: dangling reference: {error}")) + { + assert!(reader.entry(&rel("src/lib.rs")).is_some(), "{step:?}"); + } + } + if step == DurabilityStep::BaseClaimed { + let paths = checkout(&storage.path().join("roots/main"), FILES); + let scope = { + let BaseBuild::Published { claim, .. } = build_base( + BaseBuildInput { + storage: storage.path(), + family: FAMILY, + root: &storage.path().join("roots/main"), + paths: &paths, + policy: policy(), + semantic_producer: None, + callgraph_producer: "graph".into(), + ignore_fingerprint: None, + history: None, + budget: DiskBudget::default(), + }, + None, + ) + .unwrap() else { + panic!("{step:?}: the dead claim was not taken over"); + }; + assert_eq!(claim.attempt(), 2, "{step:?}"); + claim.scope().to_owned() + }; + assert!(matches!( + claim_base(storage.path(), FAMILY, &scope, None).unwrap(), + ClaimOutcome::Busy { attempt: 2, .. } + )); + } + assert!(!storage.path().join("views/v3").exists(), "{step:?}"); + } +} + +/// Delete main, kill main's daemon, run GC in another process, restart the +/// worker: it serves from its base with correct answers. +#[test] +fn a_worker_restarted_after_main_is_deleted_and_collected_serves_from_its_base() { + v3::set_free_space_for_test(u64::MAX, u64::MAX / 2); + let storage = tempdir().unwrap(); + let control = tempdir().unwrap(); + let main_root = storage.path().join("roots/main"); + let ready = control.path().join("main"); + let mut main = spawn_child( + "build-main-and-park", + storage.path(), + &ready, + &[("BASE_REF_ROOT", main_root.to_str().unwrap())], + ); + wait_for_file(&mut main, &ready); + // The base main's daemon built is the family's only base member. + let scope = with_base_barrier(storage.path(), FAMILY, |tx| { + Ok( + tx.query_row("SELECT scope FROM members WHERE kind='base'", [], |r| { + r.get::<_, String>(0) + })?, + ) + }) + .unwrap(); + // The worker binds and its daemon exits. + let ready_worker = control.path().join("worker"); + let mut worker = spawn_child( + "bind-and-park", + storage.path(), + &ready_worker, + &[("BASE_REF_WORKER", "worker"), ("BASE_REF_BASE", &scope)], + ); + wait_for_file(&mut worker, &ready_worker); + kill(&mut worker); + // Delete main and kill its daemon, then collect in a foreign process. + fs::remove_dir_all(&main_root).unwrap(); + kill(&mut main); + let ready_gc = control.path().join("gc"); + let mut gc = spawn_child("foreign-gc", storage.path(), &ready_gc, &[]); + wait_for_file(&mut gc, &ready_gc); + assert!(gc.wait().unwrap().success()); + // Restart the worker: its published generation composes over the base. + assert!(registered(storage.path(), &scope)); + let reader = open_foreign_v3(storage.path(), FAMILY, "worker", &producers()) + .unwrap() + .unwrap(); + for (name, bytes) in FILES { + let entry = reader.entry(&rel(name)).unwrap(); + assert_eq!(entry.content(), Some(ContentHash::of(bytes)), "{name}"); + assert!(matches!( + entry.plane_state(FamilyPlane::Trigram), + Some(PlaneState::Ready { .. }) + )); + } + assert!(reader.entry(&rel("src/worker_only.rs")).is_some()); + assert!(!storage.path().join("views/v3").exists()); +} From e1373f6cd0f2b4bb675fb90321541cb8b4e9b6fb Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Mon, 26 Oct 2026 16:37:02 +0100 Subject: [PATCH 006/100] mason: register the base-reference schema and Windows probe in the v3 format doc Co-authored-by: alfonso-aft <289616620+alfonso-aft@users.noreply.github.com> --- docs/design/views-v3-format.md | 66 ++++++++++++++++++++++++++++++++-- 1 file changed, 63 insertions(+), 3 deletions(-) diff --git a/docs/design/views-v3-format.md b/docs/design/views-v3-format.md index 97eb07ad19..63af60f7e4 100644 --- a/docs/design/views-v3-format.md +++ b/docs/design/views-v3-format.md @@ -419,9 +419,15 @@ post-checkpoint empty file. `reader-floor.json` untouched; at the re-check it may leave `blobs/v3/`, the lock file and an empty registry, but no reservation row or plane store. - Assumptions: different storage roots on one device are not coordinated. On - non-unix the probe reports unsupported, so every reservation is refused as - `device_free_space_floor` (unverified on a native non-unix run). -- Wiring these budgets into `DiskBudget` (`views/eviction.rs`) is slice 4's job. + Windows the probe is `GetDiskFreeSpaceExW` (caller-available bytes and + total bytes of the nearest existing ancestor); other non-unix targets + report unsupported, so every reservation there is refused as + `device_free_space_floor`. The Windows probe is compile-checked only so + far; a native Windows run is still owed. +- `DiskBudget::admit_v3`/`extend_v3` (`views/eviction.rs`) admit v3 + reservations against the family hard limit (refused as + `storage_gap:v3_family_hard_limit`, never a legacy fallback) and flag + usage above the 2 GiB target so the caller runs the family's v3 sweep. ## Retention collector @@ -522,6 +528,60 @@ of bytes and `st_mode` over `blobs/v3/` must be unchanged, and no v3 member may appear in a v2 registry. The tests are `#[ignore]`d (they take seven and thirteen minutes) and need `AFT_V0_60_0_BINARY`. +## Base-reference protocol (registry 2/2) + +Source: `views/base_refs.rs`, `views/base_sweep.rs`, `views/base_builder.rs`. +A 1/1 registry is upgraded in one barrier transaction, only by a writer that +first raised the `views_v3_registry` floor to 2 +(`blob_store::v3::open_base_refs_registry`). Registries created by the copy +path stay 1/1 until then. The added schema text is: + +```sql +CREATE TABLE base_refs (worker_scope TEXT NOT NULL, base_scope TEXT NOT NULL, nonce TEXT NOT NULL, + membership_digest TEXT NOT NULL, worker_generation TEXT, + state TEXT NOT NULL CHECK(state IN ('intent','orphaned','published')), + owner_pid INTEGER NOT NULL, owner_start INTEGER NOT NULL, updated_epoch INTEGER NOT NULL, + PRIMARY KEY(worker_scope, base_scope, nonce)); +CREATE INDEX base_refs_by_base ON base_refs(base_scope); +CREATE TABLE base_builds (base_scope TEXT NOT NULL PRIMARY KEY, + owner_pid INTEGER NOT NULL, owner_start INTEGER NOT NULL, owner_token TEXT NOT NULL, + attempt INTEGER NOT NULL CHECK(attempt>=1), + state TEXT NOT NULL CHECK(state IN ('building','serving')), heartbeat_at_ms INTEGER NOT NULL); +CREATE TABLE strict_rebuild (worker_scope TEXT NOT NULL, worker_generation TEXT NOT NULL, + reason TEXT NOT NULL, recorded_epoch INTEGER NOT NULL, PRIMARY KEY(worker_scope, worker_generation)); +CREATE TABLE base_sweeps (base_scope TEXT NOT NULL PRIMARY KEY, + unreferenced_sweeps INTEGER NOT NULL DEFAULT 0); +CREATE TABLE sweep_state (singleton INTEGER PRIMARY KEY CHECK(singleton=1), + gc_epoch INTEGER NOT NULL, lease_pid INTEGER, lease_start INTEGER, reconcile_cursor TEXT, + dangling_base_edges INTEGER NOT NULL DEFAULT 0, live_base_cap_hits INTEGER NOT NULL DEFAULT 0); +``` + +- **Identity.** The membership digest is BLAKE3 over the length-prefixed + tag `aft/views-v3/base-membership/1`, the entry count, then per path (in + path order) the path bytes, an entry-kind byte, and mode, content hash, + size and resolution-input flag (regular files), target bytes (symlinks), + OID (gitlinks) or name (synthetic entries). Readiness is excluded. A + manifest with `head_tree`, any `git_oid`, a `base` header, tombstones or + overrides is not a base. The identity digest (tag + `aft/views-v3/base-identity/1`) covers family, all producers, the ignore + fingerprint, the platform family, `head_tree:none` and the membership + digest. The base scope is `base-m1-`. +- **Authentication.** Every consumer resolves a base by pin-then-verify and + recomputes both digests from the pinned manifest; a `BaseHeader` is + checked against that result, never trusted. `bound_generation` is + diagnostic. +- **Markers.** A `BaseRefMarker` is a read marker in `B/readers//` whose + file also carries a per-acquisition `nonce` (omitted, and so unchanged, + for ordinary readers). +- **Sweep.** Runs under the reservation lock; marks current, read-marked + and pinned generations; reconciles at most 256 rows / 25 ms per pass from + a wrap-around position kept in `sweep_state.reconcile_cursor`; reclaims a + base after two consecutive unreferenced sweeps. The plane-row sweep keeps + its resume position at `retention/cursors/v3-plane-rows/.json` + (format 1) when the retention collector runs it. +- **Segments.** v3 generations carry no trigram segment yet: v3 has no + segment rows or segment collection, so readers build from payloads. + ## Deferred implementation register This register assigns the remaining formats **before their owners write them**. From 6987f4166f7fa3e2b624e48f3e21d3f274053eff Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Mon, 26 Oct 2026 16:55:01 +0100 Subject: [PATCH 007/100] mason: clarify base-builder comments and drop a timing dependency from the republication row test The republication test now runs its sweep with a 60 s reconciliation time budget: under a loaded test runner the ruled 25 ms budget could end the pass before its final empty batch, which marks the pass incomplete without changing the rows the test asserts. Co-authored-by: alfonso-aft <289616620+alfonso-aft@users.noreply.github.com> --- crates/aft/src/views/base_builder.rs | 11 ++++++----- crates/aft/src/views/base_builder_tests.rs | 3 ++- crates/aft/src/views/base_sweep_tests.rs | 9 ++++++++- docs/design/views-v3-format.md | 3 ++- 4 files changed, 18 insertions(+), 8 deletions(-) diff --git a/crates/aft/src/views/base_builder.rs b/crates/aft/src/views/base_builder.rs index f4808a0473..d8387750e5 100644 --- a/crates/aft/src/views/base_builder.rs +++ b/crates/aft/src/views/base_builder.rs @@ -2,8 +2,8 @@ //! (with the build claim) single-flight construction. //! //! A base is published once its membership and lexical plane are complete. -//! From then on its membership digest, identity and trigram segment are -//! fixed; its pointer moves only to readiness successors, which may turn +//! From then on its membership digest, identity and trigram segment (none, +//! for v3 generations today) are fixed; its pointer moves only to readiness successors, which may turn //! `Pending` into `Ready` or `Failed`, `Failed` into `Ready`, and nothing //! else. A `Ready` key never changes. @@ -177,11 +177,12 @@ pub const CLAIM_STALE: std::time::Duration = std::time::Duration::from_secs(15); /// Ruled cap of live bases per family, counting claimed or registered but /// unpublished bases. pub const LIVE_BASES_PER_FAMILY: i64 = 64; -/// Initial build reservation cap and extension step (pre-slice-4 ruling). +/// Initial build reservation cap and extension step, as fixed by the +/// maintainers' pre-slice-4 ruling (docs/design/views-slice4-opening.md). pub const INITIAL_RESERVATION_CAP: u64 = 1 << 30; pub const RESERVATION_STEP: u64 = 256 << 20; -/// The ruled initial reservation for a base of `files` members and +/// The initial reservation (docs/design/views-slice4-opening.md) for a base of `files` members and /// `source_bytes` bytes: `ceil_MiB(64 MiB + max(64 KiB × N, 2 × B, /// 1.25 × P × max(N/Np, B/Bp)))`, capped at 1 GiB. `history` is a comparable /// base's `(peak allocated build bytes P, Np, Bp)`; it is ignored when absent @@ -533,7 +534,7 @@ pub enum BaseBuild { /// Builds a membership-keyed base from strict, stability-checked reads of /// `input.paths`, keyed by the tagged membership-and-content digest /// (`head_tree = None`, no `git_oid`). Single-flight through the build claim; -/// admission through the v3 disk budget with the ruled initial reservation. +/// admission through the v3 disk budget, starting with [`initial_reservation`]. /// The first publication happens once membership and the lexical plane are /// complete; graph and semantic entries stay pending for readiness successors. pub fn build_base( diff --git a/crates/aft/src/views/base_builder_tests.rs b/crates/aft/src/views/base_builder_tests.rs index bf5fc87758..b08bf23a65 100644 --- a/crates/aft/src/views/base_builder_tests.rs +++ b/crates/aft/src/views/base_builder_tests.rs @@ -24,7 +24,8 @@ fn dead_claim(storage: &Path, scope: &str, token: &str, attempt: i64) { #[test] fn the_initial_reservation_follows_the_ruled_formula_and_cap() { - // The ruling's AFT fixture: 5,377 files / 87,031,923 bytes, no history. + // The opening note's AFT fixture: 5,377 files / 87,031,923 bytes, no + // history, which it says reserves 401 MiB. assert_eq!(initial_reservation(5_377, 87_031_923, None), 401 * MIB); // A 200,000-file repository is capped at 1 GiB, not refused. assert_eq!( diff --git a/crates/aft/src/views/base_sweep_tests.rs b/crates/aft/src/views/base_sweep_tests.rs index 5c2773d8cb..bcc6486d1b 100644 --- a/crates/aft/src/views/base_sweep_tests.rs +++ b/crates/aft/src/views/base_sweep_tests.rs @@ -301,7 +301,14 @@ fn repeated_same_base_republications_do_not_grow_the_rows() { &v3::view_dir(storage.path(), FAMILY, "worker-a").unwrap(), ); assert_eq!(refs(storage.path()).len(), 6); - let report = sweep(storage.path()); + // Only the row cap may cut this pass; a slow machine must not. + let lock = ReservationLock::acquire(storage.path()).unwrap(); + let bounds = SweepBounds { + reconcile_time: Duration::from_secs(60), + ..SweepBounds::default() + }; + let report = sweep_family_locked(storage.path(), FAMILY, &lock, None, bounds, None).unwrap(); + drop(lock); assert!(report.reconcile_complete, "{report:?}"); let rows = refs(storage.path()); assert_eq!(rows.len(), 1, "{rows:?}"); diff --git a/docs/design/views-v3-format.md b/docs/design/views-v3-format.md index 63af60f7e4..75d67c212a 100644 --- a/docs/design/views-v3-format.md +++ b/docs/design/views-v3-format.md @@ -570,7 +570,8 @@ CREATE TABLE sweep_state (singleton INTEGER PRIMARY KEY CHECK(singleton=1), recomputes both digests from the pinned manifest; a `BaseHeader` is checked against that result, never trusted. `bound_generation` is diagnostic. -- **Markers.** A `BaseRefMarker` is a read marker in `B/readers//` whose +- **Markers.** A `BaseRefMarker` is a read marker in `M/readers//`, where + `M` is the base's member directory and `G` its current generation, whose file also carries a per-acquisition `nonce` (omitted, and so unchanged, for ordinary readers). - **Sweep.** Runs under the reservation lock; marks current, read-marked From 21d7daa58e34f81f1b4b0bace0818a9425c185e6 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Mon, 26 Oct 2026 17:06:41 +0100 Subject: [PATCH 008/100] search-quality descriptor for views-c-base-references Co-authored-by: alfonso-aft <289616620+alfonso-aft@users.noreply.github.com> --- .../slice-descriptors/land-views-c-base-references.json | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 benchmarks/aft-search/slice-descriptors/land-views-c-base-references.json diff --git a/benchmarks/aft-search/slice-descriptors/land-views-c-base-references.json b/benchmarks/aft-search/slice-descriptors/land-views-c-base-references.json new file mode 100644 index 0000000000..ebd574711e --- /dev/null +++ b/benchmarks/aft-search/slice-descriptors/land-views-c-base-references.json @@ -0,0 +1,8 @@ +{ + "targeted_mechanism": "none", + "kind": "harness", + "slice_class": "non_ranking", + "fixtures": [ + "harness-goldens" + ] +} From 3cd88cf23eac2bd341e09220b9522354e2e5882c Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Mon, 26 Oct 2026 17:41:48 +0100 Subject: [PATCH 009/100] mason: v3 storage route and v3 storage for the trigram, semantic and callgraph planes Storage route: a generation opened or published in the v3 namespace carries a V3Route (storage, family, member scope); ViewAccess::route(generation) returns StorageRoute::V2 or ::V3, and ViewRegistration::v3_route names a checkout's v3 member. Routing is per generation rather than per access because one checkout reads both v2 and v3 generations during the switch. resolve_base, publish_worker and open_foreign_v3 set the route. - v3 store API: PlaneReader::contains, Publication::put_or_touch and Publication::touch (missing keys reported), PayloadSource for PlaneReader. - Trigram: a v3 generation has no segment file. The adapter assembles the base generation's segment in memory from the base's stored payloads (cached per base generation) and overlays the worker's changed entries from the v3 store. No new on-disk rows. materialize_v3_observed writes a worker delta's payloads from attachments or verified reads. - Semantic: SemanticStore (v2 FamilyStore or v3 route) carries put_or_touch/touch/get/reader for fills; v3 writes go through v3::publish, fills of a v3 generation protect keys in a v3 live pin (LivePin::create_v3), and verified-run markers are skipped on v3. The arena reads runs through a RunSource trait (v2 reader or v3 PlaneReader). SemanticPlane::materialize_v3 applies the existing readiness rules with v3 presence checks and touches. - Callgraph: CompositePlane::materialize_v3/finish_generation_v3. The bridge puts delta blobs into v3 and builds the composed generation's derived DB in the worker's v3 member directory once the projection is complete; CallgraphPlane::open_generation reads a v3 generation's derived DB from its member directory. Base successors build derived--a-.sqlite named by derived-.ref. - publish_worker_with runs a step with the final generation name before the CAS (for derived renames). Co-authored-by: alfonso-aft <289616620+alfonso-aft@users.noreply.github.com> (cherry picked from commit 50757b3b99fc5881775c636377d92a316b14266b) --- crates/aft/src/blob_store/v3.rs | 43 ++ crates/aft/src/views/base_builder.rs | 69 +++- crates/aft/src/views/base_refs.rs | 68 +++- crates/aft/src/views/callgraph.rs | 70 +++- crates/aft/src/views/contracts.rs | 20 + crates/aft/src/views/first_load.rs | 197 ++++++++++ crates/aft/src/views/mod.rs | 2 + crates/aft/src/views/registry.rs | 10 + crates/aft/src/views/segment_store.rs | 7 + crates/aft/src/views/semantic.rs | 518 +++++++++++++++++++------ crates/aft/src/views/semantic_arena.rs | 47 ++- crates/aft/src/views/snapshot.rs | 32 ++ crates/aft/src/views/trigram.rs | 149 +++++++ crates/aft/src/views/v3_plane_tests.rs | 355 +++++++++++++++++ docs/design/views-v3-format.md | 14 +- 15 files changed, 1453 insertions(+), 148 deletions(-) create mode 100644 crates/aft/src/views/v3_plane_tests.rs diff --git a/crates/aft/src/blob_store/v3.rs b/crates/aft/src/blob_store/v3.rs index c153892f54..36913daef3 100644 --- a/crates/aft/src/blob_store/v3.rs +++ b/crates/aft/src/blob_store/v3.rs @@ -1552,6 +1552,40 @@ impl Publication<'_, '_> { pub fn put(&mut self, key: &FamilyKey, payload: &[u8], payload_schema: u32) -> Result<()> { self.put_row(key, payload, payload_schema).map(|_| ()) } + /// Inserts the row, or touches an equal existing one. `true` means the + /// row was inserted by this call. + pub fn put_or_touch( + &mut self, + key: &FamilyKey, + payload: &[u8], + payload_schema: u32, + ) -> Result { + self.put_row(key, payload, payload_schema) + } + /// Stamps the plane's current `gc_epoch` on each existing row of `keys` + /// and returns the keys that have no row. Never inserts, and never creates + /// a plane store. The caller protects every key (a pin or live pin) before + /// touching it, so a sweep either marks it or sees the new epoch. + pub fn touch(&mut self, keys: &[FamilyKey]) -> Result> { + let mut missing = Vec::new(); + for key in keys { + let Some(mut c) = self.plane_with(key.plane(), false)? else { + missing.push(*key); + continue; + }; + let tx = c.transaction_with_behavior(TransactionBehavior::Immediate)?; + let sql = if plane_layout(&tx)? == 1 { + "UPDATE blob_payloads SET ref_epoch=(SELECT gc_epoch FROM store_meta) WHERE full_key=?1" + } else { + "UPDATE blob_liveness SET ref_epoch=(SELECT gc_epoch FROM store_meta) WHERE blob_id=(SELECT id FROM blob_payloads WHERE full_key=?1)" + }; + if tx.execute(sql, [key.as_bytes().as_slice()])? == 0 { + missing.push(*key); + } + tx.commit()?; + } + Ok(missing) + } /// Inserts the row, or touches an equal existing one. `true` means inserted. fn put_row(&mut self, key: &FamilyKey, payload: &[u8], payload_schema: u32) -> Result { let mut c = self.plane(key.plane())?; @@ -1661,6 +1695,15 @@ impl PlaneReader { )?, }) } + /// True when the store holds a row for `key`. A presence probe through + /// the key index: the payload is not read. + pub fn contains(&self, key: &FamilyKey) -> Result { + Ok(self.database.connection()?.query_row( + "SELECT EXISTS(SELECT 1 FROM blob_payloads WHERE full_key=?1)", + [key.as_bytes().as_slice()], + |r| r.get(0), + )?) + } pub fn get(&self, key: &FamilyKey) -> Result>> { let row: Option<(Vec, Vec)> = self .database diff --git a/crates/aft/src/views/base_builder.rs b/crates/aft/src/views/base_builder.rs index d8387750e5..55276b2c29 100644 --- a/crates/aft/src/views/base_builder.rs +++ b/crates/aft/src/views/base_builder.rs @@ -129,6 +129,18 @@ pub fn publish_base( family: &str, manifest: &ManifestV2, observer: Option<&dyn DurabilityObserver>, +) -> Result { + publish_base_with(storage, family, manifest, observer, &mut |_, _| Ok(())) +} + +/// [`publish_base`] with a step that runs after the new manifest is durable +/// under its private name and before the pointer CAS. +fn publish_base_with( + storage: &Path, + family: &str, + manifest: &ManifestV2, + observer: Option<&dyn DurabilityObserver>, + before_cas: &mut dyn FnMut(&ViewStore, &str) -> Result<()>, ) -> Result { let identity = BaseIdentity::of(family, manifest)?; let (dir, registered) = with_base_barrier(storage, family, |tx| { @@ -148,6 +160,9 @@ pub fn publish_base( } let name = GenerationName::for_manifest(manifest)?; let prepared = store.prepare_v2(&name, current.as_deref(), manifest, observer)?; + // The private manifest is pinned by `prepared`; derived artifacts named + // after it are built before the pointer can name it. + before_cas(&store, &name.to_string())?; match store.commit_v2(prepared, observer)? { PublishV2::Published => { if current.is_some() { @@ -360,6 +375,50 @@ impl BuildClaim { self.attempt, self.token ) } + /// Builds the base's derived call graph for `generation` once every + /// callgraph member of `manifest` is ready, as + /// `derived--a-.sqlite` with a + /// `derived-.ref` naming it, so a takeover never reuses a + /// dead attempt's file. While any member is pending nothing is built. + fn build_derived( + &self, + store: &ViewStore, + generation: &str, + manifest: &ManifestV2, + ) -> Result<()> { + let composed = super::snapshot::ComposedGeneration::new(generation, manifest.clone(), None); + if super::callgraph::project_manifest(&composed).is_err() { + return Ok(()); + } + let owner = format!("{generation}-a{}-{}", self.attempt, self.token); + // `derived_name` is `derived-.sqlite`: the name the + // reference file below resolves to. + let database = store.view_dir().join(self.derived_name(generation)); + let reader = super::callgraph::V3BlobReader(v3::PlaneReader::open_existing( + &self.storage, + &self.family, + FamilyPlane::Callgraph, + )?); + super::callgraph::materialize(&database, &composed, &reader) + .map_err(|error| BaseRefError::Io(std::io::Error::other(error.reason)))?; + let (busy, frames, checkpointed) = + super::sqlite_full_sync_checkpoint(&database, "base_builder::build_derived")?; + if busy != 0 || (frames >= 0 && checkpointed != frames) { + return Err(BaseRefError::Io(std::io::Error::other( + "base derived graph checkpoint incomplete", + ))); + } + let reference = store.view_dir().join(format!("derived-{generation}.ref")); + let temporary = store + .view_dir() + .join(format!(".derived-{generation}.ref.tmp")); + std::fs::write(&temporary, owner.as_bytes())?; + std::fs::File::open(&temporary)?.sync_all()?; + std::fs::rename(&temporary, &reference)?; + super::sync_directory(store.view_dir())?; + Ok(()) + } + /// Deletes the staging directories of a dead attempt. Called only after /// the claim CAS confirmed that attempt's owner dead. fn remove_dead_staging(&self, attempt: i64, token: &str) -> Result<()> { @@ -677,7 +736,15 @@ pub fn publish_successor( let mut next = base.manifest().clone(); drop(base); update(&mut next); - match publish_base(&claim.storage, &claim.family, &next, observer)? { + let mut build_graph = + |store: &ViewStore, generation: &str| claim.build_derived(store, generation, &next); + match publish_base_with( + &claim.storage, + &claim.family, + &next, + observer, + &mut build_graph, + )? { BasePublication::Published { generation, .. } => Ok(generation), BasePublication::Lost { current } => Err(BaseRefError::Unavailable(format!( "the base pointer moved to {current:?}" diff --git a/crates/aft/src/views/base_refs.rs b/crates/aft/src/views/base_refs.rs index 24341b43e2..d3dc104fe2 100644 --- a/crates/aft/src/views/base_refs.rs +++ b/crates/aft/src/views/base_refs.rs @@ -312,12 +312,24 @@ impl AuthenticatedBase { } /// The base side of a composed generation. It owns the marker, so the base /// generation stays protected for as long as any bundle holds it. - pub fn into_generation(self) -> Arc { - Arc::new(ComposedGeneration::new( - self.generation, - self.manifest, - Some(Residency::Marker(self.marker)), - )) + pub fn into_generation(self, storage: &Path, family: &str) -> Arc { + let route = route(storage, family, &self.identity.scope); + Arc::new( + ComposedGeneration::new( + self.generation, + self.manifest, + Some(Residency::Marker(self.marker)), + ) + .with_v3_route(route), + ) + } +} + +pub(crate) fn route(storage: &Path, family: &str, scope: &str) -> super::snapshot::V3Route { + super::snapshot::V3Route { + storage: storage.to_path_buf(), + family: family.to_owned(), + scope: scope.to_owned(), } } @@ -667,6 +679,19 @@ thread_local! { pub fn publish_worker( request: WorkerPublication<'_>, observer: Option<&dyn DurabilityObserver>, +) -> Result { + publish_worker_with(request, observer, &mut |_| Ok(())) +} + +/// [`publish_worker`] with a step that runs once the final generation name is +/// known (after the delta is rebound to the authenticated base header and +/// its manifest is durable) and before the pointer CAS. Plane finishers +/// rename private derived files to that name here. An error aborts the job +/// like a failed verify. +pub fn publish_worker_with( + request: WorkerPublication<'_>, + observer: Option<&dyn DurabilityObserver>, + before_cas: &mut dyn FnMut(&str) -> Result<()>, ) -> Result { let WorkerPublication { storage, @@ -758,7 +783,7 @@ pub fn publish_worker( if let Err(error) = delta.set_base_header(header.clone()) { return Err(abort(error.into(), marker)); } - let base_generation = authenticated.into_generation(); + let base_generation = authenticated.into_generation(storage, family); let worker_store = match ViewStore::open_dir(worker_dir.clone()) { Ok(store) => store, Err(error) => return Err(abort(error.into(), marker)), @@ -772,6 +797,9 @@ pub fn publish_worker( Ok(prepared) => prepared, Err(error) => return Err(abort(error.into(), marker)), }; + if let Err(error) = before_cas(&generation) { + return Err(abort(error, marker)); + } // The bundle's own marker on the new worker generation exists before the // CAS, so the generation is protected from the moment it is published. let worker_marker = match ReadMarker::create(&worker_dir, &generation) { @@ -784,7 +812,7 @@ pub fn publish_worker( base_generation, Some(Residency::Marker(worker_marker)), ) { - Ok(composed) => Arc::new(composed), + Ok(composed) => Arc::new(composed.with_v3_route(route(storage, family, worker_scope))), Err(error) => return Err(abort(error.into(), marker)), }; @@ -931,11 +959,10 @@ pub fn open_foreign_v3( let manifest = store.load_manifest_v2(&generation)?; manifest.ensure_producers(producers)?; let Some(header) = manifest.base_header().cloned() else { - return Ok(Some(Arc::new(ComposedGeneration::new( - generation, - manifest, - Some(Residency::Marker(marker)), - )))); + return Ok(Some(Arc::new( + ComposedGeneration::new(generation, manifest, Some(Residency::Marker(marker))) + .with_v3_route(route(storage, family, scope)), + ))); }; let base = match resolve_base(storage, family, &header.scope) .and_then(|base| base.check_header(&header).map(|()| base)) @@ -947,12 +974,15 @@ pub fn open_foreign_v3( } Err(error) => return Err(error), }; - return Ok(Some(Arc::new(ComposedGeneration::compose( - generation, - manifest, - base.into_generation(), - Some(Residency::Marker(marker)), - )?))); + return Ok(Some(Arc::new( + ComposedGeneration::compose( + generation, + manifest, + base.into_generation(storage, family), + Some(Residency::Marker(marker)), + )? + .with_v3_route(route(storage, family, scope)), + ))); } Err(last.unwrap_or_else(|| { BaseRefError::Unavailable(format!("{scope} moved during {RESOLVE_ATTEMPTS} attempts")) diff --git a/crates/aft/src/views/callgraph.rs b/crates/aft/src/views/callgraph.rs index b8c6f3f83a..80d067008d 100644 --- a/crates/aft/src/views/callgraph.rs +++ b/crates/aft/src/views/callgraph.rs @@ -90,6 +90,18 @@ impl ManifestBlobReader for BlobReader { } } +/// Reads callgraph blobs from the family's v3 callgraph plane store. +pub struct V3BlobReader(pub crate::blob_store::v3::PlaneReader); +impl ManifestBlobReader for V3BlobReader { + fn read_callgraph_blob(&self, key: &str) -> Result>, ManifestJoinError> { + let bytes = parse_hex32(key) + .ok_or_else(|| ManifestJoinError::InvalidBlob("invalid callgraph key".into()))?; + self.0 + .get(&FamilyKey::new(FamilyPlane::Callgraph, bytes)) + .map_err(|e| ManifestJoinError::InvalidBlob(e.to_string())) + } +} + /// Project membership and compatible ready keys, not checkout paths/configuration. /// Missing/failed callgraph members refuse construction rather than falsely ready. pub fn project_manifest( @@ -147,6 +159,38 @@ pub fn materialize( .map_err(error) } +/// The checkout root recorded for a v3 member, read without any lock. +fn v3_member_root(route: &super::snapshot::V3Route) -> Result { + use rusqlite::OptionalExtension; + let root: Option>> = + crate::blob_store::v3::LedgerReader::open_existing(&route.storage, &route.family) + .and_then(|ledger| { + ledger.read(|c| { + Ok(c.query_row( + "SELECT root FROM members WHERE scope=?1", + [&route.scope], + |r| r.get(0), + ) + .optional()?) + }) + }) + .map_err(error)?; + let bytes = root + .flatten() + .ok_or_else(|| error("v3 member root cannot be bound"))?; + #[cfg(unix)] + { + use std::os::unix::ffi::OsStrExt; + Ok(PathBuf::from(std::ffi::OsStr::from_bytes(&bytes))) + } + #[cfg(not(unix))] + { + String::from_utf8(bytes) + .map(PathBuf::from) + .map_err(|_| error("v3 member root is not UTF-8")) + } +} + /// Holding a reader keeps exactly its generation pinned, including after a swap. pub struct PinnedReader { pub store: Arc, @@ -201,12 +245,26 @@ impl PlaneAdapter for CallgraphPlane { generation: &Arc, ) -> Result<(), PlaneError> { queryable_manifest(generation)?; - let (root, dir): (PathBuf, PathBuf) = match access { - ViewAccess::Owner(view) => (view.root().to_path_buf(), view.view_dir().to_path_buf()), - ViewAccess::Reader { - registration, - scope, - } => { + let route = access.route(generation); + let (root, dir): (PathBuf, PathBuf) = match (access, route) { + // A v3 generation's derived graph lives in its v3 member + // directory; the checkout root binds rendering as on v2. + (ViewAccess::Owner(view), super::contracts::StorageRoute::V3(route)) => { + (view.root().to_path_buf(), route.member_dir()) + } + (ViewAccess::Reader { .. }, super::contracts::StorageRoute::V3(route)) => { + (v3_member_root(&route)?, route.member_dir()) + } + (ViewAccess::Owner(view), _) => { + (view.root().to_path_buf(), view.view_dir().to_path_buf()) + } + ( + ViewAccess::Reader { + registration, + scope, + }, + _, + ) => { let member = registration .members() .map_err(error)? diff --git a/crates/aft/src/views/contracts.rs b/crates/aft/src/views/contracts.rs index 2556c1aa71..edecaf0261 100644 --- a/crates/aft/src/views/contracts.rs +++ b/crates/aft/src/views/contracts.rs @@ -233,6 +233,26 @@ impl ViewAccess { } } +/// Where a generation's plane artifacts are read and written. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum StorageRoute { + /// The v2 family store and the registration's v2 view directory. + V2, + /// The v3 plane stores and the v3 member directory of the route's scope. + V3(super::snapshot::V3Route), +} + +impl ViewAccess { + /// The storage of `generation`: a generation opened or published in the + /// v3 namespace carries its route; every other generation is v2. + pub fn route(&self, generation: &ComposedGeneration) -> StorageRoute { + generation + .v3_route() + .cloned() + .map_or(StorageRoute::V2, StorageRoute::V3) + } +} + /// An error from an index plane or loader orchestration, carried into existing /// incomplete/named-gap reporting, never turned into an empty success. #[derive(Clone, Debug, Eq, PartialEq)] diff --git a/crates/aft/src/views/first_load.rs b/crates/aft/src/views/first_load.rs index c9d2bb12c8..7e69e011af 100644 --- a/crates/aft/src/views/first_load.rs +++ b/crates/aft/src/views/first_load.rs @@ -1257,6 +1257,41 @@ pub trait CompositePlane: Send + Sync { staging: &str, published: &str, ) -> Result<(), PlaneError>; + + /// Materializes this plane for a v3 worker generation whose `delta` + /// composes over `base`: rows go to the family's v3 plane stores and + /// derived files to `route.member_dir()` under `staging`. Every new key + /// is listed in `live`, the owner's v3 live pin, before it is put or + /// touched. Only the delta's entries are this checkout's work; inherited + /// base entries belong to the base's builder. + #[allow(clippy::too_many_arguments)] + fn materialize_v3( + &self, + owner: &super::registry::ViewRegistration, + route: &super::snapshot::V3Route, + staging: &str, + base: &Arc, + observed: &BTreeMap, + delta: &mut super::manifest_v2::ManifestV2, + live: &mut crate::pins::LivePin, + ) -> Result<(), PlaneError> { + let _ = (owner, route, staging, base, observed, delta, live); + Err(PlaneError { + plane: Some(self.plane()), + reason: "this plane has no v3 materialization".into(), + }) + } + + /// [`Self::finish_generation`] for a v3 member directory. + fn finish_generation_v3( + &self, + route: &super::snapshot::V3Route, + staging: &str, + published: &str, + ) -> Result<(), PlaneError> { + let _ = (route, staging, published); + Ok(()) + } } /// Supplies the same full membership as the configured lexical walker. Runtime @@ -3308,6 +3343,148 @@ impl CompositePlane for CallgraphBridge { super::sync_directory(owner.view_dir()).map_err(|e| error(e.into()))?; Ok(()) } + fn materialize_v3( + &self, + _owner: &super::registry::ViewRegistration, + route: &super::snapshot::V3Route, + staging: &str, + base: &Arc, + observed: &BTreeMap, + delta: &mut super::manifest_v2::ManifestV2, + live: &mut crate::pins::LivePin, + ) -> Result<(), PlaneError> { + let error = |reason: String| PlaneError { + plane: Some(FamilyPlane::Callgraph), + reason, + }; + let mut attachments = Vec::new(); + for (path, entry) in observed { + if entry.disk == super::snapshot::DiskState::Absent || !self.applies_to(path) { + continue; + } + // Only the delta's own entries; inherited base entries are the + // base's work, and their keys are the base's. + if delta.get(path).is_none() + || super::snapshot::DiskState::in_generation(delta, path) != entry.disk + { + continue; + } + let attachment = entry + .attachments + .get(&FamilyPlane::Callgraph) + .and_then(|value| value.downcast_ref::()) + .ok_or_else(|| error(format!("{path:?}: callgraph attachment missing")))?; + attachments.push((path.clone(), attachment.clone())); + } + live.protect(&attachments.iter().map(|(_, a)| a.key).collect::>()) + .map_err(|e| error(e.to_string()))?; + let mut payloads = Vec::new(); + for (path, attachment) in &attachments { + payloads.push(( + path.clone(), + attachment.key, + attachment + .blob + .to_bytes() + .map_err(|e| error(e.to_string()))?, + )); + } + if !payloads.is_empty() { + let request = payloads + .iter() + .map(|(_, _, bytes)| bytes.len() as u64) + .sum::() + .saturating_mul(2) + .saturating_add(1 << 20); + let schema = FamilyPlane::Callgraph.payload_schema(); + crate::blob_store::v3::publish(&route.storage, &route.family, request, None, |p| { + for (_, key, bytes) in &payloads { + p.put(key, bytes, schema)?; + } + Ok(()) + }) + .map_err(|e| error(e.to_string()))?; + } + for (path, key, _) in &payloads { + if let Some(super::manifest_v2::EntryV2::Regular { + planes, + resolution_input, + .. + }) = delta.get_mut(path) + { + planes.callgraph = Some(super::readiness::PlaneState::ready(key)); + *resolution_input = super::assembly::is_resolution_input(path.as_bytes()); + } + } + // The derived graph covers the composed generation. While the base's + // graph is still pending the projection refuses, and the worker's + // graph stays building until a later publication; nothing is copied. + let composed = ComposedGeneration::compose(staging, delta.clone(), base.clone(), None) + .map_err(|e| error(e.to_string()))?; + if super::callgraph::project_manifest(&composed).is_err() { + return Ok(()); + } + let reader = super::callgraph::V3BlobReader( + crate::blob_store::v3::PlaneReader::open_existing( + &route.storage, + &route.family, + FamilyPlane::Callgraph, + ) + .map_err(|e| error(e.to_string()))?, + ); + let dir = route.member_dir(); + // Open (and so initialize and recover) the member's view store before + // writing a private derived file into it: first-open recovery removes + // derived files no generation owns. The caller protects `staging` with + // an assembly pin, as on the v2 path. + super::ViewStore::open_dir(dir.clone()).map_err(|e| error(e.to_string()))?; + let database = + super::resolve_derived_path(&dir, staging).map_err(|e| error(e.to_string()))?; + super::callgraph::materialize(&database, &composed, &reader) + } + fn finish_generation_v3( + &self, + route: &super::snapshot::V3Route, + staging: &str, + published: &str, + ) -> Result<(), PlaneError> { + let error = |e: super::ViewError| PlaneError { + plane: Some(FamilyPlane::Callgraph), + reason: e.to_string(), + }; + let dir = route.member_dir(); + let staging_path = super::resolve_derived_path(&dir, staging).map_err(error)?; + if !staging_path.exists() { + // The graph was not built for this generation (base graph pending). + return Ok(()); + } + let (busy, frames, checkpointed) = super::sqlite_full_sync_checkpoint( + &staging_path, + "first_load::CallgraphBridge::finish_generation_v3", + ) + .map_err(error)?; + if busy != 0 || (frames >= 0 && checkpointed != frames) { + return Err(PlaneError { + plane: Some(FamilyPlane::Callgraph), + reason: "private callgraph checkpoint incomplete".into(), + }); + } + let published_path = super::resolve_derived_path(&dir, published).map_err(error)?; + if staging_path != published_path { + if crate::db::file_identity::open_connections(&staging_path) != 0 + || published_path.exists() + { + return Err(PlaneError { + plane: Some(FamilyPlane::Callgraph), + reason: "private callgraph install would replace a live database".into(), + }); + } + crate::db::file_identity::guard_replacement(&staging_path, "v3 derived rename"); + std::fs::rename(&staging_path, &published_path).map_err(|e| error(e.into()))?; + } + super::sync_directory(&dir).map_err(|e| error(e.into()))?; + Ok(()) + } } #[cfg(test)] @@ -3893,6 +4070,26 @@ impl CompositePlane for TrigramBridge { ) -> Result<(), PlaneError> { Ok(()) } + fn materialize_v3( + &self, + owner: &super::registry::ViewRegistration, + route: &super::snapshot::V3Route, + _: &str, + _: &Arc, + observed: &BTreeMap, + delta: &mut super::manifest_v2::ManifestV2, + live: &mut crate::pins::LivePin, + ) -> Result<(), PlaneError> { + super::trigram::materialize_v3_observed( + &route.storage, + &route.family, + owner.root(), + delta, + observed, + live, + self.policy, + ) + } } impl CheckoutDriver { diff --git a/crates/aft/src/views/mod.rs b/crates/aft/src/views/mod.rs index ac5b1d59ee..4e5e4bfcc3 100644 --- a/crates/aft/src/views/mod.rs +++ b/crates/aft/src/views/mod.rs @@ -68,6 +68,8 @@ pub mod trigram; mod dispatch_parity_probe; #[cfg(test)] mod per_checkout_core_tests; +#[cfg(test)] +mod v3_plane_tests; use std::collections::{BTreeMap, BTreeSet, HashMap}; use std::fmt; diff --git a/crates/aft/src/views/registry.rs b/crates/aft/src/views/registry.rs index 5d4fcf4c5d..e0f0dcd08a 100644 --- a/crates/aft/src/views/registry.rs +++ b/crates/aft/src/views/registry.rs @@ -1073,6 +1073,16 @@ impl ViewRegistration { } /// The view's own pointer and manifests in `views/v2/`. + /// This checkout's member in the v3 namespace: the same family and scope + /// under `blobs/v3//views/`. + pub fn v3_route(&self) -> super::snapshot::V3Route { + super::snapshot::V3Route { + storage: self.registry().storage().to_path_buf(), + family: self.family().to_owned(), + scope: self.scope().to_owned(), + } + } + pub fn view_store(&self) -> RegistryResult { let view = super::ViewStore::open_dir(self.view_dir.clone())? .with_sweep_registry(self.registry.clone()); diff --git a/crates/aft/src/views/segment_store.rs b/crates/aft/src/views/segment_store.rs index 923eabca83..0ee9b6eee4 100644 --- a/crates/aft/src/views/segment_store.rs +++ b/crates/aft/src/views/segment_store.rs @@ -353,6 +353,13 @@ impl PayloadSource for FamilyStoreReader { } } +impl PayloadSource for crate::blob_store::v3::PlaneReader { + fn payload(&self, key: &FamilyKey) -> Result>, StoreError> { + self.get(key) + .map_err(|error| StoreError::Io(std::io::Error::other(error.to_string()))) + } +} + /// Builds a segment from stored payloads only; no checkout bytes are read. pub fn build_from_blobs( source: &dyn PayloadSource, diff --git a/crates/aft/src/views/semantic.rs b/crates/aft/src/views/semantic.rs index 09812c236c..e12025389d 100644 --- a/crates/aft/src/views/semantic.rs +++ b/crates/aft/src/views/semantic.rs @@ -33,9 +33,7 @@ use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex, MutexGuard}; use std::time::{Duration, Instant}; -use crate::blob_store::v2::{ - ContentHash, FamilyKey, FamilyPlane, FamilyStore, FamilyStoreReader, PutOrTouch, -}; +use crate::blob_store::v2::{ContentHash, FamilyKey, FamilyPlane, FamilyStore, PutOrTouch}; use crate::blob_store::{FullKey, SemanticKey, SEMANTIC_PRODUCER_VERSION}; use crate::refresh::{FailureClass, FailureTracker, PreparedWork}; use crate::semantic_index::{ @@ -339,8 +337,184 @@ fn decode_chunk_vector(payload: &[u8], identity: &str, text: &str) -> Option Result>, String> { + Ok(None) + } + fn markers_at_deletion_epoch( + &self, + _: &mut dyn FnMut(&crate::blob_store::v2::DeletionEpoch) -> Vec, + ) -> Option<(crate::blob_store::v2::DeletionEpoch, HashSet)> { + None + } +} + +fn v3_store_error(error: crate::blob_store::v3::Error) -> crate::blob_store::v2::StoreError { + crate::blob_store::v2::StoreError::Io(std::io::Error::other(error.to_string())) +} + +impl SemanticStore { + fn for_generation( + owner: &ViewRegistration, + generation: &ComposedGeneration, + ) -> Result { + match generation.v3_route() { + Some(route) => Ok(Self::V3(route.clone())), + None => owner + .open_store(FamilyPlane::Semantic) + .map(Self::V2) + .map_err(|error| plane_error(error.to_string())), + } + } + + fn is_v3(&self) -> bool { + matches!(self, Self::V3(_)) + } + + fn v3_reader( + route: &super::snapshot::V3Route, + ) -> Result, crate::blob_store::v2::StoreError> { + let path = + crate::blob_store::v3::plane_path(&route.storage, &route.family, FamilyPlane::Semantic) + .map_err(v3_store_error)?; + if !path.exists() { + return Ok(None); + } + crate::blob_store::v3::PlaneReader::open_existing( + &route.storage, + &route.family, + FamilyPlane::Semantic, + ) + .map(Some) + .map_err(v3_store_error) + } + + fn get(&self, key: &FamilyKey) -> Result>, crate::blob_store::v2::StoreError> { + match self { + Self::V2(store) => store.get(key), + Self::V3(route) => match Self::v3_reader(route)? { + Some(reader) => reader.get(key).map_err(v3_store_error), + None => Ok(None), + }, + } + } + + fn reader(&self) -> Box { + match self { + Self::V2(store) => Box::new(store.reader()), + Self::V3(route) => match Self::v3_reader(route) { + Ok(Some(reader)) => Box::new(reader), + _ => Box::new(NoRuns), + }, + } + } + + fn put_or_touch( + &self, + key: &FamilyKey, + payload: &[u8], + ) -> Result { + match self { + Self::V2(store) => store.put_or_touch(key, payload), + Self::V3(route) => { + let request = (payload.len() as u64) + .saturating_mul(2) + .saturating_add(1 << 20); + let inserted = crate::blob_store::v3::publish( + &route.storage, + &route.family, + request, + None, + |publication| { + publication.put_or_touch( + key, + payload, + FamilyPlane::Semantic.payload_schema(), + ) + }, + ) + .map_err(|error| match error { + crate::blob_store::v3::Error::Refused("payload_mismatch") => { + crate::blob_store::v2::StoreError::ConflictingPayload(*key) + } + other => v3_store_error(other), + })?; + Ok(if inserted { + PutOrTouch::Inserted { ref_epoch: 0 } + } else { + PutOrTouch::Reused { ref_epoch: 0 } + }) + } + } + } + + /// Verified-run markers are a v2 deletion-epoch optimization; v3 stores + /// none, which only costs later loads their chunk-row reads. + fn put_at_deletion_epoch( + &self, + build: impl FnOnce(&crate::blob_store::v2::DeletionEpoch) -> Option<(FamilyKey, Vec)>, + ) -> Result, crate::blob_store::v2::StoreError> { + match self { + Self::V2(store) => store.put_at_deletion_epoch(build), + Self::V3(_) => Ok(None), + } + } + + fn deletion_epoch( + &self, + ) -> Result, crate::blob_store::v2::StoreError> + { + match self { + Self::V2(store) => store.deletion_epoch(), + Self::V3(_) => Ok(None), + } + } + + fn touch( + &self, + keys: &[FamilyKey], + ) -> Result { + match self { + Self::V2(store) => store.touch(keys), + Self::V3(route) => { + if Self::v3_reader(route)?.is_none() { + return Ok(crate::blob_store::v2::TouchReport { + missing: keys.to_vec(), + ..Default::default() + }); + } + let request = (keys.len() as u64).saturating_mul(64 * 1024).max(1 << 20); + let missing = crate::blob_store::v3::publish( + &route.storage, + &route.family, + request, + None, + |publication| publication.touch(keys), + ) + .map_err(v3_store_error)?; + Ok(crate::blob_store::v2::TouchReport { + touched: keys.len() - missing.len(), + missing, + ref_epoch: 0, + }) + } + } + } +} + fn load_chunk( - store: &FamilyStore, + store: &SemanticStore, arena: &super::semantic_arena::SemanticArena, key: &FamilyKey, identity: &str, @@ -506,6 +680,10 @@ struct ViewState { failed: BTreeMap<(RelPath, ContentHash), String>, tracker: FailureTracker, live: Option>>, + /// The live pin in the v3 member directory, for fills of a generation + /// published in the v3 namespace. Its keys are kept until the process + /// ends: post-fold trimming covers only the v2 pin so far. + live_v3: Option>>, active_fills: usize, cache: Option<(CacheKey, Arc)>, } @@ -738,14 +916,122 @@ impl SemanticPlane { .cloned() } - fn store_reader(access: &ViewAccess) -> Result, PlaneError> { + /// Records semantic readiness for `manifest`'s entries; see + /// [`super::first_load::CompositePlane::materialize`]. + fn materialize_with( + &self, + owner: &ViewRegistration, + observed: &BTreeMap, + manifest: &mut ManifestV2, + live: &mut crate::pins::LivePin, + store: &SemanticStore, + ) -> Result<(), PlaneError> { + let id = self.producer.id(); + if manifest.header().producers.semantic.as_deref() != Some(id.as_str()) { + return Err(plane_error("semantic producer mismatch")); + } + let view = self.view(&ViewAccess::Owner(owner.clone())); + let state = lock(&view); + let mut candidates = Vec::new(); + for (path, entry) in manifest.entries_mut() { + let EntryV2::Regular { + content, planes, .. + } = entry + else { + continue; + }; + if !applies_to(path) { + planes.semantic = None; + continue; + } + // A completed small-cap repair replaces the old Ready file-run key. + let repaired = self.producer.caps.total_chars < EmbedTextCaps::default().total_chars + && state + .fill + .get(path, content, FamilyPlane::Semantic) + .is_some(); + if matches!(planes.semantic, Some(PlaneState::Ready { .. })) && !repaired { + continue; + } + let stored = observed + .get(path) + .and_then(|entry| entry.attachments.get(&FamilyPlane::Semantic)) + .and_then(|value| value.downcast_ref::()) + .filter(|attachment| attachment.content == *content && attachment.producer == id) + .map(|attachment| attachment.key); + let key = match state.fill.get(path, content, FamilyPlane::Semantic) { + Some(key) => Some(*key), + None => match stored { + Some(key) => store + .get(&key) + .map_err(|error| plane_error(error.to_string()))? + .is_some() + .then_some(key), + None => None, + }, + }; + if let Some(key) = key { + candidates.push((path.clone(), key)); + } + if let Some(reason) = state.failed.get(&(path.clone(), *content)) { + planes.semantic = Some(PlaneState::Failed { + reason: reason.clone(), + producer: id.clone(), + }); + continue; + } + if !matches!(&planes.semantic, Some(PlaneState::Failed { producer, .. }) if *producer == id) + { + planes.semantic = Some(PlaneState::pending("awaiting semantic fill")); + } + } + if !candidates.is_empty() { + // Pin all candidates before the touch transaction. Missing rows + // remain pending/failed: GC may have won before protection landed. + let keys = candidates.iter().map(|(_, key)| *key).collect::>(); + live.protect(&keys) + .map_err(|error| plane_error(error.to_string()))?; + let missing = store + .touch(&keys) + .map_err(|error| plane_error(error.to_string()))? + .missing + .into_iter() + .collect::>(); + for (path, key) in candidates { + if !missing.contains(&key) { + if let Some(EntryV2::Regular { planes, .. }) = manifest.get_mut(&path) { + planes.semantic = Some(PlaneState::ready(&key)); + } + } + } + } + Ok(()) + } + + fn store_reader( + access: &ViewAccess, + generation: &ComposedGeneration, + ) -> Result>, PlaneError> { + if let super::contracts::StorageRoute::V3(route) = access.route(generation) { + return SemanticStore::v3_reader(&route) + .map(|reader| { + reader + .map(|reader| Box::new(reader) as Box) + }) + .map_err(|error| plane_error(error.to_string())); + } match access { ViewAccess::Owner(owner) => owner .open_store(FamilyPlane::Semantic) - .map(|store| Some(store.reader())) + .map(|store| { + Some(Box::new(store.reader()) as Box) + }) .map_err(|error| plane_error(error.to_string())), ViewAccess::Reader { registration, .. } => registration .open_store(FamilyPlane::Semantic) + .map(|store| { + store.map(|store| Box::new(store) as Box) + }) .map_err(|error| plane_error(error.to_string())), } } @@ -767,7 +1053,7 @@ impl SemanticPlane { let mut legacy_summaries = BTreeSet::new(); if compatible { let arena = self.arena(access.family()); - let store = Self::store_reader(access)?; + let store = Self::store_reader(access, generation)?; // Markers of every run loaded here are probed together afterwards. let mut seeds = Vec::new(); for (path, entry) in manifest.entries() { @@ -786,7 +1072,7 @@ impl SemanticPlane { ) { (Some(bytes), Some(store), Some(relative)) => arena .load_deferring_seed( - store, + store.as_ref(), &FamilyKey::new(FamilyPlane::Semantic, bytes), &relative, &self.producer.payload(), @@ -818,7 +1104,7 @@ impl SemanticPlane { } } if let Some(store) = store.as_ref() { - arena.seed_verified_runs(store, seeds, &self.producer); + arena.seed_verified_runs(store.as_ref(), seeds, &self.producer); } } Ok(Resident { @@ -1014,15 +1300,32 @@ impl SemanticPlane { // The view's state is locked only for short bookkeeping steps, never // across file reads, model calls or store writes, so queries and // publication of this checkout proceed while it fills. - let needs_pin = lock(&view).live.is_none(); + let v3 = snapshot.generation().v3_route().cloned(); + let needs_pin = { + let state = lock(&view); + if v3.is_some() { + state.live_v3.is_none() + } else { + state.live.is_none() + } + }; if needs_pin { - let pin = Arc::new(Mutex::new( - crate::pins::LivePin::create(owner) - .map_err(|error| plane_error(error.to_string()))?, - )); + let pin = match &v3 { + Some(route) => { + crate::pins::LivePin::create_v3(&route.storage, &route.family, &route.scope) + } + None => crate::pins::LivePin::create(owner), + } + .map_err(|error| plane_error(error.to_string()))?; + let pin = Arc::new(Mutex::new(pin)); let mut state = lock(&view); - if state.live.is_none() { - state.live = Some(pin.clone()); + let slot = if v3.is_some() { + &mut state.live_v3 + } else { + &mut state.live + }; + if slot.is_none() { + *slot = Some(pin.clone()); } drop(state); drop(pin); @@ -1105,9 +1408,7 @@ impl SemanticPlane { let lookup_started = Instant::now(); let arena = self.arena(owner.family()); - let store = owner - .open_store(FamilyPlane::Semantic) - .map_err(|error| plane_error(error.to_string()))?; + let store = SemanticStore::for_generation(owner, snapshot.generation())?; let payload_producer = self.producer.payload(); let mut ready = Vec::new(); let mut claimed = Vec::new(); @@ -1123,7 +1424,7 @@ impl SemanticPlane { ready.push((item.clone(), key, run)); } else if let Some(run) = arena .load( - &store.reader(), + &*store.reader(), &key, relative, &payload_producer, @@ -1146,7 +1447,7 @@ impl SemanticPlane { ready.push((item.clone(), key, run)); } else if let Some(run) = arena .load( - &store.reader(), + &*store.reader(), &key, relative, &payload_producer, @@ -1234,7 +1535,7 @@ impl SemanticPlane { ) .collect::>(); let store_started = Instant::now(); - protect(&view, &keys)?; + protect(&view, &keys, store.is_v3())?; report.timing.store_write += store_started.elapsed(); let mut pending = to_embed.into_iter().zip(chunks).peekable(); while pending.peek().is_some() { @@ -1388,7 +1689,7 @@ impl SemanticPlane { } if let Some(run) = arena .load( - &store.reader(), + &*store.reader(), &key, &relative, &payload_producer, @@ -1466,7 +1767,7 @@ impl SemanticPlane { Some(run) => Some(run), None => arena .load( - &store.reader(), + &*store.reader(), &key, &relative, &payload_producer, @@ -1518,7 +1819,7 @@ impl SemanticPlane { fn embed_chunks( &self, arena: &super::semantic_arena::SemanticArena, - store: &FamilyStore, + store: &SemanticStore, texts: Vec, embed: &mut F, report: &mut FillReport, @@ -1662,7 +1963,7 @@ fn chunk_store_error(error: crate::blob_store::v2::StoreError, report: &mut Fill fn admit_ready( view: &Mutex, - store: &FamilyStore, + store: &SemanticStore, ready: Vec<( WorkItem, FamilyKey, @@ -1676,7 +1977,7 @@ fn admit_ready( return Ok(()); } let keys = ready.iter().map(|(_, key, _)| *key).collect::>(); - protect(view, &keys)?; + protect(view, &keys, store.is_v3())?; // GC may have won before the live-pin write. Do not admit a missing row. let swept = store .touch(&keys) @@ -1712,11 +2013,11 @@ fn admit_ready( Ok(()) } -fn protect(view: &Mutex, keys: &[FamilyKey]) -> Result<(), PlaneError> { - let pin = lock(view) - .live - .clone() - .ok_or_else(|| plane_error("semantic fill has no live pin"))?; +fn protect(view: &Mutex, keys: &[FamilyKey], v3: bool) -> Result<(), PlaneError> { + let state = lock(view); + let pin = if v3 { &state.live_v3 } else { &state.live }.clone(); + drop(state); + let pin = pin.ok_or_else(|| plane_error("semantic fill has no live pin"))?; let result = lock(&pin) .protect(keys) .map_err(|error| plane_error(error.to_string())); @@ -2216,6 +2517,7 @@ impl PlaneAdapter for SemanticPlane { failed, tracker: std::mem::take(&mut state.tracker), live: pin.clone(), + live_v3: state.live_v3.clone(), active_fills: 0, cache: None, })); @@ -2344,88 +2646,33 @@ impl super::first_load::CompositePlane for SemanticPlane { live: &mut crate::pins::LivePin, _seed_derived: bool, ) -> Result<(), PlaneError> { - let id = self.producer.id(); - if manifest.header().producers.semantic.as_deref() != Some(id.as_str()) { - return Err(plane_error("semantic producer mismatch")); - } let store = owner .open_store(FamilyPlane::Semantic) + .map(SemanticStore::V2) .map_err(|error| plane_error(error.to_string()))?; - let view = self.view(&ViewAccess::Owner(owner.clone())); - let state = lock(&view); - let mut candidates = Vec::new(); - for (path, entry) in manifest.entries_mut() { - let EntryV2::Regular { - content, planes, .. - } = entry - else { - continue; - }; - if !applies_to(path) { - planes.semantic = None; - continue; - } - // A completed small-cap repair replaces the old Ready file-run key. - let repaired = self.producer.caps.total_chars < EmbedTextCaps::default().total_chars - && state - .fill - .get(path, content, FamilyPlane::Semantic) - .is_some(); - if matches!(planes.semantic, Some(PlaneState::Ready { .. })) && !repaired { - continue; - } - let stored = observed - .get(path) - .and_then(|entry| entry.attachments.get(&FamilyPlane::Semantic)) - .and_then(|value| value.downcast_ref::()) - .filter(|attachment| attachment.content == *content && attachment.producer == id) - .map(|attachment| attachment.key); - let key = match state.fill.get(path, content, FamilyPlane::Semantic) { - Some(key) => Some(*key), - None => match stored { - Some(key) => store - .contains(&key) - .map_err(|error| plane_error(error.to_string()))? - .then_some(key), - None => None, - }, - }; - if let Some(key) = key { - candidates.push((path.clone(), key)); - } - if let Some(reason) = state.failed.get(&(path.clone(), *content)) { - planes.semantic = Some(PlaneState::Failed { - reason: reason.clone(), - producer: id.clone(), - }); - continue; - } - if !matches!(&planes.semantic, Some(PlaneState::Failed { producer, .. }) if *producer == id) - { - planes.semantic = Some(PlaneState::pending("awaiting semantic fill")); - } - } - if !candidates.is_empty() { - // Pin all candidates before the touch transaction. Missing rows - // remain pending/failed: GC may have won before protection landed. - let keys = candidates.iter().map(|(_, key)| *key).collect::>(); - live.protect(&keys) - .map_err(|error| plane_error(error.to_string()))?; - let missing = store - .touch(&keys) - .map_err(|error| plane_error(error.to_string()))? - .missing - .into_iter() - .collect::>(); - for (path, key) in candidates { - if !missing.contains(&key) { - if let Some(EntryV2::Regular { planes, .. }) = manifest.get_mut(&path) { - planes.semantic = Some(PlaneState::ready(&key)); - } - } - } - } - Ok(()) + self.materialize_with(owner, observed, manifest, live, &store) + } + + /// The v3 counterpart: the same readiness rules, with presence checks and + /// touches against the family's v3 semantic store. Only the delta's own + /// entries are materialized; inherited base entries belong to the base. + fn materialize_v3( + &self, + owner: &ViewRegistration, + route: &super::snapshot::V3Route, + _staging: &str, + _base: &Arc, + observed: &BTreeMap, + delta: &mut ManifestV2, + live: &mut crate::pins::LivePin, + ) -> Result<(), PlaneError> { + self.materialize_with( + owner, + observed, + delta, + live, + &SemanticStore::V3(route.clone()), + ) } fn finish_generation( @@ -2553,7 +2800,7 @@ mod tests { crate::pins::LivePin::create(&checkout.owner).unwrap(), ))); } - protect(&view, &[key]).unwrap(); + protect(&view, &[key], false).unwrap(); let mut manifest = super::super::manifest_v2::ManifestV2::new(snapshot.generation().header().clone()); manifest @@ -2600,7 +2847,7 @@ mod tests { let key = plane .producer .key(b"pending bytes", &RelPath::new(b"new.rs".to_vec()).unwrap()); - protect(&view, &[key]).unwrap(); + protect(&view, &[key], false).unwrap(); let path = live_pin_path(&view); plane .generation_published(&checkout.access, snapshot.generation(), &|| { @@ -3820,7 +4067,7 @@ mod tests { let root = tempfile::tempdir().unwrap(); let plane = new_plane(storage.path(), "model-a"); let checkout = Checkout::open(storage.path(), "scope-a", root.path(), &plane); - let store = checkout.owner.open_store(FamilyPlane::Semantic).unwrap(); + let store = SemanticStore::V2(checkout.owner.open_store(FamilyPlane::Semantic).unwrap()); let producer = plane.semantic_producer(); let identity = producer.chunk_identity(); let text = "file:src/a.rs\nfn compact_vector()"; @@ -3908,7 +4155,7 @@ mod tests { let root = tempfile::tempdir().unwrap(); let plane = new_plane(storage.path(), "model-a"); let checkout = Checkout::open(storage.path(), "scope-a", root.path(), &plane); - let store = checkout.owner.open_store(FamilyPlane::Semantic).unwrap(); + let store = SemanticStore::V2(checkout.owner.open_store(FamilyPlane::Semantic).unwrap()); let producer = plane.semantic_producer(); let text = "file:src/a.rs\nfn correct()"; let key = producer.chunk_key(text); @@ -4724,3 +4971,44 @@ mod tests { assert_eq!(plane.readiness(&b.access, &shared), unfilled); } } + +#[cfg(test)] +mod v3_store_tests { + use super::*; + + #[test] + fn a_v3_semantic_store_puts_touches_and_admits_runs_without_the_v2_store() { + let _serial = crate::blob_store::v3::serial_for_test(); + let storage = tempfile::tempdir().unwrap(); + let store = SemanticStore::V3(super::super::snapshot::V3Route { + storage: storage.path().to_path_buf(), + family: "00112233445566ff".into(), + scope: "worker".into(), + }); + let key = FamilyKey::new(FamilyPlane::Semantic, [1; 32]); + assert_eq!(store.get(&key).unwrap(), None); + assert_eq!(store.touch(&[key]).unwrap().missing, vec![key]); + assert!(matches!( + store.put_or_touch(&key, b"run").unwrap(), + PutOrTouch::Inserted { .. } + )); + assert!(matches!( + store.put_or_touch(&key, b"run").unwrap(), + PutOrTouch::Reused { .. } + )); + assert!(matches!( + store.put_or_touch(&key, b"another run"), + Err(crate::blob_store::v2::StoreError::ConflictingPayload(conflict)) if conflict == key + )); + assert_eq!(store.get(&key).unwrap(), Some(b"run".to_vec())); + assert!(store.touch(&[key]).unwrap().missing.is_empty()); + // Verified-run markers are a v2 optimization; v3 writes none. + assert!(store.put_at_deletion_epoch(|_| None).unwrap().is_none()); + assert!(store.deletion_epoch().unwrap().is_none()); + assert_eq!( + store.reader().run_payload(&key).unwrap(), + Some(b"run".to_vec()) + ); + assert!(!storage.path().join("blobs/v2").exists()); + } +} diff --git a/crates/aft/src/views/semantic_arena.rs b/crates/aft/src/views/semantic_arena.rs index 03db203ea1..eb48949100 100644 --- a/crates/aft/src/views/semantic_arena.rs +++ b/crates/aft/src/views/semantic_arena.rs @@ -346,6 +346,43 @@ impl Drop for ArenaClaim { } } +/// Where admitted runs are read from: the v2 family store, or a v3 plane +/// store. Verified-run markers are a v2 deletion-epoch optimization; a source +/// without them returns `None` and its runs simply do not seed the chunk index. +pub(crate) trait RunSource { + fn run_payload(&self, key: &FamilyKey) -> Result>, String>; + fn markers_at_deletion_epoch( + &self, + keys: &mut dyn FnMut(&DeletionEpoch) -> Vec, + ) -> Option<(DeletionEpoch, std::collections::HashSet)>; +} + +impl RunSource for FamilyStoreReader { + fn run_payload(&self, key: &FamilyKey) -> Result>, String> { + self.get(key).map_err(|error| error.to_string()) + } + fn markers_at_deletion_epoch( + &self, + keys: &mut dyn FnMut(&DeletionEpoch) -> Vec, + ) -> Option<(DeletionEpoch, std::collections::HashSet)> { + self.contains_many_at_deletion_epoch(|epoch| keys(epoch)) + .ok() + .flatten() + } +} + +impl RunSource for crate::blob_store::v3::PlaneReader { + fn run_payload(&self, key: &FamilyKey) -> Result>, String> { + self.get(key).map_err(|error| error.to_string()) + } + fn markers_at_deletion_epoch( + &self, + _keys: &mut dyn FnMut(&DeletionEpoch) -> Vec, + ) -> Option<(DeletionEpoch, std::collections::HashSet)> { + None + } +} + impl SemanticArena { #[cfg(test)] pub(crate) fn set_chunk_shortcut(&self, enabled: bool) { @@ -419,7 +456,7 @@ impl SemanticArena { /// chunk index; see [`Self::seed_verified_runs`]. pub(crate) fn load( &self, - store: &FamilyStoreReader, + store: &dyn RunSource, key: &FamilyKey, rel_path: &Path, producer: &ViewPayloadProducer<'_>, @@ -437,7 +474,7 @@ impl SemanticArena { /// afterwards. pub(crate) fn load_deferring_seed( &self, - store: &FamilyStoreReader, + store: &dyn RunSource, key: &FamilyKey, rel_path: &Path, producer: &ViewPayloadProducer<'_>, @@ -448,7 +485,7 @@ impl SemanticArena { return Ok(Some(run)); } self.store_reads.fetch_add(1, Ordering::Relaxed); - let Some(payload) = store.get(key).map_err(|error| error.to_string())? else { + let Some(payload) = store.run_payload(key)? else { return Ok(None); }; self.decodes.fetch_add(1, Ordering::Relaxed); @@ -512,7 +549,7 @@ impl SemanticArena { /// [`MAX_CHUNK_KEYS`] keys, the remaining vectors stay unindexed. pub(crate) fn seed_verified_runs( &self, - store: &FamilyStoreReader, + store: &dyn RunSource, seeds: Vec, naming: &dyn ChunkNaming, ) { @@ -523,7 +560,7 @@ impl SemanticArena { if seeds.is_empty() { return; } - let Ok(Some((epoch, present))) = store.contains_many_at_deletion_epoch(|epoch| { + let Some((epoch, present)) = store.markers_at_deletion_epoch(&mut |epoch| { seeds .iter() .map(|seed| naming.verified_run_marker(&seed.payload_digest, epoch)) diff --git a/crates/aft/src/views/snapshot.rs b/crates/aft/src/views/snapshot.rs index a33014f5d0..a987f1c866 100644 --- a/crates/aft/src/views/snapshot.rs +++ b/crates/aft/src/views/snapshot.rs @@ -89,9 +89,28 @@ pub struct ComposedGeneration { delta: Arc, base: Option>, overrides: BTreeMap, + route: Option, _residency: Option, } +/// Where a v3 generation's artifacts live: plane rows in +/// `blobs/v3//.sqlite`, and its manifest, pointer and derived +/// files in the member directory `blobs/v3//views/`. A +/// generation without a route is a v2 generation. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct V3Route { + pub storage: std::path::PathBuf, + pub family: String, + pub scope: String, +} + +impl V3Route { + pub fn member_dir(&self) -> std::path::PathBuf { + crate::blob_store::v3::view_dir(&self.storage, &self.family, &self.scope) + .unwrap_or_else(|_| self.storage.join("blobs/v3/invalid")) + } +} + /// Compatibility name for callers that open a flat generation. Both names /// expose only the composed projection, never a raw manifest accessor. pub type OpenGeneration = ComposedGeneration; @@ -111,6 +130,7 @@ impl ComposedGeneration { delta: Arc::new(manifest), base: None, overrides: BTreeMap::new(), + route: None, _residency: residency, } } @@ -159,10 +179,22 @@ impl ComposedGeneration { delta: Arc::new(delta), base: Some(base), overrides, + route: None, _residency: residency, }) } + /// Marks this generation as stored in the v3 namespace at `route`. + pub fn with_v3_route(mut self, route: V3Route) -> Self { + self.route = Some(route); + self + } + + /// The v3 location of this generation, or `None` for a v2 generation. + pub fn v3_route(&self) -> Option<&V3Route> { + self.route.as_ref() + } + pub fn name(&self) -> &str { &self.name } diff --git a/crates/aft/src/views/trigram.rs b/crates/aft/src/views/trigram.rs index fd48473cbf..042a91ebff 100644 --- a/crates/aft/src/views/trigram.rs +++ b/crates/aft/src/views/trigram.rs @@ -738,6 +738,67 @@ pub fn materialize_v3( TrigramPayload::extract(source, &policy).encode(), )); } + put_v3_payloads(storage, family, manifest, payloads, live) +} + +/// [`materialize_v3`] for a worker delta: each regular entry's payload comes +/// from its strict-reconcile attachment when that attachment names the same +/// content, otherwise from a fresh read of the checkout that must hash to the +/// entry's content. +pub fn materialize_v3_observed( + storage: &Path, + family: &str, + root: &Path, + manifest: &mut super::manifest_v2::ManifestV2, + observed: &BTreeMap, + live: &mut crate::pins::LivePin, + policy: TrigramPolicy, +) -> Result<(), super::contracts::PlaneError> { + if manifest.header().producers.trigram != policy.fingerprint_hex() { + return Err(plane_error("trigram producer mismatch")); + } + let mut payloads = Vec::new(); + for (path, entry) in manifest.entries() { + let super::manifest_v2::EntryV2::Regular { content, size, .. } = entry else { + continue; + }; + let attachment = observed + .get(path) + .and_then(|entry| entry.attachments.get(&FamilyPlane::Trigram)) + .and_then(|value| value.downcast_ref::()) + .filter(|value| value.content == *content && value.policy == policy.fingerprint()); + let payload = match attachment { + Some(attachment) => attachment.payload.clone(), + None => { + let relative = rel_path_to_os(path).map_err(|e| plane_error(e.to_string()))?; + let bytes = + fs::read(root.join(relative)).map_err(|e| plane_error(e.to_string()))?; + if ContentHash::of(&bytes) != *content || bytes.len() as u64 != *size { + return Err(plane_error("source changed during trigram construction")); + } + TrigramPayload::extract(&bytes, &policy) + } + }; + let key = crate::blob_store::v2::TrigramKey { + content: *content, + policy, + } + .family_key(); + payloads.push((path.clone(), key, payload.encode())); + } + put_v3_payloads(storage, family, manifest, payloads, live) +} + +fn put_v3_payloads( + storage: &Path, + family: &str, + manifest: &mut super::manifest_v2::ManifestV2, + payloads: Vec<(RelPath, crate::blob_store::v2::FamilyKey, Vec)>, + live: &mut crate::pins::LivePin, +) -> Result<(), super::contracts::PlaneError> { + if payloads.is_empty() { + return Ok(()); + } // Protect every key before the first put, as the v2 materializer does. live.protect(&payloads.iter().map(|(_, key, _)| *key).collect::>()) .map_err(|e| plane_error(e.to_string()))?; @@ -805,6 +866,9 @@ impl TrigramAdapter { access: &super::contracts::ViewAccess, snapshot: &Snapshot, ) -> Result { + if let super::contracts::StorageRoute::V3(route) = access.route(snapshot.generation()) { + return self.open_v3_index(&route, snapshot); + } let id = snapshot .generation() .segment_id() @@ -864,6 +928,91 @@ impl TrigramAdapter { } } +impl TrigramAdapter { + /// A v3 generation has no segment file: v3 keeps no segment rows, so the + /// base's segment is assembled in memory from the base's stored payloads + /// (one build per base generation, shared through the segment cache), and + /// the worker's changed entries load as the usual overlay. Missing or + /// not-ready payloads stay direct-scan gaps, as on the v2 path. + fn open_v3_index( + &self, + route: &super::snapshot::V3Route, + snapshot: &Snapshot, + ) -> Result { + let generation = snapshot.generation(); + if !generation.segment_ids().is_empty() { + return Err(plane_error("a v3 generation names a trigram segment")); + } + let source = crate::blob_store::v3::PlaneReader::open_existing( + &route.storage, + &route.family, + FamilyPlane::Trigram, + ) + .map_err(|e| plane_error(e.to_string()))?; + let base = generation + .base() + .map_or(generation.as_ref(), |base| base.as_ref()); + let base_scope = base + .v3_route() + .map_or(route.scope.as_str(), |base| base.scope.as_str()); + let segment_key = ( + format!("v3:{}", route.family), + *blake3::hash(format!("{base_scope}\0{}", base.name()).as_bytes()).as_bytes(), + ); + let cached = { + let mut segments = self.segments.lock().unwrap_or_else(|e| e.into_inner()); + segments.retain(|_, segment| segment.strong_count() != 0); + segments + .get(&segment_key) + .and_then(|segment| segment.upgrade()) + }; + let segment = match cached { + Some(segment) => segment, + None => { + let members: Vec<_> = base + .entries() + .filter_map(|(path, entry)| { + let content = entry.content()?; + let key = crate::blob_store::v2::TrigramKey { + content, + policy: self.policy, + } + .family_key(); + (base.plane_readiness(path, FamilyPlane::Trigram) + == Some(&super::readiness::PlaneState::ready(&key))) + .then(|| super::segment_store::SegmentMember { + rel_path: path.clone(), + content, + size: match entry { + super::manifest_v2::EntryV2::Regular { size, .. } => *size, + _ => 0, + }, + }) + }) + .collect(); + let bytes = super::segment_store::build_from_blobs(&source, &members, &self.policy) + .map_err(|e| plane_error(e.to_string()))?; + let reader = Arc::new( + SegmentReader::from_bytes(&bytes.bytes) + .map_err(|e| plane_error(e.to_string()))?, + ); + let mut segments = self.segments.lock().unwrap_or_else(|e| e.into_inner()); + match segments + .get(&segment_key) + .and_then(|segment| segment.upgrade()) + { + Some(existing) => existing, + None => { + segments.insert(segment_key, Arc::downgrade(&reader)); + reader + } + } + } + }; + GenerationIndex::open(segment, snapshot, self.policy, &source).map_err(plane_error) + } +} + impl super::contracts::PlaneAdapter for TrigramAdapter { fn plane(&self) -> FamilyPlane { FamilyPlane::Trigram diff --git a/crates/aft/src/views/v3_plane_tests.rs b/crates/aft/src/views/v3_plane_tests.rs new file mode 100644 index 0000000000..9f110e28ba --- /dev/null +++ b/crates/aft/src/views/v3_plane_tests.rs @@ -0,0 +1,355 @@ +//! The plane adapters on v3 storage: a v3 generation's trigram, callgraph +//! and semantic artifacts are read from and written to the v3 plane stores +//! and the v3 member directory, never the v2 family store. + +use std::collections::{BTreeMap, BTreeSet}; +use std::path::Path; +use std::sync::Arc; + +use crate::blob_store::v2::{ContentHash, FamilyPlane, TrigramPolicy}; +use crate::blob_store::v3; +use crate::views::base_builder::{build_base, publish_successor, BaseBuild, BaseBuildInput}; +use crate::views::base_refs::{ + register_view_in, resolve_base, with_base_barrier, BaseIdentity, WorkerOutcome, + WorkerPublication, +}; +use crate::views::contracts::{PlaneAdapter, ViewAccess}; +use crate::views::eviction::DiskBudget; +use crate::views::first_load::{CallgraphBridge, CompositePlane}; +use crate::views::manifest_v2::{EntryPlanes, EntryV2, ManifestV2, PublishedFrontier}; +use crate::views::readiness::PlaneState; +use crate::views::snapshot::{LiveDelta, LiveEntry}; +use crate::views::RelPath; + +const FAMILY: &str = "00112233445566ff"; +const BASE_FILES: &[(&str, &[u8])] = &[ + ("src/a.rs", b"pub fn alpha() -> u32 { 1 }\n"), + ("src/b.rs", b"pub fn beta() -> u32 { crate::a::alpha() }\n"), +]; +const CHANGED: &[u8] = b"pub fn gamma() -> u32 { 3 }\n"; + +fn policy() -> TrigramPolicy { + TrigramPolicy { + max_file_size: 1 << 20, + } +} + +fn rel(name: &str) -> RelPath { + RelPath::new(name.as_bytes().to_vec()).unwrap() +} + +fn write_files(root: &Path, files: &[(&str, &[u8])]) -> Vec { + files + .iter() + .map(|(name, bytes)| { + let path = root.join(name); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, bytes).unwrap(); + rel(name) + }) + .collect() +} + +struct Fixture { + storage: tempfile::TempDir, + worker_root: tempfile::TempDir, + identity: BaseIdentity, + claim: crate::views::base_builder::BuildClaim, +} + +fn fixture() -> Fixture { + let storage = tempfile::tempdir().unwrap(); + let base_root = tempfile::tempdir().unwrap(); + let paths = write_files(base_root.path(), BASE_FILES); + let BaseBuild::Published { + identity, claim, .. + } = build_base( + BaseBuildInput { + storage: storage.path(), + family: FAMILY, + root: base_root.path(), + paths: &paths, + policy: policy(), + semantic_producer: None, + callgraph_producer: crate::views::callgraph::PRODUCER.into(), + ignore_fingerprint: None, + history: None, + budget: DiskBudget::default(), + }, + None, + ) + .unwrap() + else { + panic!("the only builder publishes") + }; + let worker_root = tempfile::tempdir().unwrap(); + write_files(worker_root.path(), BASE_FILES); + write_files(worker_root.path(), &[("src/a.rs", CHANGED)]); + with_base_barrier(storage.path(), FAMILY, |tx| { + register_view_in(tx, storage.path(), FAMILY, "worker", worker_root.path()).map(|_| ()) + }) + .unwrap(); + Fixture { + storage, + worker_root, + identity, + claim, + } +} + +impl Fixture { + fn route(&self) -> crate::views::snapshot::V3Route { + crate::views::snapshot::V3Route { + storage: self.storage.path().to_path_buf(), + family: FAMILY.into(), + scope: "worker".into(), + } + } + + fn delta(&self) -> ManifestV2 { + let base = resolve_base(self.storage.path(), FAMILY, &self.identity.scope).unwrap(); + let mut delta = ManifestV2::new_v3( + base.manifest().header().clone(), + Some(base.header()), + PublishedFrontier::default(), + ); + delta + .insert( + rel("src/a.rs"), + EntryV2::regular( + ContentHash::of(CHANGED), + CHANGED.len() as u64, + EntryPlanes { + trigram: Some(PlaneState::pending("not built")), + semantic: None, + callgraph: Some(PlaneState::pending("not built")), + }, + ), + ) + .unwrap(); + delta + } + + fn publish( + &self, + delta: ManifestV2, + before_cas: &mut dyn FnMut(&str) -> crate::views::base_refs::Result<()>, + ) -> (String, Arc) { + let base = resolve_base(self.storage.path(), FAMILY, &self.identity.scope).unwrap(); + let header = base.header(); + drop(base); + match crate::views::base_refs::publish_worker_with( + WorkerPublication { + storage: self.storage.path(), + family: FAMILY, + worker_scope: "worker", + root: self.worker_root.path(), + base: &header, + delta, + previous: None, + }, + None, + before_cas, + ) + .unwrap() + { + WorkerOutcome::Published { + generation, + composed, + } => (generation, composed), + other => panic!("{other:?}"), + } + } + + /// A registry reader of the family. Reading a v3 generation never opens + /// the v2 store it registers in. + fn reader(&self) -> ViewAccess { + let registry = + crate::views::registry::FamilyRegistry::open(self.storage.path(), FAMILY).unwrap(); + ViewAccess::Reader { + registration: Arc::new(registry.register_reader("v3-plane-test").unwrap()), + scope: "worker".into(), + } + } +} + +fn files_named(root: &Path, prefix: &str) -> Vec { + let mut found = Vec::new(); + let mut stack = vec![root.to_path_buf()]; + while let Some(dir) = stack.pop() { + for entry in std::fs::read_dir(dir).into_iter().flatten().flatten() { + if entry.file_type().unwrap().is_dir() { + stack.push(entry.path()); + } else if entry.file_name().to_string_lossy().starts_with(prefix) { + found.push(entry.path()); + } + } + } + found +} + +#[test] +fn a_v3_trigram_index_builds_the_base_segment_from_payloads_and_overlays_the_delta() { + let _serial = v3::serial_for_test(); + let fixture = fixture(); + let mut delta = fixture.delta(); + let mut live = + crate::pins::LivePin::create_v3(fixture.storage.path(), FAMILY, "worker").unwrap(); + crate::views::trigram::materialize_v3_observed( + fixture.storage.path(), + FAMILY, + fixture.worker_root.path(), + &mut delta, + &BTreeMap::new(), + &mut live, + policy(), + ) + .unwrap(); + let (_, composed) = fixture.publish(delta, &mut |_| Ok(())); + let snapshot = LiveDelta::new(composed).snapshot(); + let adapter = + crate::views::trigram::TrigramAdapter::new(fixture.storage.path().to_path_buf(), policy()); + let index = adapter.open_index(&fixture.reader(), &snapshot).unwrap(); + let members: BTreeSet = snapshot.membership().into_keys().collect(); + let candidates = |literal: &str| index.candidates(&snapshot, &members, literal).indexed; + assert!(candidates("gamma").contains(&rel("src/a.rs"))); + assert!(candidates("beta").contains(&rel("src/b.rs"))); + assert!(!candidates("alpha() -> u32 { 1").contains(&rel("src/a.rs"))); + // The segment exists only in memory: v3 keeps no segment rows or files. + assert!(files_named(fixture.storage.path(), "trigram-seg").is_empty()); + assert!(!fixture + .storage + .path() + .join("blobs/v2/00112233445566ff/trigram.sqlite") + .exists()); +} + +#[test] +fn a_v3_worker_graph_is_built_from_v3_blobs_into_its_member_directory() { + let _serial = v3::serial_for_test(); + let fixture = fixture(); + let bridge = CallgraphBridge::default(); + let attachment = |name: &str, bytes: &[u8]| { + let attachment = bridge.attachment(&rel(name), bytes).unwrap(); + attachment + .downcast_ref::() + .unwrap() + .clone() + }; + // The base owner completes the base graph: blobs first, then a successor + // whose derived graph carries the claim's attempt and token. + let base_graph: Vec<_> = BASE_FILES + .iter() + .map(|(name, bytes)| (rel(name), attachment(name, bytes))) + .collect(); + v3::publish( + fixture.storage.path(), + FAMILY, + 1 << 22, + None, + |publication| { + for (_, attachment) in &base_graph { + publication.put( + &attachment.key, + &attachment.blob.to_bytes().unwrap(), + FamilyPlane::Callgraph.payload_schema(), + )?; + } + Ok(()) + }, + ) + .unwrap(); + let successor = publish_successor( + &fixture.claim, + |manifest| { + for (path, attachment) in &base_graph { + if let Some(EntryV2::Regular { planes, .. }) = manifest.get_mut(path) { + planes.callgraph = Some(PlaneState::ready(&attachment.key)); + } + } + }, + None, + ) + .unwrap(); + let base_dir = v3::view_dir(fixture.storage.path(), FAMILY, &fixture.identity.scope).unwrap(); + let owner = format!( + "{successor}-a{}-{}", + fixture.claim.attempt(), + fixture.claim.token() + ); + assert!(base_dir.join(format!("derived-{owner}.sqlite")).is_file()); + assert_eq!( + std::fs::read_to_string(base_dir.join(format!("derived-{successor}.ref"))).unwrap(), + owner + ); + // The worker's delta: its changed file's blob goes to v3, and the graph + // over the composed generation is built in the worker's member directory. + let registry = + crate::views::registry::FamilyRegistry::open(fixture.storage.path(), FAMILY).unwrap(); + let owner_registration = registry + .register_view_with_storage(fixture.storage.path(), "worker", fixture.worker_root.path()) + .unwrap(); + let mut observed = BTreeMap::new(); + let mut entry = LiveEntry::new(crate::views::snapshot::DiskState::of_bytes(CHANGED), 0); + entry.attachments.insert( + FamilyPlane::Callgraph, + bridge.attachment(&rel("src/a.rs"), CHANGED).unwrap(), + ); + observed.insert(rel("src/a.rs"), entry); + let mut delta = fixture.delta(); + let mut live = + crate::pins::LivePin::create_v3(fixture.storage.path(), FAMILY, "worker").unwrap(); + let base = resolve_base(fixture.storage.path(), FAMILY, &fixture.identity.scope) + .unwrap() + .into_generation(fixture.storage.path(), FAMILY); + let route = fixture.route(); + let _staging_pin = crate::pins::AssemblyPin::create( + &route.member_dir(), + FAMILY, + "worker", + "staging-graph", + &[], + ) + .unwrap(); + bridge + .materialize_v3( + &owner_registration, + &route, + "staging-graph", + &base, + &observed, + &mut delta, + &mut live, + ) + .unwrap(); + assert!(matches!( + delta + .get(&rel("src/a.rs")) + .unwrap() + .plane_state(FamilyPlane::Callgraph), + Some(PlaneState::Ready { .. }) + )); + let worker_dir = route.member_dir(); + assert!(worker_dir.join("derived-staging-graph.sqlite").is_file()); + let (generation, composed) = fixture.publish(delta, &mut |generation| { + bridge + .finish_generation_v3(&route, "staging-graph", generation) + .map_err(|error| { + crate::views::base_refs::BaseRefError::Io(std::io::Error::other(error.reason)) + }) + }); + assert!(worker_dir + .join(format!("derived-{generation}.sqlite")) + .is_file()); + // A foreign reader opens the worker's graph from the v3 member directory. + let access = fixture.reader(); + bridge.adapter.open_generation(&access, &composed).unwrap(); + let snapshot = LiveDelta::new(composed).snapshot(); + bridge.adapter.reader(&access, &snapshot).unwrap(); + drop(owner_registration); + assert!(!fixture + .storage + .path() + .join("blobs/v2/00112233445566ff/callgraph.sqlite") + .exists()); +} diff --git a/docs/design/views-v3-format.md b/docs/design/views-v3-format.md index 75d67c212a..ca58b660bf 100644 --- a/docs/design/views-v3-format.md +++ b/docs/design/views-v3-format.md @@ -580,8 +580,18 @@ CREATE TABLE sweep_state (singleton INTEGER PRIMARY KEY CHECK(singleton=1), base after two consecutive unreferenced sweeps. The plane-row sweep keeps its resume position at `retention/cursors/v3-plane-rows/.json` (format 1) when the retention collector runs it. -- **Segments.** v3 generations carry no trigram segment yet: v3 has no - segment rows or segment collection, so readers build from payloads. +- **Segments.** v3 generations carry no trigram segment: v3 has no segment + rows, files or segment collection. A reader assembles the base + generation's segment in memory from the base's stored trigram payloads + (once per base generation per process) and loads the worker's changed + entries as the usual overlay. No new on-disk rows were added for this. +- **Derived graphs.** A base's derived call graph is + `M/derived--a-.sqlite`, named by the + existing `M/derived-.ref` owner-reference file, and is built + only once every callgraph member of that generation is ready. A worker's + derived graph covers its composed generation and lives in its own member + directory under the usual `derived-.sqlite` name; while the + base's graph is pending the worker has none. ## Deferred implementation register From bba57f23863d83fc0ebb53119c60ec2001a55e42 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Mon, 26 Oct 2026 17:59:26 +0100 Subject: [PATCH 010/100] mason: CheckoutDriver publishes base-referencing worker generations through v3 The base-header branch of build_own_generation no longer refuses. It authenticates the named base (pin-then-verify, digests recomputed; a header that names no registered base is refused before any write), then folds the cut into a sparse delta against it. A path whose bytes equal the base's inherits the base entry, with its readiness patch as an override; a deleted base path is a tombstone; only changed paths are entries. The plane delta keys are materialized into v3 under a v3 live pin, and a private staging name is pinned in the checkout's v3 member directory. Publication goes through base_refs::publish_worker_with, whose pre-CAS step runs each plane's finish_generation_v3. Wold is the installed v3 generation or the durable v3 pointer. Published opens the generation's readers for installation; Lost (Conflict/EquivalentWinner) and failed or possibly landed publications set the retry flag and return CHECKOUT_CHANGED_BEFORE_INSTALL, the existing revalidate path. No v1/v2 row, manifest or pointer is written. A plane without v3 storage now leaves its delta entries pending, instead of refusing. unwired_worker_publication_refuses_before_any_legacy_write is replaced: a_base_referencing_worker_publishes_a_sparse_v3_generation_readable_after_restart checks that the generation is sparse (entries = changed paths, tombstone for the deleted base path, nothing inherited copied), names the authenticated base, leaves the v2 pointer untouched, and composes correctly for a fresh reader after the driver is dropped. Its refusal half survives as a_worker_header_naming_an_unknown_base_is_refused_before_any_write. Co-authored-by: alfonso-aft <289616620+alfonso-aft@users.noreply.github.com> (cherry picked from commit 6eb0522ec0ca0e74b79af075eaa17f5ddfbb89cb) --- crates/aft/src/views/first_load.rs | 387 ++++++++++++++++++++++++++++- 1 file changed, 374 insertions(+), 13 deletions(-) diff --git a/crates/aft/src/views/first_load.rs b/crates/aft/src/views/first_load.rs index 7e69e011af..0e0877ca1f 100644 --- a/crates/aft/src/views/first_load.rs +++ b/crates/aft/src/views/first_load.rs @@ -1275,11 +1275,10 @@ pub trait CompositePlane: Send + Sync { delta: &mut super::manifest_v2::ManifestV2, live: &mut crate::pins::LivePin, ) -> Result<(), PlaneError> { + // A plane without v3 storage leaves its delta entries as they are + // (pending), which keeps them queued and honestly incomplete. let _ = (owner, route, staging, base, observed, delta, live); - Err(PlaneError { - plane: Some(self.plane()), - reason: "this plane has no v3 materialization".into(), - }) + Ok(()) } /// [`Self::finish_generation`] for a v3 member directory. @@ -1982,8 +1981,8 @@ impl FirstLoadDriver for CheckoutDriver { seed_derived: bool, ) -> Result, PlaneError> { self.check_owner(access)?; - if snapshot.generation().base_header().is_some() { - return Err(binding_unavailable()); + if let Some(header) = snapshot.generation().base_header() { + return self.build_v3_worker_generation(access, snapshot, header.clone()); } super::io::WORK .own_generation_folds @@ -2150,6 +2149,227 @@ impl FirstLoadDriver for CheckoutDriver { } } +impl CheckoutDriver { + /// Publishes this checkout's generation as a v3 worker that references the + /// base named by `header`. The header is only a lookup: the base is + /// authenticated (pin-then-verify, digests recomputed) before anything is + /// written. The published manifest is sparse: entries whose bytes differ + /// from the base, tombstones for base paths gone from the checkout, and + /// readiness overrides from the live delta's readiness patches; inherited + /// base entries are never copied. Plane rows go to the family's v3 plane + /// stores under a v3 live pin, derived files to the checkout's v3 member + /// directory, and the pointer CAS, referrer row and markers follow + /// `base_refs::publish_worker`. Nothing is written to v1 or v2 storage. + fn build_v3_worker_generation( + &self, + access: &ViewAccess, + snapshot: &Snapshot, + header: super::manifest_v2::BaseHeader, + ) -> Result, PlaneError> { + let failed = + |error: super::base_refs::BaseRefError| SiblingLoader::error(error.to_string()); + let route = self.owner.v3_route(); + let storage = route.storage.clone(); + let family = route.family.clone(); + let scope = route.scope.clone(); + let base = super::base_refs::resolve_base(&storage, &family, &header.scope) + .and_then(|base| base.check_header(&header).map(|()| base)) + .map_err(failed)?; + let base_header = base.header(); + let base_generation = base.into_generation(&storage, &family); + // The sparse delta: fold this cut's changes into the installed + // worker delta. A path whose bytes equal the base's inherits the base + // entry (with any checkout-specific readiness patch as an override); + // a deleted base path becomes a tombstone. + let generation = snapshot.generation(); + let inherited = |path: &RelPath, + content: &crate::blob_store::v2::ContentHash, + size: u64| { + generation.base().and_then(|base| base.entry(path)).filter(|entry| { + matches!(entry, super::manifest_v2::EntryV2::Regular { content: c, size: s, .. } if c == content && *s == size) + }) + }; + let patched = |path: &RelPath, entry: &super::manifest_v2::EntryV2| { + let mut entry = entry.clone(); + if let ( + Some(patch), + super::manifest_v2::EntryV2::Regular { + content, planes, .. + }, + ) = (snapshot.readiness_patches().get(path), &mut entry) + { + if patch.content == *content { + for plane in FamilyPlane::ALL { + if let Some(state) = patch.planes.get(plane) { + *planes.get_mut(plane) = Some(state.clone()); + } + } + } + } + entry + }; + let mut updates = Vec::new(); + let mut changed = std::collections::BTreeSet::new(); + for (path, entry) in snapshot.live_entries() { + changed.insert(path.clone()); + let update = match entry.disk { + super::snapshot::DiskState::Absent => None, + super::snapshot::DiskState::Present { content, size } => { + match inherited(path, &content, size) { + Some(base_entry) => Some(patched(path, base_entry)), + None => { + let mut planes = super::manifest_v2::EntryPlanes::default(); + for plane in &self.planes { + if plane.applies_to(path) { + *planes.get_mut(plane.plane()) = + Some(super::readiness::PlaneState::pending( + "awaiting checkout materialization", + )); + } + } + Some(super::manifest_v2::EntryV2::regular(content, size, planes)) + } + } + } + }; + updates.push((path.clone(), update)); + } + for (path, patch) in snapshot.readiness_patches() { + if changed.contains(path) { + continue; + } + if let Some(base_entry) = generation.base().and_then(|base| base.entry(path)) { + if base_entry.content() == Some(patch.content) { + updates.push((path.clone(), Some(patched(path, base_entry)))); + } + } + } + let mut delta = generation + .fold( + &generation.header().producers, + updates, + &super::readiness::FillMap::default(), + ) + .map_err(|error| SiblingLoader::error(error.to_string()))?; + delta + .set_base_header(base_header.clone()) + .map_err(|error| SiblingLoader::error(error.to_string()))?; + // Register the v3 member and protect the private staging name before + // any plane writes derived files under it. + super::base_refs::with_base_barrier(&storage, &family, |tx| { + super::base_refs::register_view_in(tx, &storage, &family, &scope, self.owner.root()) + .map(|_| ()) + }) + .map_err(failed)?; + let staging = format!( + "staging-{}-{}", + std::process::id(), + super::base_refs::fresh_nonce() + ); + let _staging_pin = crate::pins::AssemblyPin::create( + &route.member_dir(), + family.clone(), + scope.clone(), + staging.clone(), + &[], + ) + .map_err(|error| SiblingLoader::error(error.to_string()))?; + let mut live = crate::pins::LivePin::create_v3(&storage, &family, &scope) + .map_err(|error| SiblingLoader::error(error.to_string()))?; + let observed = self + .observed + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .entries + .clone(); + for plane in &self.planes { + plane.materialize_v3( + &self.owner, + &route, + &staging, + &base_generation, + &observed, + &mut delta, + &mut live, + )?; + } + // `Wold`: the installed v3 generation of this checkout, or the + // durable v3 pointer when the installed bundle is not one. + let previous = match generation.v3_route() { + Some(installed) if installed.scope == scope => Some(generation.name().to_owned()), + _ => super::ViewStore::existing_dir(route.member_dir()) + .map(|store| store.current_generation_read_only()) + .transpose() + .map_err(|error| SiblingLoader::error(error.to_string()))? + .flatten(), + }; + let planes = &self.planes; + let outcome = super::base_refs::publish_worker_with( + super::base_refs::WorkerPublication { + storage: &storage, + family: &family, + worker_scope: &scope, + root: self.owner.root(), + base: &base_header, + delta, + previous, + }, + None, + &mut |published| { + for plane in planes { + plane + .finish_generation_v3(&route, &staging, published) + .map_err(|error| { + super::base_refs::BaseRefError::Io(std::io::Error::other(error.reason)) + })?; + } + Ok(()) + }, + ); + drop(live); + match outcome { + Ok(super::base_refs::WorkerOutcome::Published { composed, .. }) => { + let readers = PlaneReaders::open(access, composed.clone(), &self.adapters)?; + for adapter in &self.adapters { + adapter + .generation_published(access, &composed, &|| self.installed_snapshot())?; + } + let retired = self + .prepared_readers + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .insert( + composed.name().to_owned(), + PreparedReaders { + seq: snapshot.install_cut().map_or(0, |cut| cut.seq), + readers, + }, + ); + drop(retired); + Ok(composed) + } + Ok(super::base_refs::WorkerOutcome::Lost { + current, + equivalent, + }) => { + self.retry.store(true, std::sync::atomic::Ordering::Release); + Err(SiblingLoader::error(format!( + "{CHECKOUT_CHANGED_BEFORE_INSTALL}: publication lost ({}, current {current:?}); revalidate a fresh job", + if equivalent { "EquivalentWinner" } else { "Conflict" } + ))) + } + Err(error) => { + // publish_worker already resolved any landed CAS into a + // published referrer; the next job revalidates from disk. + self.retry.store(true, std::sync::atomic::Ordering::Release); + Err(SiblingLoader::error(format!( + "{CHECKOUT_CHANGED_BEFORE_INSTALL}: v3 worker publication failed or may have landed; revalidate: {error}" + ))) + } + } + } +} + impl QueryState for CheckoutDriver { fn installed_state( &self, @@ -3107,7 +3327,151 @@ mod composite_tests { } #[test] - fn unwired_worker_publication_refuses_before_any_legacy_write() { + fn a_base_referencing_worker_publishes_a_sparse_v3_generation_readable_after_restart() { + let _serial = crate::blob_store::v3::serial_for_test(); + let root = tempfile::tempdir().unwrap(); + let storage = tempfile::tempdir().unwrap(); + let (driver, _, _, access) = fixture(root.path(), storage.path()); + // A v3 base over the checkout's files as they are now. + std::fs::write(root.path().join("kept.rs"), b"kept content").unwrap(); + std::fs::write(root.path().join("deleted.rs"), b"deleted content").unwrap(); + let paths = ["deleted.rs", "file.rs", "kept.rs"] + .map(|name| RelPath::new(name.as_bytes().to_vec()).unwrap()); + let super::super::base_builder::BaseBuild::Published { identity, .. } = + super::super::base_builder::build_base( + super::super::base_builder::BaseBuildInput { + storage: storage.path(), + family: "family", + root: root.path(), + paths: &paths, + policy: crate::blob_store::v2::TrigramPolicy { + max_file_size: 1 << 20, + }, + semantic_producer: None, + callgraph_producer: "extractor".into(), + ignore_fingerprint: None, + history: None, + budget: super::super::eviction::DiskBudget::default(), + }, + None, + ) + .unwrap() + else { + panic!("the only builder publishes"); + }; + let base = super::super::base_refs::resolve_base(storage.path(), "family", &identity.scope) + .unwrap(); + // An installed worker generation that inherits the whole base; the + // header names the base only by its identity strings. + let worker = super::super::manifest_v2::ManifestV2::new_v3( + base.manifest().header().clone(), + Some(base.header()), + Default::default(), + ); + let composed = Arc::new( + ComposedGeneration::compose( + "installed-worker", + worker, + base.into_generation(storage.path(), "family"), + None, + ) + .unwrap(), + ); + // The checkout then edits one file, adds one and deletes one. + let mut delta = LiveDelta::new(composed); + let rel = |name: &str| RelPath::new(name.as_bytes().to_vec()).unwrap(); + std::fs::write(root.path().join("file.rs"), b"edited content").unwrap(); + std::fs::write(root.path().join("added.rs"), b"added content").unwrap(); + std::fs::remove_file(root.path().join("deleted.rs")).unwrap(); + delta.apply( + rel("file.rs"), + LiveEntry::new( + super::super::snapshot::DiskState::of_bytes(b"edited content"), + 1, + ), + ); + delta.apply( + rel("added.rs"), + LiveEntry::new( + super::super::snapshot::DiskState::of_bytes(b"added content"), + 2, + ), + ); + delta.apply( + rel("deleted.rs"), + LiveEntry::new(super::super::snapshot::DiskState::Absent, 3), + ); + // A path rewritten with its base bytes inherits, rather than copies. + delta.apply( + rel("kept.rs"), + LiveEntry::new( + super::super::snapshot::DiskState::of_bytes(b"kept content"), + 4, + ), + ); + let snapshot = delta.snapshot(); + let published = driver + .build_own_generation(&access, &snapshot, false) + .unwrap(); + let generation = published.name().to_owned(); + assert!(published.v3_route().is_some()); + drop(published); + // No v2 pointer moved and nothing went outside the v3 namespace. + assert!(driver + .owner + .view_store() + .unwrap() + .current_generation() + .unwrap() + .is_none()); + assert!(!storage.path().join("views/v3").exists()); + let member = crate::blob_store::v3::view_dir(storage.path(), "family", "local").unwrap(); + let store = super::super::ViewStore::existing_dir(member).unwrap(); + assert_eq!( + store.current_generation_read_only().unwrap().as_deref(), + Some(generation.as_str()) + ); + let manifest = store.load_manifest_v2(&generation).unwrap(); + let header = manifest.base_header().unwrap(); + assert_eq!(header.scope, identity.scope); + identity.check_header(header).unwrap(); + // Sparse: only the changed paths, never the inherited base entries. + let entries: Vec<_> = manifest.entries().map(|(path, _)| path.clone()).collect(); + assert_eq!(entries, vec![rel("added.rs"), rel("file.rs")]); + assert_eq!( + manifest.tombstones().iter().cloned().collect::>(), + vec![rel("deleted.rs")] + ); + assert!(manifest.readiness_overrides().is_empty()); + // After a restart a fresh reader composes it over the authenticated base. + drop(driver); + let reader = super::super::base_refs::open_foreign_v3( + storage.path(), + "family", + "local", + &manifest.header().producers, + ) + .unwrap() + .unwrap(); + assert_eq!(reader.name(), generation); + assert!(reader.entry(&rel("deleted.rs")).is_none()); + assert_eq!( + reader + .entry(&rel("kept.rs")) + .and_then(|entry| entry.content()), + Some(crate::blob_store::v2::ContentHash::of(b"kept content")) + ); + assert_eq!( + reader + .entry(&rel("file.rs")) + .and_then(|entry| entry.content()), + Some(crate::blob_store::v2::ContentHash::of(b"edited content")) + ); + assert!(reader.entry(&rel("added.rs")).is_some()); + } + + #[test] + fn a_worker_header_naming_an_unknown_base_is_refused_before_any_write() { let root = tempfile::tempdir().unwrap(); let storage = tempfile::tempdir().unwrap(); let (driver, _, _, access) = fixture(root.path(), storage.path()); @@ -3115,7 +3479,7 @@ mod composite_tests { let worker = super::super::manifest_v2::ManifestV2::new_v3( base.header().clone(), Some(super::super::manifest_v2::BaseHeader { - scope: "base".into(), + scope: super::super::base_refs::base_scope_for(&[1; 32]), membership_digest: "11".repeat(32), identity_digest: "22".repeat(32), bound_generation: base.name().into(), @@ -3125,15 +3489,12 @@ mod composite_tests { let composed = Arc::new(ComposedGeneration::compose("worker", worker, base, None).unwrap()); let snapshot = LiveDelta::new(composed).snapshot(); let store = driver.owner.view_store().unwrap(); - assert!(store.current_generation().unwrap().is_none()); let error = driver .build_own_generation(&access, &snapshot, false) .unwrap_err(); - assert!(error - .reason - .contains("durable base publication binding is not wired")); + assert!(error.reason.contains("base unavailable"), "{error:?}"); assert!(store.current_generation().unwrap().is_none()); - assert!(!storage.path().join("blobs/v3").exists()); + assert!(!storage.path().join("blobs/v3/family/views/local").exists()); } #[test] From 11f168e0361cbed7a6fbc1f460ffc1866031f37f Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Mon, 26 Oct 2026 18:05:56 +0100 Subject: [PATCH 011/100] mason: correct comments on v3 routing, the v3 live pin and in-memory segments Co-authored-by: alfonso-aft <289616620+alfonso-aft@users.noreply.github.com> (cherry picked from commit c26f1c23821947845961d0f6b686efa836893f0e) --- crates/aft/src/views/first_load.rs | 14 ++++++++------ crates/aft/src/views/registry.rs | 6 +++--- crates/aft/src/views/semantic.rs | 5 +++-- crates/aft/src/views/trigram.rs | 2 +- docs/design/views-v3-format.md | 2 +- 5 files changed, 16 insertions(+), 13 deletions(-) diff --git a/crates/aft/src/views/first_load.rs b/crates/aft/src/views/first_load.rs index 0e0877ca1f..fa9743e8a3 100644 --- a/crates/aft/src/views/first_load.rs +++ b/crates/aft/src/views/first_load.rs @@ -2151,9 +2151,9 @@ impl FirstLoadDriver for CheckoutDriver { impl CheckoutDriver { /// Publishes this checkout's generation as a v3 worker that references the - /// base named by `header`. The header is only a lookup: the base is - /// authenticated (pin-then-verify, digests recomputed) before anything is - /// written. The published manifest is sparse: entries whose bytes differ + /// base named by `header`. The header is only a lookup: the base is pinned + /// and authenticated (digests recomputed) before any of this worker's plane + /// artifacts or its manifest is written. The published manifest is sparse: entries whose bytes differ /// from the base, tombstones for base paths gone from the checkout, and /// readiness overrides from the live delta's readiness patches; inherited /// base entries are never copied. Plane rows go to the family's v3 plane @@ -2359,8 +2359,9 @@ impl CheckoutDriver { ))) } Err(error) => { - // publish_worker already resolved any landed CAS into a - // published referrer; the next job revalidates from disk. + // If the CAS landed, its referrer is published or left as an + // intent row for reconciliation; the next job revalidates + // from disk. self.retry.store(true, std::sync::atomic::Ordering::Release); Err(SiblingLoader::error(format!( "{CHECKOUT_CHANGED_BEFORE_INSTALL}: v3 worker publication failed or may have landed; revalidate: {error}" @@ -3443,7 +3444,8 @@ mod composite_tests { vec![rel("deleted.rs")] ); assert!(manifest.readiness_overrides().is_empty()); - // After a restart a fresh reader composes it over the authenticated base. + // With the driver gone, a fresh reader composes the published delta + // over the authenticated base. drop(driver); let reader = super::super::base_refs::open_foreign_v3( storage.path(), diff --git a/crates/aft/src/views/registry.rs b/crates/aft/src/views/registry.rs index e0f0dcd08a..d0aee2b097 100644 --- a/crates/aft/src/views/registry.rs +++ b/crates/aft/src/views/registry.rs @@ -1072,9 +1072,8 @@ impl ViewRegistration { Ok(FamilyStore::open(&self.write_access(), plane)?) } - /// The view's own pointer and manifests in `views/v2/`. - /// This checkout's member in the v3 namespace: the same family and scope - /// under `blobs/v3//views/`. + /// The v3 member of this checkout: the same family and scope, under + /// `blobs/v3//views/`. pub fn v3_route(&self) -> super::snapshot::V3Route { super::snapshot::V3Route { storage: self.registry().storage().to_path_buf(), @@ -1083,6 +1082,7 @@ impl ViewRegistration { } } + /// The view's own pointer and manifests in `views/v2/`. pub fn view_store(&self) -> RegistryResult { let view = super::ViewStore::open_dir(self.view_dir.clone())? .with_sweep_registry(self.registry.clone()); diff --git a/crates/aft/src/views/semantic.rs b/crates/aft/src/views/semantic.rs index e12025389d..d5c76fdd69 100644 --- a/crates/aft/src/views/semantic.rs +++ b/crates/aft/src/views/semantic.rs @@ -681,8 +681,9 @@ struct ViewState { tracker: FailureTracker, live: Option>>, /// The live pin in the v3 member directory, for fills of a generation - /// published in the v3 namespace. Its keys are kept until the process - /// ends: post-fold trimming covers only the v2 pin so far. + /// published in the v3 namespace. It is carried across generation + /// publications but not trimmed after folds yet (only the v2 pin is), so + /// it keeps its keys until the view state holding it is dropped. live_v3: Option>>, active_fills: usize, cache: Option<(CacheKey, Arc)>, diff --git a/crates/aft/src/views/trigram.rs b/crates/aft/src/views/trigram.rs index 042a91ebff..f178485dd5 100644 --- a/crates/aft/src/views/trigram.rs +++ b/crates/aft/src/views/trigram.rs @@ -931,7 +931,7 @@ impl TrigramAdapter { impl TrigramAdapter { /// A v3 generation has no segment file: v3 keeps no segment rows, so the /// base's segment is assembled in memory from the base's stored payloads - /// (one build per base generation, shared through the segment cache), and + /// (shared through the segment cache while an index holds it), and /// the worker's changed entries load as the usual overlay. Missing or /// not-ready payloads stay direct-scan gaps, as on the v2 path. fn open_v3_index( diff --git a/docs/design/views-v3-format.md b/docs/design/views-v3-format.md index ca58b660bf..d6dc89e322 100644 --- a/docs/design/views-v3-format.md +++ b/docs/design/views-v3-format.md @@ -583,7 +583,7 @@ CREATE TABLE sweep_state (singleton INTEGER PRIMARY KEY CHECK(singleton=1), - **Segments.** v3 generations carry no trigram segment: v3 has no segment rows, files or segment collection. A reader assembles the base generation's segment in memory from the base's stored trigram payloads - (once per base generation per process) and loads the worker's changed + (shared through the segment cache while an index holds it) and loads the worker's changed entries as the usual overlay. No new on-disk rows were added for this. - **Derived graphs.** A base's derived call graph is `M/derived--a-.sqlite`, named by the From d84548f75728f0d45affaff3bbf6bc7fca41884a Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Mon, 26 Oct 2026 18:16:16 +0100 Subject: [PATCH 012/100] search-quality descriptor for views-c2-v3-publication Co-authored-by: alfonso-aft <289616620+alfonso-aft@users.noreply.github.com> --- .../slice-descriptors/land-views-c2-v3-publication.json | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 benchmarks/aft-search/slice-descriptors/land-views-c2-v3-publication.json diff --git a/benchmarks/aft-search/slice-descriptors/land-views-c2-v3-publication.json b/benchmarks/aft-search/slice-descriptors/land-views-c2-v3-publication.json new file mode 100644 index 0000000000..ebd574711e --- /dev/null +++ b/benchmarks/aft-search/slice-descriptors/land-views-c2-v3-publication.json @@ -0,0 +1,8 @@ +{ + "targeted_mechanism": "none", + "kind": "harness", + "slice_class": "non_ranking", + "fixtures": [ + "harness-goldens" + ] +} From 6b17d7e575059421cdac37dcf6259341947cc949 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Mon, 26 Oct 2026 17:54:56 +0100 Subject: [PATCH 013/100] mason: register the v3 import ledger and symbol plane formats; add the v3-only symbol plane Co-authored-by: alfonso-aft <289616620+alfonso-aft@users.noreply.github.com> (cherry picked from commit b25f98bc736c63bd411c933b779bfc3c458ce3b0) --- crates/aft/src/blob_store/v2.rs | 30 +++++++ crates/aft/src/blob_store/v3.rs | 21 ++++- crates/aft/src/views/manifest_v2.rs | 9 ++ docs/design/views-v3-format.md | 130 ++++++++++++++++++++++++++-- 4 files changed, 180 insertions(+), 10 deletions(-) diff --git a/crates/aft/src/blob_store/v2.rs b/crates/aft/src/blob_store/v2.rs index 372f725051..ab8d71f328 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,16 +87,28 @@ 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", } } @@ -102,6 +117,7 @@ impl FamilyPlane { 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, } } } @@ -1892,6 +1908,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 36913daef3..06da17f730 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; @@ -1315,7 +1319,8 @@ impl RegistryWriter<'_> { complete: true, ..PlaneRowSweep::default() }; - for plane in FamilyPlane::ALL { + // 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()); @@ -1349,6 +1354,15 @@ impl RegistryWriter<'_> { .and_then(|hex| super::v2::parse_hex32(hex)) .map(|key| key.to_vec()) .unwrap_or_default(); + // No manifest names a symbol row, so marking never protects one. + // A symbol row is instead kept until it has gone untouched for + // SYMBOL_RETENTION_SWEEPS sweep epochs; every other plane is + // collected as soon as it is unmarked and older than this epoch. + let collect_below = if plane == FamilyPlane::Symbol { + epoch.saturating_sub(SYMBOL_RETENTION_SWEEPS) + } else { + epoch + }; loop { if report.examined >= limit || deadline.is_some_and(|d| Instant::now() >= d) { report.complete = false; @@ -1357,7 +1371,7 @@ impl RegistryWriter<'_> { let tx = c.transaction_with_behavior(TransactionBehavior::Immediate)?; let keys: Vec> = tx .prepare(select)? - .query_map(params![epoch, after], |r| r.get(0))? + .query_map(params![collect_below, after], |r| r.get(0))? .collect::>()?; if keys.is_empty() { tx.commit()?; @@ -1369,7 +1383,7 @@ impl RegistryWriter<'_> { let protected = <[u8; 32]>::try_from(key.as_slice()) .map_or(true, |key| marked.contains(&key)); if !protected { - report.deleted += tx.execute(delete, params![key, epoch])? as u64; + report.deleted += tx.execute(delete, params![key, collect_below])? as u64; } } tx.commit()?; @@ -1662,6 +1676,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)?; diff --git a/crates/aft/src/views/manifest_v2.rs b/crates/aft/src/views/manifest_v2.rs index 48031a6845..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") + } } } } diff --git a/docs/design/views-v3-format.md b/docs/design/views-v3-format.md index d6dc89e322..aef2ebf5f3 100644 --- a/docs/design/views-v3-format.md +++ b/docs/design/views-v3-format.md @@ -609,7 +609,10 @@ 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 + 5/5, earlier reserved for 6a, is **not used** by 6a: its import ledger is a + sidecar file and its 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 @@ -633,10 +636,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. 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 @@ -651,8 +655,8 @@ contract is [views-slice4-opening.md](views-slice4-opening.md). | 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 | `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*. | 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 @@ -664,3 +668,115 @@ 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. + +## Import ledger and symbol plane (6a) + +Sources: `crates/aft/src/migration/v3_import.rs` (ledger, decoders, staging, +registration), `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 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) copy the database and its WAL +to a private temporary directory and read the copy, so a read never writes +under the storage root. + +```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, then inverted in file-id ranges +whose postings fit a working-set budget (64 MiB by default), one pass over +the posting blob 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. From 97afe94db74828aa6fff6ac1e707e10969fb2b82 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Mon, 26 Oct 2026 18:20:58 +0100 Subject: [PATCH 014/100] mason: stream legacy postings and vectors into v3 plane rows through staged bundles Co-authored-by: alfonso-aft <289616620+alfonso-aft@users.noreply.github.com> (cherry picked from commit d18ea48005e640c55aa3333a8671a76a6f3e7c9f) --- crates/aft/src/migration/mod.rs | 1 + crates/aft/src/migration/per_checkout.rs | 246 ++-- crates/aft/src/migration/v3_import.rs | 1416 +++++++++++++++++++ crates/aft/src/migration/v3_import_tests.rs | 441 ++++++ 4 files changed, 1983 insertions(+), 121 deletions(-) create mode 100644 crates/aft/src/migration/v3_import.rs create mode 100644 crates/aft/src/migration/v3_import_tests.rs diff --git a/crates/aft/src/migration/mod.rs b/crates/aft/src/migration/mod.rs index badf2ceeb7..d9f9f9acd6 100644 --- a/crates/aft/src/migration/mod.rs +++ b/crates/aft/src/migration/mod.rs @@ -15,6 +15,7 @@ pub mod per_checkout; pub mod prune_legacy; +pub mod v3_import; 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/v3_import.rs b/crates/aft/src/migration/v3_import.rs new file mode 100644 index 0000000000..bbfa72656d --- /dev/null +++ b/crates/aft/src/migration/v3_import.rs @@ -0,0 +1,1416 @@ +//! 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. +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(()) + } + + /// 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`. + 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(), + )) + } +} + +/// 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 => Err("SYMBOLS_PENDING".to_owned()), + }; + 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 => Err("SYMBOLS_PENDING".to_owned()), + }) +} + +/// 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..4eccc54071 --- /dev/null +++ b/crates/aft/src/migration/v3_import_tests.rs @@ -0,0 +1,441 @@ +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(); + assert!(entries.len() >= 3, "{} entries", entries.len()); + 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()); +} From 8e21784354da59285b0e058737df6c2a504a7f97 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Mon, 26 Oct 2026 18:21:00 +0100 Subject: [PATCH 015/100] mason: v3 import ledger per artifact, producer and plane, with crash-resume and storage-gap release Co-authored-by: alfonso-aft <289616620+alfonso-aft@users.noreply.github.com> (cherry picked from commit e4424f60f21a63dcab0860b53e7f7697210e1e97) --- crates/aft/src/migration/mod.rs | 1 + crates/aft/src/migration/v3_ledger.rs | 942 ++++++++++++++++++++ crates/aft/src/migration/v3_ledger_tests.rs | 541 +++++++++++ 3 files changed, 1484 insertions(+) create mode 100644 crates/aft/src/migration/v3_ledger.rs create mode 100644 crates/aft/src/migration/v3_ledger_tests.rs diff --git a/crates/aft/src/migration/mod.rs b/crates/aft/src/migration/mod.rs index d9f9f9acd6..eaf91d6eab 100644 --- a/crates/aft/src/migration/mod.rs +++ b/crates/aft/src/migration/mod.rs @@ -16,6 +16,7 @@ pub mod per_checkout; pub mod prune_legacy; pub mod v3_import; +pub mod v3_ledger; use std::collections::BTreeMap; use std::fmt; diff --git a/crates/aft/src/migration/v3_ledger.rs b/crates/aft/src/migration/v3_ledger.rs new file mode 100644 index 0000000000..45d08540fd --- /dev/null +++ b/crates/aft/src/migration/v3_ledger.rs @@ -0,0 +1,942 @@ +//! 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. + 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. +pub fn read_rows(storage: &Path, family: &str) -> V3Result>> { + let ledger = V3Ledger::new(storage, family)?; + if let Some(shared) = lock(open_connections()) + .get(&ledger.path) + .and_then(Weak::upgrade) + { + return read_all(&lock(&shared)).map(Some); + } + if !ledger.path.is_file() { + return Ok(None); + } + static SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + let copy_dir = std::env::temp_dir().join(format!( + "aft-v3-ledger-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(LEDGER_FILE); + crate::private_storage::copy(&ledger.path, ©)?; + let wal = ledger.path.with_file_name(format!("{LEDGER_FILE}-wal")); + match crate::private_storage::copy(&wal, copy.with_file_name(format!("{LEDGER_FILE}-wal"))) + { + Ok(_) => {} + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(error) => return Err(error.into()), + } + let connection = TrackedConnection::open(©, STORE)?; + check_format(&connection)?; + let rows = read_all(&connection)?; + drop(connection); + Ok(Some(rows)) + })(); + let _ = fs::remove_dir_all(©_dir); + 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())); + } + // SYMBOLS_ROW + 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; 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..af90ad932f --- /dev/null +++ b/crates/aft/src/migration/v3_ledger_tests.rs @@ -0,0 +1,541 @@ +use std::path::Path; +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, +}; +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); +} + +#[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(); + // Close this process's shared connection before editing the file. + lock(open_connections()).clear(); + 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. + +const KILL_SUBJECT: &str = "AFT_V3_IMPORT_KILL_SUBJECT"; +const KILL_AT: &str = "AFT_V3_IMPORT_KILL_AT"; + +fn step_name(step: V3Step) -> String { + match step { + V3Step::BatchRegistered(batch) => format!("batch-{batch}"), + other => format!("{other:?}").to_lowercase(), + } +} + +struct Park { + at: String, + ready: PathBuf, +} + +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() + ); + } + } +} From 880e140a43c1f151427a52442e3fbc9a0f652a47 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Mon, 26 Oct 2026 18:30:14 +0100 Subject: [PATCH 016/100] mason: v3 symbol plane store and parse-free legacy symbol importer Co-authored-by: alfonso-aft <289616620+alfonso-aft@users.noreply.github.com> (cherry picked from commit 59f5b5c4ac7f0039d05fafd8a88da29e652931fc) --- crates/aft/src/migration/mod.rs | 1 + crates/aft/src/migration/v3_import.rs | 43 ++- crates/aft/src/migration/v3_ledger.rs | 7 +- crates/aft/src/migration/v3_symbols.rs | 122 ++++++++ crates/aft/src/migration/v3_symbols_tests.rs | 204 ++++++++++++++ crates/aft/src/parser.rs | 9 + crates/aft/src/symbol_cache_disk.rs | 52 +++- crates/aft/src/views/mod.rs | 1 + crates/aft/src/views/symbol_store.rs | 280 +++++++++++++++++++ crates/aft/src/views/symbol_store_tests.rs | 186 ++++++++++++ 10 files changed, 888 insertions(+), 17 deletions(-) create mode 100644 crates/aft/src/migration/v3_symbols.rs create mode 100644 crates/aft/src/migration/v3_symbols_tests.rs create mode 100644 crates/aft/src/views/symbol_store.rs create mode 100644 crates/aft/src/views/symbol_store_tests.rs diff --git a/crates/aft/src/migration/mod.rs b/crates/aft/src/migration/mod.rs index eaf91d6eab..0e40b00d60 100644 --- a/crates/aft/src/migration/mod.rs +++ b/crates/aft/src/migration/mod.rs @@ -17,6 +17,7 @@ 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/v3_import.rs b/crates/aft/src/migration/v3_import.rs index bbfa72656d..43a06d8313 100644 --- a/crates/aft/src/migration/v3_import.rs +++ b/crates/aft/src/migration/v3_import.rs @@ -241,7 +241,7 @@ impl SourceIdentity { /// A legacy source opened once, read only through bounded reads. It is opened /// read-only and never written, truncated or repaired. -struct Source { +pub(super) struct Source { file: fs::File, len: u64, identity: SourceIdentity, @@ -282,6 +282,14 @@ impl Source { 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 @@ -291,7 +299,10 @@ impl Source { /// One sequential pass over the whole file: its BLAKE3 and the CRC32 of /// each `[start, end)` range in `crc_ranges`. - fn checksum_pass(&mut self, crc_ranges: &[(u64, u64)]) -> Result<([u8; 32], Vec), String> { + 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]; @@ -317,6 +328,28 @@ impl Source { } } +/// `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> { @@ -1212,7 +1245,9 @@ pub(super) fn stage( } None => Err("the request carries no semantic producer".to_owned()), }, - LegacyArtifact::Symbols => Err("SYMBOLS_PENDING".to_owned()), + LegacyArtifact::Symbols => { + super::v3_symbols::convert_symbols_streaming(&mut source, input.root, &mut emit) + } }; if let Some(error) = write_error { writer.abandon(); @@ -1282,7 +1317,7 @@ pub(super) fn check_staged( ) .map(|_| ()) } - LegacyArtifact::Symbols => Err("SYMBOLS_PENDING".to_owned()), + LegacyArtifact::Symbols => super::v3_symbols::check_staged_entry(&entry), }) } diff --git a/crates/aft/src/migration/v3_ledger.rs b/crates/aft/src/migration/v3_ledger.rs index 45d08540fd..70c4eb95b4 100644 --- a/crates/aft/src/migration/v3_ledger.rs +++ b/crates/aft/src/migration/v3_ledger.rs @@ -622,7 +622,12 @@ impl V3ImportRequest { if let Some(producer) = &self.semantic { rows.push(self.row(LegacyArtifact::Semantic, producer.id())); } - // SYMBOLS_ROW + if self.symbols { + rows.push(self.row( + LegacyArtifact::Symbols, + crate::views::symbol_store::SymbolProducer::current().id(), + )); + } rows } 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..20f532b922 --- /dev/null +++ b/crates/aft/src/migration/v3_symbols_tests.rs @@ -0,0 +1,204 @@ +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); + assert!(files.len() >= 3, "{} parsed files", files.len()); + 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/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/mod.rs b/crates/aft/src/views/mod.rs index 4e5e4bfcc3..542f4b6594 100644 --- a/crates/aft/src/views/mod.rs +++ b/crates/aft/src/views/mod.rs @@ -49,6 +49,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. 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()); +} From 9e6df8d341eacd2d9ddf790792bc138f8bfeaec8 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Mon, 26 Oct 2026 18:55:53 +0100 Subject: [PATCH 017/100] mason: retained legacy inventory, live import sources kept by retention, prune-legacy consults v3 ledgers and base builds Co-authored-by: alfonso-aft <289616620+alfonso-aft@users.noreply.github.com> (cherry picked from commit 2a0960762595a330762eb6773ebf6cccbe324774) --- crates/aft/src/cli/prune_legacy.rs | 5 +- crates/aft/src/migration/prune_legacy.rs | 174 +++++++++++- .../aft/src/migration/prune_legacy_tests.rs | 250 ++++++++++++++++++ crates/aft/src/migration/v3_ledger.rs | 62 ++++- crates/aft/src/storage_retention.rs | 227 ++++++++++++++++ 5 files changed, 698 insertions(+), 20 deletions(-) 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/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..fba7aab282 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 slice-4 base protocol is 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_ledger.rs b/crates/aft/src/migration/v3_ledger.rs index 70c4eb95b4..21b1773208 100644 --- a/crates/aft/src/migration/v3_ledger.rs +++ b/crates/aft/src/migration/v3_ledger.rs @@ -493,28 +493,49 @@ pub fn read_rows(storage: &Path, family: &str) -> V3Result( + 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-ledger-read-{}-{}", + "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(LEDGER_FILE); - crate::private_storage::copy(&ledger.path, ©)?; - let wal = ledger.path.with_file_name(format!("{LEDGER_FILE}-wal")); - match crate::private_storage::copy(&wal, copy.with_file_name(format!("{LEDGER_FILE}-wal"))) + let copy = copy_dir.join(name); + crate::private_storage::copy(path, ©)?; + 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)?; - check_format(&connection)?; - let rows = read_all(&connection)?; + let result = body(&connection); drop(connection); - Ok(Some(rows)) + result })(); let _ = fs::remove_dir_all(©_dir); result @@ -945,3 +966,28 @@ pub fn run_v3_import( #[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/storage_retention.rs b/crates/aft/src/storage_retention.rs index 380739e214..bb5e100a4e 100644 --- a/crates/aft/src/storage_retention.rs +++ b/crates/aft/src/storage_retention.rs @@ -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(()); @@ -9240,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(); From 883c6f61a9e0aa87d5a1a701e675472ed14f95a8 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Mon, 26 Oct 2026 18:59:57 +0100 Subject: [PATCH 018/100] mason: gate unix-only kill-test helpers, and clarify the import ledger and symbol plane format text Co-authored-by: alfonso-aft <289616620+alfonso-aft@users.noreply.github.com> (cherry picked from commit d559a9a292ec464d4f983b35ebf8086ee15cbd62) --- .../aft/src/migration/prune_legacy_tests.rs | 4 +- crates/aft/src/migration/v3_ledger_tests.rs | 7 +++ docs/design/views-v3-format.md | 44 ++++++++++--------- 3 files changed, 33 insertions(+), 22 deletions(-) diff --git a/crates/aft/src/migration/prune_legacy_tests.rs b/crates/aft/src/migration/prune_legacy_tests.rs index fba7aab282..f722304c78 100644 --- a/crates/aft/src/migration/prune_legacy_tests.rs +++ b/crates/aft/src/migration/prune_legacy_tests.rs @@ -261,8 +261,8 @@ fn a_set_a_live_v3_import_reads_is_never_prune_eligible() { drop(held); } -/// The slice-4 base protocol is consulted: a live base build in the family -/// of the same name pins the legacy set; a dead builder does not. +/// 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(); diff --git a/crates/aft/src/migration/v3_ledger_tests.rs b/crates/aft/src/migration/v3_ledger_tests.rs index af90ad932f..75afc354b0 100644 --- a/crates/aft/src/migration/v3_ledger_tests.rs +++ b/crates/aft/src/migration/v3_ledger_tests.rs @@ -1,4 +1,5 @@ use std::path::Path; +#[cfg(unix)] use std::time::{Duration, Instant}; use super::*; @@ -7,6 +8,7 @@ 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 { @@ -371,9 +373,12 @@ fn a_ledger_from_a_newer_build_is_refused_by_name() { // 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}"), @@ -381,11 +386,13 @@ fn step_name(step: V3Step) -> String { } } +#[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 { diff --git a/docs/design/views-v3-format.md b/docs/design/views-v3-format.md index aef2ebf5f3..f17d4436aa 100644 --- a/docs/design/views-v3-format.md +++ b/docs/design/views-v3-format.md @@ -608,11 +608,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, earlier reserved for 6a, is **not used** by 6a: its import ledger is a - sidecar file and its 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 + 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 @@ -636,11 +636,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 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. + 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 @@ -669,10 +669,11 @@ 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. -## Import ledger and symbol plane (6a) +## Import ledger and symbol plane -Sources: `crates/aft/src/migration/v3_import.rs` (ledger, decoders, staging, -registration), `crates/aft/src/migration/v3_symbols.rs` (symbol importer) and +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 @@ -688,7 +689,8 @@ a legacy file. 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 and the producer id, each preceded by its + (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 @@ -715,9 +717,10 @@ 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) copy the database and its WAL -to a private temporary directory and read the copy, so a read never writes -under the storage root. +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), @@ -766,9 +769,10 @@ import, and a producer change starts a new row. - **done:** the staging bundle is removed. Inputs have no size limit. The decoders stream: the trigram `cache.bin` -is checksummed in one sequential pass, then inverted in file-id ranges -whose postings fit a working-set budget (64 MiB by default), one pass over -the posting blob per range; `semantic.bin` is decoded one file's rows at a +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) From c8900b486980e7eda2b8e86eea7e1428199f6f25 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Mon, 26 Oct 2026 19:12:29 +0100 Subject: [PATCH 019/100] mason: hold the ledger open lock across ledger copies so no descriptor closes under a live connection; assert exact imported path sets Co-authored-by: alfonso-aft <289616620+alfonso-aft@users.noreply.github.com> (cherry picked from commit 8ca96e51a2e3d76fde3e3d69cd5e3fefd5a234d6) --- crates/aft/src/migration/v3_import_tests.rs | 25 ++++++- crates/aft/src/migration/v3_ledger.rs | 53 +++++++++++--- crates/aft/src/migration/v3_ledger_tests.rs | 75 +++++++++++++++++++- crates/aft/src/migration/v3_symbols_tests.rs | 12 +++- 4 files changed, 153 insertions(+), 12 deletions(-) diff --git a/crates/aft/src/migration/v3_import_tests.rs b/crates/aft/src/migration/v3_import_tests.rs index 4eccc54071..3f96c8219a 100644 --- a/crates/aft/src/migration/v3_import_tests.rs +++ b/crates/aft/src/migration/v3_import_tests.rs @@ -332,7 +332,30 @@ fn imported_trigram_rows_equal_a_cold_build_and_read_no_checkout_file() { }, ) .unwrap(); - assert!(entries.len() >= 3, "{} entries", entries.len()); + // 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, diff --git a/crates/aft/src/migration/v3_ledger.rs b/crates/aft/src/migration/v3_ledger.rs index 21b1773208..f358cedc52 100644 --- a/crates/aft/src/migration/v3_ledger.rs +++ b/crates/aft/src/migration/v3_ledger.rs @@ -266,6 +266,10 @@ impl V3Ledger { /// 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); @@ -482,21 +486,48 @@ impl V3Ledger { /// 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)?; - if let Some(shared) = lock(open_connections()) - .get(&ledger.path) - .and_then(Weak::upgrade) - { + 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); } - read_sqlite_copy(&ledger.path, |connection| { + 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 @@ -505,8 +536,10 @@ pub fn read_rows(storage: &Path, family: &str) -> V3Result( path: &Path, body: impl FnOnce(&rusqlite::Connection) -> V3Result, @@ -524,6 +557,8 @@ pub(crate) fn read_sqlite_copy( 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)) @@ -538,6 +573,8 @@ pub(crate) fn read_sqlite_copy( result })(); let _ = fs::remove_dir_all(©_dir); + #[cfg(test)] + copy_hook("finished"); result } diff --git a/crates/aft/src/migration/v3_ledger_tests.rs b/crates/aft/src/migration/v3_ledger_tests.rs index 75afc354b0..3269a0ed37 100644 --- a/crates/aft/src/migration/v3_ledger_tests.rs +++ b/crates/aft/src/migration/v3_ledger_tests.rs @@ -343,6 +343,76 @@ fn a_live_claim_is_single_flight_and_an_abandoned_one_is_taken_over() { 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(); @@ -355,8 +425,9 @@ fn a_ledger_from_a_newer_build_is_refused_by_name() { .unwrap() .path() .to_path_buf(); - // Close this process's shared connection before editing the file. - lock(open_connections()).clear(); + // 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", []) diff --git a/crates/aft/src/migration/v3_symbols_tests.rs b/crates/aft/src/migration/v3_symbols_tests.rs index 20f532b922..5151a1a869 100644 --- a/crates/aft/src/migration/v3_symbols_tests.rs +++ b/crates/aft/src/migration/v3_symbols_tests.rs @@ -61,7 +61,17 @@ fn a_compatible_symbol_import_needs_no_parse_and_serves_lookups() { let root = checkout(dir.path()); let storage = dir.path().join("storage"); let files = legacy_symbols(&storage, &root); - assert!(files.len() >= 3, "{} parsed files", files.len()); + // 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(); From d25885b46941575c35daf2cea8288ae9724b8583 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Mon, 26 Oct 2026 19:30:03 +0100 Subject: [PATCH 020/100] search-quality descriptor for views-e-legacy-imports Co-authored-by: alfonso-aft <289616620+alfonso-aft@users.noreply.github.com> --- .../slice-descriptors/land-views-e-legacy-imports.json | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 benchmarks/aft-search/slice-descriptors/land-views-e-legacy-imports.json 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" + ] +} From 3a3e6c4c13dec3fec713a86dc9590442f0275363 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Mon, 26 Oct 2026 14:05:37 +0100 Subject: [PATCH 021/100] mason: add read-only Git index reader and golden fixtures Co-authored-by: alfonso-aft <289616620+alfonso-aft@users.noreply.github.com> (cherry picked from commit 2f67e5589a47fa7696cae759c83769233b198758) --- crates/aft/src/git_index_reader.rs | 1291 ++++++++++++++++++++++++++++ crates/aft/src/lib.rs | 1 + 2 files changed, 1292 insertions(+) create mode 100644 crates/aft/src/git_index_reader.rs diff --git a/crates/aft/src/git_index_reader.rs b/crates/aft/src/git_index_reader.rs new file mode 100644 index 0000000000..4cfc79c5bf --- /dev/null +++ b/crates/aft/src/git_index_reader.rs @@ -0,0 +1,1291 @@ +//! Read-only Git index snapshots and counted, deadline-bound Git metadata queries. +//! +//! Nothing in production routes through this module yet. A snapshot records only +//! the index bytes observed, not proof that worktree bytes are clean. Callers must +//! verify worktree 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 { + 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, + }) +} + +/// 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()) + } + + 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); + 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)); + } + + #[cfg(unix)] + #[test] + fn real_git_preserves_non_utf8_paths() { + use std::ffi::OsStr; + use std::os::unix::ffi::OsStrExt; + let dir = fixture(ObjectFormat::Sha1); + let name = b"non-utf8-\xff"; + fs::write(dir.path().join(OsStr::from_bytes(name)), b"raw\n").unwrap(); + git(dir.path(), &["add", "."], &[]); + git(dir.path(), &["update-index", "--index-version", "4"], &[]); + assert!(snapshot(dir.path(), ObjectFormat::Sha1) + .entries + .iter() + .any(|e| e.path == name)); + } + + #[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)); + } +} 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; From b8e6a3c07991f9e85ccae30a4d906a8ad738f068 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Mon, 26 Oct 2026 14:21:58 +0100 Subject: [PATCH 022/100] mason: correct check-attr stdin argument syntax The large bidirectional-pump golden test exposed BrokenPipe because check-attr --stdin treats arguments after -- as forbidden pathnames, not attribute names. Keep the fixed conversion attribute names before any pathname separator. The initial module run passed the other 19 tests; the corrected query test passes on Linux. Windows tests compile with warnings denied. Co-authored-by: alfonso-aft <289616620+alfonso-aft@users.noreply.github.com> (cherry picked from commit 32d148904afb9261a7bd1fbe6b4d9f18706b7c84) --- crates/aft/src/git_index_reader.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/crates/aft/src/git_index_reader.rs b/crates/aft/src/git_index_reader.rs index 4cfc79c5bf..2356bea40e 100644 --- a/crates/aft/src/git_index_reader.rs +++ b/crates/aft/src/git_index_reader.rs @@ -534,7 +534,6 @@ impl GitQueries { "check-attr", "--stdin", "-z", - "--", "filter", "text", "eol", From 229ed4341c3d0c8f84d35dc95cd9a2211f7f382e Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Mon, 26 Oct 2026 14:13:10 +0100 Subject: [PATCH 023/100] mason: audit watcher frontier coverage before cookie wiring Co-authored-by: alfonso-aft <289616620+alfonso-aft@users.noreply.github.com> (cherry picked from commit 9f8edb63efe442423662ac9babc35bca3b343558) --- docs/design/views-4v-frontier-audit.md | 162 +++++++++++++++++++++++++ 1 file changed, 162 insertions(+) create mode 100644 docs/design/views-4v-frontier-audit.md diff --git a/docs/design/views-4v-frontier-audit.md b/docs/design/views-4v-frontier-audit.md new file mode 100644 index 0000000000..3c171f6ff8 --- /dev/null +++ b/docs/design/views-4v-frontier-audit.md @@ -0,0 +1,162 @@ +# 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). 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 planned 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. | From 9e2d31eba9d47470de4cb5ff0143649edd6f9f70 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Mon, 26 Oct 2026 14:32:12 +0100 Subject: [PATCH 024/100] mason: deliver ordered watcher cookies and views overflow state Co-authored-by: alfonso-aft <289616620+alfonso-aft@users.noreply.github.com> (cherry picked from commit ffd27611beaf10a53c225095ad4a29a6673d19ab) --- crates/aft/src/context.rs | 30 ++ crates/aft/src/runtime_drain.rs | 16 + crates/aft/src/views/first_load.rs | 46 +++ crates/aft/src/watcher_backend/cookie.rs | 151 +++++++++ .../aft/src/watcher_backend/cookie_tests.rs | 299 ++++++++++++++++++ crates/aft/src/watcher_backend/flush.rs | 8 +- crates/aft/src/watcher_backend/fsevents.rs | 1 + crates/aft/src/watcher_backend/inotify.rs | 52 ++- crates/aft/src/watcher_backend/mod.rs | 3 + crates/aft/src/watcher_filter.rs | 170 +++++++++- docs/design/views-4v-frontier-audit.md | 7 +- 11 files changed, 770 insertions(+), 13 deletions(-) create mode 100644 crates/aft/src/watcher_backend/cookie.rs create mode 100644 crates/aft/src/watcher_backend/cookie_tests.rs diff --git a/crates/aft/src/context.rs b/crates/aft/src/context.rs index 48e3b14360..46c3f11d68 100644 --- a/crates/aft/src/context.rs +++ b/crates/aft/src/context.rs @@ -1720,6 +1720,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 +1894,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; } } @@ -10522,6 +10527,31 @@ impl AppContext { self.checkout_semantic.wake(); } + pub(crate) fn record_checkout_watcher_overflow(&self) { + self.reset_view_publication_retry(); + 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(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 { diff --git a/crates/aft/src/runtime_drain.rs b/crates/aft/src/runtime_drain.rs index c8557a85c4..ffb909dbc0 100644 --- a/crates/aft/src/runtime_drain.rs +++ b/crates/aft/src/runtime_drain.rs @@ -3695,6 +3695,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 +3755,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 +3778,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/views/first_load.rs b/crates/aft/src/views/first_load.rs index fa9743e8a3..433d2ba3c3 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; @@ -1483,6 +1484,7 @@ 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, #[cfg(test)] work: std::sync::Mutex, } @@ -1553,6 +1555,7 @@ impl CheckoutDriver { adapters: Vec::new(), publish_fence: std::sync::RwLock::new(None), observed: Default::default(), + watcher_cookies: Default::default(), installed: std::sync::Mutex::new(Arc::new(InstalledBundle { seq: 0, composed, @@ -1615,6 +1618,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()); + // A known path cannot repair an earlier stream-wide delivery gap. + delta.set_watcher(installed.snapshot.watcher()); installed.snapshot = delta .snapshot() .with_readers(installed.readers.clone()) @@ -1627,6 +1632,47 @@ impl CheckoutDriver { }); } + /// Invalidates stale builds and indexed membership until strict reconciliation. + pub fn record_watcher_overflow(&self) { + let mut installed = self + .installed + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + installed.revision += 1; + let mut delta = + super::snapshot::LiveDelta::new(Arc::clone(installed.snapshot.generation())); + for (path, entry) in installed.snapshot.live_entries() { + delta.apply(path.clone(), entry.clone()); + } + for path in installed.snapshot.pending_intent() { + delta.record_intent(path.clone()); + } + delta.record_root_ignore_files(installed.snapshot.root_ignore_files().cloned()); + delta.set_watcher(super::snapshot::WatcherState::Overflowed); + installed.snapshot = delta.snapshot(); + } + + /// 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) 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..fc529f5d22 100644 --- a/crates/aft/src/watcher_backend/inotify.rs +++ b/crates/aft/src/watcher_backend/inotify.rs @@ -335,12 +335,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() @@ -813,6 +812,47 @@ fn desired_watch_directories( #[cfg(test)] pub(super) mod tests { + #[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..1f901c54d6 100644 --- a/crates/aft/src/watcher_backend/mod.rs +++ b/crates/aft/src/watcher_backend/mod.rs @@ -3,6 +3,9 @@ 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; #[cfg(target_os = "macos")] diff --git a/crates/aft/src/watcher_filter.rs b/crates/aft/src/watcher_filter.rs index eb4933d394..b91224fe79 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) @@ -2403,10 +2407,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 +2465,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 +2499,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 +2745,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 +3022,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 +3086,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 +3201,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/docs/design/views-4v-frontier-audit.md b/docs/design/views-4v-frontier-audit.md index 3c171f6ff8..bf4fa4a194 100644 --- a/docs/design/views-4v-frontier-audit.md +++ b/docs/design/views-4v-frontier-audit.md @@ -43,11 +43,14 @@ 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). This provides an ordered **delivery acknowledgment**, +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 planned additive implementation includes regression tests named +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 From 1fbd899736d5bf91730d24fd5daae4c762412eaf Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Mon, 26 Oct 2026 19:46:20 +0100 Subject: [PATCH 025/100] mason: expose plane-independent verified bind snapshot hook Co-authored-by: alfonso-aft <289616620+alfonso-aft@users.noreply.github.com> --- crates/aft/src/views/first_load.rs | 41 ++++++++++++++++++++++++++++-- 1 file changed, 39 insertions(+), 2 deletions(-) diff --git a/crates/aft/src/views/first_load.rs b/crates/aft/src/views/first_load.rs index 433d2ba3c3..7bc22df3ff 100644 --- a/crates/aft/src/views/first_load.rs +++ b/crates/aft/src/views/first_load.rs @@ -565,6 +565,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 @@ -576,8 +584,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)); @@ -1395,6 +1403,23 @@ mod stat_tests { .unwrap(); } + #[test] + fn verified_bind_hook_without_a_frontier_strictly_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, _) = driver.verified_bind(&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 stat_reconcile_detects_unreported_same_size_backdated_edit() { let root = tempfile::tempdir().unwrap(); @@ -1506,6 +1531,18 @@ pub(crate) struct ReconcileWork { } impl CheckoutDriver { + /// Verifies this checkout against `base` and returns a reconciliation cut. + /// Unsupported freshness proofs use strict reconciliation instead. This + /// does not publish or install the cut; those steps still validate its epoch + /// and installed-bundle identity. The hook is independent of plane selection. + pub fn verified_bind( + &self, + access: &ViewAccess, + base: Arc, + ) -> Result<(Snapshot, u64), PlaneError> { + SiblingLoader::reconciled_snapshot(self, access, base) + } + pub fn take_revalidated_job(&self) -> bool { self.retry.swap(false, std::sync::atomic::Ordering::AcqRel) } From 2dec7c976d80cde459ac5ff6920b39079ea43407 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Mon, 26 Oct 2026 19:54:49 +0100 Subject: [PATCH 026/100] mason: reject unstable view reads and validate after publication waits Co-authored-by: alfonso-aft <289616620+alfonso-aft@users.noreply.github.com> --- crates/aft/src/views/base_refs.rs | 9 +- crates/aft/src/views/base_refs_tests.rs | 56 ++++ crates/aft/src/views/first_load.rs | 246 +++++++++++++++--- crates/aft/src/views/readiness.rs | 11 + crates/aft/src/views/semantic_runtime.rs | 4 +- .../aft/src/views/semantic_runtime_tests.rs | 29 +++ crates/aft/src/views/snapshot.rs | 2 + crates/aft/src/views/v3_plane_tests.rs | 1 + 8 files changed, 323 insertions(+), 35 deletions(-) diff --git a/crates/aft/src/views/base_refs.rs b/crates/aft/src/views/base_refs.rs index d3dc104fe2..4b2d5f6c6c 100644 --- a/crates/aft/src/views/base_refs.rs +++ b/crates/aft/src/views/base_refs.rs @@ -680,7 +680,7 @@ pub fn publish_worker( request: WorkerPublication<'_>, observer: Option<&dyn DurabilityObserver>, ) -> Result { - publish_worker_with(request, observer, &mut |_| Ok(())) + publish_worker_with(request, observer, &mut |_| Ok(()), &mut || Ok(())) } /// [`publish_worker`] with a step that runs once the final generation name is @@ -688,10 +688,14 @@ pub fn publish_worker( /// 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 { let WorkerPublication { storage, @@ -846,6 +850,9 @@ pub fn publish_worker_with( if let Err(error) = verified { return Err(abort(error, marker)); } + if let Err(error) = final_validate() { + return Err(abort(error, marker)); + } // (4) The CAS. From here on the reference may be durable. let outcome = worker_store.commit_v2(prepared, observer); diff --git a/crates/aft/src/views/base_refs_tests.rs b/crates/aft/src/views/base_refs_tests.rs index 55a562e3c2..531cf93845 100644 --- a/crates/aft/src/views/base_refs_tests.rs +++ b/crates/aft/src/views/base_refs_tests.rs @@ -402,6 +402,62 @@ fn a_failed_verify_cleans_up_its_intent_before_any_pointer_moves() { 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(); diff --git a/crates/aft/src/views/first_load.rs b/crates/aft/src/views/first_load.rs index 7bc22df3ff..fdaf881924 100644 --- a/crates/aft/src/views/first_load.rs +++ b/crates/aft/src/views/first_load.rs @@ -602,7 +602,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); @@ -1332,6 +1336,7 @@ struct ObservedStat { #[cfg(unix)] device: u64, recorded_ns: u128, + delivery_seq: u64, } /// Publish stat evidence together with the extracted content it validates. @@ -1353,6 +1358,7 @@ impl ObservedStat { metadata.dev() }, recorded_ns, + delivery_seq: 0, } } @@ -1420,6 +1426,67 @@ mod stat_tests { 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, _) = driver.verified_bind(&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(); @@ -1510,10 +1577,28 @@ pub struct CheckoutDriver { publication_transition: std::sync::Mutex<()>, retry: std::sync::atomic::AtomicBool, watcher_cookies: crate::watcher_backend::cookie::CookieBarrier, + delivery: 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, +} + struct PreparedReaders { seq: u64, readers: Arc, @@ -1531,6 +1616,66 @@ pub(crate) struct ReconcileWork { } impl CheckoutDriver { + fn stable_read( + &self, + path: &RelPath, + absolute: &std::path::Path, + recorded_ns: u128, + first_seq: u64, + ) -> 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 = 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; + work.content_hash_bytes += bytes.len(); + } + 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; + #[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. This /// does not publish or install the cut; those steps still validate its epoch @@ -1593,6 +1738,9 @@ impl CheckoutDriver { publish_fence: std::sync::RwLock::new(None), observed: Default::default(), watcher_cookies: Default::default(), + delivery: Default::default(), + #[cfg(test)] + read_hook: Default::default(), installed: std::sync::Mutex::new(Arc::new(InstalledBundle { seq: 0, composed, @@ -1642,6 +1790,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. @@ -1671,22 +1828,28 @@ impl CheckoutDriver { /// Invalidates stale builds and indexed membership until strict reconciliation. pub fn record_watcher_overflow(&self) { + 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; - let mut delta = - super::snapshot::LiveDelta::new(Arc::clone(installed.snapshot.generation())); - for (path, entry) in installed.snapshot.live_entries() { - delta.apply(path.clone(), entry.clone()); - } - for path in installed.snapshot.pending_intent() { - delta.record_intent(path.clone()); - } - delta.record_root_ignore_files(installed.snapshot.root_ignore_files().cloned()); + delta.bump_epoch(); delta.set_watcher(super::snapshot::WatcherState::Overflowed); - installed.snapshot = delta.snapshot(); + 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(), + }); } /// Creates an exclusive root cookie. This does not enable verified bind; @@ -1829,6 +1992,11 @@ impl FirstLoadDriver for CheckoutDriver { .unwrap_or_default() .as_secs(), ) * 1_000_000_000; + 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)) = ( @@ -1836,7 +2004,15 @@ 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); + if cached.reusable(current) && delivered <= cached.delivery_seq { let mut entry = old.clone(); entry.seq = revision; entries.insert(path.clone(), entry); @@ -1845,27 +2021,20 @@ impl FirstLoadDriver for CheckoutDriver { } } } - 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); - } - } - #[cfg(test)] - { - let mut work = self.work.lock().unwrap(); - work.reads += 1; - work.content_hash_bytes += bytes.len(); - } + let (bytes, evidence) = + match self.stable_read(&path, &absolute, recorded_ns, delivery_seq)? { + 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(), evidence); let mut entry = LiveEntry::new(super::snapshot::DiskState::of_bytes(&bytes), revision); + entry.reconciliation = super::readiness::ReconciledDelta::Reconciled; for plane in &self.planes { if plane.applies_to(&path) { #[cfg(test)] @@ -2408,6 +2577,10 @@ impl CheckoutDriver { } Ok(()) }, + &mut || { + self.validate_cut(access, snapshot, 0) + .map_err(|error| super::base_refs::BaseRefError::Unavailable(error.reason)) + }, ); drop(live); match outcome { @@ -2495,7 +2668,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() { 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/semantic_runtime.rs b/crates/aft/src/views/semantic_runtime.rs index 9baa87f782..3f6456efc7 100644 --- a/crates/aft/src/views/semantic_runtime.rs +++ b/crates/aft/src/views/semantic_runtime.rs @@ -730,7 +730,9 @@ pub struct CheckoutSemanticMemory { fn needs_reload(snapshot: &Snapshot) -> bool { snapshot.generation().name() == "empty" || snapshot.pending_intent().next().is_some() - || snapshot.live_entries().next().is_some() + || snapshot + .live_entries() + .any(|(_, entry)| entry.reconciliation != super::readiness::ReconciledDelta::Reconciled) } /// Whether reconciling `snapshot` needs a cold-build slot. A reconcile walks diff --git a/crates/aft/src/views/semantic_runtime_tests.rs b/crates/aft/src/views/semantic_runtime_tests.rs index 858e815fc2..86b15bc598 100644 --- a/crates/aft/src/views/semantic_runtime_tests.rs +++ b/crates/aft/src/views/semantic_runtime_tests.rs @@ -12,6 +12,35 @@ 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())); + delta.record_intent(path); + assert!(super::needs_reload(&delta.snapshot())); +} + /// 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 a987f1c866..e89db24033 100644 --- a/crates/aft/src/views/snapshot.rs +++ b/crates/aft/src/views/snapshot.rs @@ -535,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 { @@ -553,6 +554,7 @@ impl LiveEntry { disk, seq, attachments: BTreeMap::new(), + reconciliation: Default::default(), } } } diff --git a/crates/aft/src/views/v3_plane_tests.rs b/crates/aft/src/views/v3_plane_tests.rs index 9f110e28ba..a3df1dbde7 100644 --- a/crates/aft/src/views/v3_plane_tests.rs +++ b/crates/aft/src/views/v3_plane_tests.rs @@ -150,6 +150,7 @@ impl Fixture { }, None, before_cas, + &mut || Ok(()), ) .unwrap() { From c62a7cc5c2852800cb38cefb9d2bd1b51ea9c78a Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Mon, 26 Oct 2026 20:13:29 +0100 Subject: [PATCH 027/100] mason: arm inotify directories before initial enumeration Co-authored-by: alfonso-aft <289616620+alfonso-aft@users.noreply.github.com> --- crates/aft/src/watcher_backend/inotify.rs | 122 ++++++++++++++++++---- 1 file changed, 103 insertions(+), 19 deletions(-) diff --git a/crates/aft/src/watcher_backend/inotify.rs b/crates/aft/src/watcher_backend/inotify.rs index fc529f5d22..bbcdc5b2f9 100644 --- a/crates/aft/src/watcher_backend/inotify.rs +++ b/crates/aft/src/watcher_backend/inotify.rs @@ -114,30 +114,18 @@ 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 installed = + 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); @@ -734,6 +722,42 @@ impl DirectoryIdentity { } } +/// Arm each parent before listing it. Children created during registration +/// are then either enumerated or reported on the already armed parent stream. +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(); + while let Some(directory) = stack.pop() { + if exclusions + .iter() + .any(|excluded| directory.starts_with(excluded)) + || watcher_directory_is_ignored_by_matcher(matcher, &directory, root) + || !boundary.allows_directory(&directory) + { + continue; + } + if !watch(&directory)? { + return Err(notify::Error::path_not_found().add_path(directory)); + } + installed.insert(directory.clone()); + for entry in + crate::watcher_filter::exclusion_read_dir(&directory).map_err(notify::Error::io)? + { + let entry = entry.map_err(notify::Error::io)?; + if entry.file_type().map_err(notify::Error::io)?.is_dir() { + stack.push(entry.path()); + } + } + } + Ok(installed) +} + fn collect_watch_directories( boundary: &crate::watcher_filter::WatcherWalkBoundary, matcher: &SharedGitignore, @@ -812,6 +836,66 @@ fn desired_watch_directories( #[cfg(test)] pub(super) mod tests { + #[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.len(), 3); + } + + #[test] + fn initial_subscription_refuses_a_missing_watch_or_incomplete_listing() { + 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) + } + }, + ); + assert!(result.is_err()); + } + } + #[test] fn watcher_cookie_inotify_recovery_precedes_root_cookie_without_command_tracking() { let temp = tempfile::tempdir().unwrap(); From b1870982dbe791688dbfe04e297af4de8df35b8e Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Mon, 26 Oct 2026 20:13:31 +0100 Subject: [PATCH 028/100] mason: pair v3 stat evidence with generations and mint advisory view tokens Co-authored-by: alfonso-aft <289616620+alfonso-aft@users.noreply.github.com> --- crates/aft/src/blob_store/v3.rs | 56 ++ crates/aft/src/commands/mod.rs | 1 + crates/aft/src/commands/view_token.rs | 62 ++ crates/aft/src/main.rs | 1 + crates/aft/src/persisted_format.rs | 2 +- crates/aft/src/views/first_load.rs | 398 +++++++++- crates/aft/src/views/mod.rs | 1 + crates/aft/src/views/registry.rs | 380 ++++++++++ crates/aft/src/views/semantic.rs | 16 + crates/aft/src/views/snapshot.rs | 4 + crates/aft/src/views/verification.rs | 689 ++++++++++++++++++ docs/design/views-v3-format.md | 27 + .../src/__tests__/view-token.test.ts | 27 + packages/aft-bridge/src/index.ts | 2 + packages/aft-bridge/src/view-token.ts | 31 + 15 files changed, 1670 insertions(+), 27 deletions(-) create mode 100644 crates/aft/src/commands/view_token.rs create mode 100644 crates/aft/src/views/verification.rs create mode 100644 packages/aft-bridge/src/__tests__/view-token.test.ts create mode 100644 packages/aft-bridge/src/view-token.ts diff --git a/crates/aft/src/blob_store/v3.rs b/crates/aft/src/blob_store/v3.rs index 06da17f730..ef6d118a33 100644 --- a/crates/aft/src/blob_store/v3.rs +++ b/crates/aft/src/blob_store/v3.rs @@ -52,6 +52,7 @@ const META: &str = "CREATE TABLE format_meta (singleton INTEGER PRIMARY KEY CHEC /// [`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 { () => { " @@ -97,6 +98,21 @@ CREATE TABLE sweep_state (singleton INTEGER PRIMARY KEY CHECK(singleton=1), 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), @@ -487,6 +503,7 @@ fn validate(c: &Connection, path: &Path, kind: Kind) -> Result<()> { // 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 { @@ -508,6 +525,11 @@ fn validate(c: &Connection, path: &Path, kind: Kind) -> Result<()> { 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, }; @@ -906,6 +928,8 @@ impl<'a> RegistryWriter<'a> { )?; 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(()); } @@ -931,6 +955,19 @@ impl<'a> RegistryWriter<'a> { 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() } @@ -1437,6 +1474,25 @@ pub fn open_base_refs_registry<'a>( ) .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, 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/view_token.rs b/crates/aft/src/commands/view_token.rs new file mode 100644 index 0000000000..29416fb372 --- /dev/null +++ b/crates/aft/src/commands/view_token.rs @@ -0,0 +1,62 @@ +//! 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 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, "root is required"); + }; + let Some(tree) = request.params.get("expected_tree").and_then(|v| v.as_str()) else { + return Response::error(&request.id, "expected_tree is required"); + }; + let Some(project) = context.canonical_cache_root_opt() else { + return Response::error(&request.id, "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, &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, &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/main.rs b/crates/aft/src/main.rs index d3857d0d42..cf562ef377 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" => { diff --git a/crates/aft/src/persisted_format.rs b/crates/aft/src/persisted_format.rs index 1037e120db..67ed315d59 100644 --- a/crates/aft/src/persisted_format.rs +++ b/crates/aft/src/persisted_format.rs @@ -111,7 +111,7 @@ 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 => crate::blob_store::v3::REGISTRY_BASE_REFS_VERSION, + PersistedStore::ViewsV3Registry => crate::blob_store::v3::REGISTRY_VERIFICATION_VERSION, PersistedStore::ViewsV3Store => 1, } } diff --git a/crates/aft/src/views/first_load.rs b/crates/aft/src/views/first_load.rs index fdaf881924..78eab56433 100644 --- a/crates/aft/src/views/first_load.rs +++ b/crates/aft/src/views/first_load.rs @@ -1238,6 +1238,29 @@ pub trait CompositePlane: Send + Sync { fn plane(&self) -> FamilyPlane; fn applies_to(&self, path: &RelPath) -> bool; + /// 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. fn attachment( @@ -1337,6 +1360,7 @@ struct ObservedStat { device: u64, recorded_ns: u128, delivery_seq: u64, + native: Option, } /// Publish stat evidence together with the extracted content it validates. @@ -1346,6 +1370,8 @@ struct ObservedStat { struct ObservedCheckout { entries: BTreeMap, stats: BTreeMap, + evidence: BTreeMap, + guard: Option>, } impl ObservedStat { @@ -1359,10 +1385,14 @@ impl ObservedStat { }, 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 @@ -1578,6 +1608,8 @@ pub struct CheckoutDriver { 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>, #[cfg(test)] read_hook: std::sync::Mutex>, #[cfg(test)] @@ -1599,6 +1631,15 @@ enum StableRead { 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, @@ -1640,7 +1681,7 @@ impl CheckoutDriver { if let Some(bytes) = &bytes { let mut work = self.work.lock().unwrap(); work.reads += 1; - work.content_hash_bytes += bytes.len(); + let _ = bytes; } if let Some(hook) = &*self.read_hook.lock().unwrap() { hook(absolute, attempt); @@ -1656,7 +1697,7 @@ impl CheckoutDriver { 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; + let stable = before.stamp == after.stamp && before.native == after.native; #[cfg(unix)] let stable = stable && before.device == after.device; if stable { @@ -1685,9 +1726,41 @@ impl CheckoutDriver { access: &ViewAccess, base: Arc, ) -> Result<(Snapshot, u64), PlaneError> { + 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( + base.v3_route().is_some(), + std::sync::atomic::Ordering::Release, + ); SiblingLoader::reconciled_snapshot(self, access, base) } + pub fn set_view_token(&self, token: String) { + *self + .advisory_token + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(token); + } + pub fn take_revalidated_job(&self) -> bool { self.retry.swap(false, std::sync::atomic::Ordering::AcqRel) } @@ -1727,6 +1800,7 @@ impl CheckoutDriver { epoch: delta.epoch(), version: delta.version(), intent_version: delta.snapshot().intent_version(), + verification: None, }); Self { owner, @@ -1739,6 +1813,8 @@ impl CheckoutDriver { observed: Default::default(), watcher_cookies: Default::default(), delivery: Default::default(), + v3_reconcile: Default::default(), + advisory_token: Default::default(), #[cfg(test)] read_hook: Default::default(), installed: std::sync::Mutex::new(Arc::new(InstalledBundle { @@ -1823,6 +1899,10 @@ 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()), }); } @@ -1849,6 +1929,10 @@ 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()), }); } @@ -1970,8 +2054,42 @@ impl FirstLoadDriver for CheckoutDriver { .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) .clone(); + 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(); let mut entries = BTreeMap::new(); let mut stats = BTreeMap::new(); + let mut evidence = BTreeMap::new(); #[cfg(test)] { self.work.lock().unwrap().walks += 1; @@ -1986,12 +2104,7 @@ 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() @@ -2012,11 +2125,79 @@ impl FirstLoadDriver for CheckoutDriver { .get(&path) .copied() .unwrap_or(0); - if cached.reusable(current) && delivered <= cached.delivery_seq { + 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; + } + } + } + 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| 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; + stats.insert(path.clone(), current); + entries.insert(path.clone(), entry); + evidence.insert(path, record.clone()); continue; } } @@ -2033,19 +2214,59 @@ impl FirstLoadDriver for CheckoutDriver { } }; stats.insert(path.clone(), evidence); - let mut entry = LiveEntry::new(super::snapshot::DiskState::of_bytes(&bytes), revision); + let content = crate::blob_store::v2::ContentHash::of(&bytes); + #[cfg(test)] + { + self.work.lock().unwrap().content_hash_bytes += bytes.len(); + } + 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 @@ -2054,11 +2275,17 @@ impl FirstLoadDriver for CheckoutDriver { .unwrap_or_else(std::sync::PoisonError::into_inner) = ObservedCheckout { entries: entries.clone(), stats, + evidence, + guard: guard.clone(), }; + 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), }) @@ -2155,6 +2382,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() { @@ -2575,9 +2803,30 @@ impl CheckoutDriver { 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)) }, @@ -3822,6 +4071,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, + crate::blob_store::CALLGRAPH_PRODUCER_VERSION, + ) + .full_key(), + )) + } fn attachment( &self, path: &RelPath, @@ -3995,6 +4268,25 @@ impl CompositePlane for CallgraphBridge { { 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, .. }) = + delta.get_mut(path) + { + planes.callgraph = Some(super::readiness::PlaneState::ready(&cached.0)); + } + continue; + } let attachment = entry .attachments .get(&FamilyPlane::Callgraph) @@ -4657,6 +4949,19 @@ impl CompositePlane for TrigramBridge { 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, @@ -4698,7 +5003,7 @@ impl CompositePlane for TrigramBridge { } fn materialize_v3( &self, - owner: &super::registry::ViewRegistration, + _owner: &super::registry::ViewRegistration, route: &super::snapshot::V3Route, _: &str, _: &Arc, @@ -4706,15 +5011,53 @@ impl CompositePlane for TrigramBridge { delta: &mut super::manifest_v2::ManifestV2, live: &mut crate::pins::LivePin, ) -> Result<(), PlaneError> { - super::trigram::materialize_v3_observed( - &route.storage, - &route.family, - owner.root(), - delta, - observed, - live, - self.policy, - ) + 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(()) } } @@ -4730,7 +5073,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::read(path).ok(); + Ok(Some(stat)) } Ok(_) => Ok(None), Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None), @@ -4814,6 +5159,7 @@ impl super::intent::WriteIntentListener for CheckoutDriver { epoch: live.epoch(), version: live.version(), intent_version: live.snapshot().intent_version(), + verification: None, }); } let snapshot = self diff --git a/crates/aft/src/views/mod.rs b/crates/aft/src/views/mod.rs index 542f4b6594..67e7433e8e 100644 --- a/crates/aft/src/views/mod.rs +++ b/crates/aft/src/views/mod.rs @@ -64,6 +64,7 @@ pub mod semantic; pub mod semantic_arena; pub mod semantic_runtime; pub mod trigram; +mod verification; #[cfg(test)] mod dispatch_parity_probe; diff --git a/crates/aft/src/views/registry.rs b/crates/aft/src/views/registry.rs index d0aee2b097..5ed7326166 100644 --- a/crates/aft/src/views/registry.rs +++ b/crates/aft/src/views/registry.rs @@ -1410,6 +1410,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::read(&root), + super::verification::NativeStat::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::*; @@ -1688,3 +1873,198 @@ mod tests { ); } } + +#[cfg(test)] +mod view_token_tests { + use super::*; + const TREE: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + + #[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/semantic.rs b/crates/aft/src/views/semantic.rs index d5c76fdd69..ae753850d9 100644 --- a/crates/aft/src/views/semantic.rs +++ b/crates/aft/src/views/semantic.rs @@ -2613,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, diff --git a/crates/aft/src/views/snapshot.rs b/crates/aft/src/views/snapshot.rs index e89db24033..6a3069034e 100644 --- a/crates/aft/src/views/snapshot.rs +++ b/crates/aft/src/views/snapshot.rs @@ -1314,6 +1314,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(); @@ -1376,6 +1377,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)); @@ -1410,6 +1412,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)); @@ -1684,6 +1687,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 diff --git a/crates/aft/src/views/verification.rs b/crates/aft/src/views/verification.rs new file mode 100644 index 0000000000..eb0c74874b --- /dev/null +++ b/crates/aft/src/views/verification.rs @@ -0,0 +1,689 @@ +//! 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; +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, + }, +} + +#[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, +} + +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(), + }) + } + #[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 == current && self.modified_ns >= 0 && (self.modified_ns as u128) < observed_ns + } +} + +#[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, + }) +} + +#[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 mut hash = blake3::Hasher::new(); + hash.update(b"aft/views-v3/conversion-policy/1"); + let queries = + crate::git_index_reader::GitQueries::new(root.to_path_buf(), Duration::from_secs(2)); + let output = queries.config()?; + if !output.status.success() { + return Ok(Self { + digest: "non-git".into(), + reusable: false, + }); + } + hash.update(&output.stdout); + let mut reusable = true; + // Config --show-origin records include targets and worktree config. + // Hash their bytes as well as the effective values, including empty files. + for item in output.stdout.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; + } + if let Some(path) = item.strip_prefix(b"core.attributesfile\n") { + metadata_source(&path_from_bytes(path)?, &mut hash)?; + reusable = false; + } + } + 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; + } + } + if let Some(home) = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE")) { + for relative in [".gitattributes", ".config/git/attributes"] { + let path = PathBuf::from(&home).join(relative); + metadata_source(&path, &mut hash)?; + reusable &= !path.exists(); + } + } + let system = Path::new("/etc/gitattributes"); + metadata_source(system, &mut hash)?; + reusable &= !system.exists(); + 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"] { + 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")); + } + 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, +} + +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 policy = Policy::capture(root, &inputs)?; + let root_identity = NativeStat::read(root)?.identity; + let directory = match gitdir(root) { + Ok(path) => Some((path.clone(), NativeStat::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::read(&path) { + Ok(before) => { + let bytes = fs::read(&path)?; + let after = NativeStat::read(&path)?; + if before != after { + return Err(io::Error::other("Git index changed during verification")); + } + index = Some((before, *blake3::hash(&bytes).as_bytes())); + } + 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, + }) + } + 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 the evidence before the CAS. Its 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-{}.staging", + super::base_refs::fresh_nonce() + )); + let result = (|| -> Result<(), Box> { + let mut db = rusqlite::Connection::open(&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); + fs::File::open(&staging)?.sync_all()?; + 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( + 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) +} + +#[cfg(test)] +mod tests { + use super::*; + + 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 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()); + } + + #[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.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/docs/design/views-v3-format.md b/docs/design/views-v3-format.md index f17d4436aa..244ebb82ae 100644 --- a/docs/design/views-v3-format.md +++ b/docs/design/views-v3-format.md @@ -658,6 +658,33 @@ contract is [views-slice4-opening.md](views-slice4-opening.md). | 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. + 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 assigned schema and encoding before publishing any such bytes. Registration 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..561609bd30 --- /dev/null +++ b/packages/aft-bridge/src/__tests__/view-token.test.ts @@ -0,0 +1,27 @@ +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, data: { 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, error: "views disabled", data: {} }, + { success: true, data: { 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..3303a5a032 100644 --- a/packages/aft-bridge/src/index.ts +++ b/packages/aft-bridge/src/index.ts @@ -100,6 +100,8 @@ export { readBinaryIdentity, writeStampedFileDigest, } from "./binary-identity.js"; +export { mintViewToken, type ViewToken } from "./view-token.js"; + export type { BashCompletedPayload, BashLongRunningPayload, diff --git a/packages/aft-bridge/src/view-token.ts b/packages/aft-bridge/src/view-token.ts new file mode 100644 index 0000000000..b2c4fbe345 --- /dev/null +++ b/packages/aft-bridge/src/view-token.ts @@ -0,0 +1,31 @@ +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(response.error ?? "View token mint failed"); + } + const value = response.data; + if ( + typeof value.token !== "string" || + typeof value.expires_ms !== "number" || + value.advisory !== true + ) { + throw new Error("Invalid advisory view token response"); + } + return value as unknown as ViewToken; +} From e0e98ad8fcc5d6cecbca167a22df8013eda35f1f Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Mon, 26 Oct 2026 20:33:56 +0100 Subject: [PATCH 029/100] mason: bootstrap fresh verified-bind drivers through v3-only registration Co-authored-by: alfonso-aft <289616620+alfonso-aft@users.noreply.github.com> --- crates/aft/src/blob_store/v2.rs | 14 + crates/aft/src/views/first_load.rs | 489 +++++++++++++++++- crates/aft/src/views/registry.rs | 171 ++++-- crates/aft/src/views/semantic_runtime.rs | 6 +- crates/aft/src/views/snapshot.rs | 13 + crates/aft/src/views/verification.rs | 18 +- crates/aft/src/watcher_backend/mod.rs | 1 + .../aft/src/watcher_backend/subscription.rs | 85 +++ crates/aft/src/watcher_filter.rs | 1 + 9 files changed, 728 insertions(+), 70 deletions(-) create mode 100644 crates/aft/src/watcher_backend/subscription.rs diff --git a/crates/aft/src/blob_store/v2.rs b/crates/aft/src/blob_store/v2.rs index ab8d71f328..9529b867bd 100644 --- a/crates/aft/src/blob_store/v2.rs +++ b/crates/aft/src/blob_store/v2.rs @@ -363,6 +363,7 @@ pub type StoreResult = Result; pub struct StoreWriteAccess { storage: PathBuf, family: String, + legacy_allowed: bool, } impl StoreWriteAccess { @@ -371,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 @@ -442,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)?; diff --git a/crates/aft/src/views/first_load.rs b/crates/aft/src/views/first_load.rs index 78eab56433..a6dd686002 100644 --- a/crates/aft/src/views/first_load.rs +++ b/crates/aft/src/views/first_load.rs @@ -1237,6 +1237,9 @@ mod tests { pub trait CompositePlane: Send + Sync { fn plane(&self) -> FamilyPlane; fn applies_to(&self, path: &RelPath) -> bool; + fn bootstrap_policy(&self) -> Option { + None + } /// Derive a payload key without extraction. Planes with deferred extraction /// may leave this absent and keep their own cheap attachment format. @@ -1333,6 +1336,13 @@ pub trait CompositePlane: Send + Sync { /// 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)] @@ -1372,6 +1382,7 @@ struct ObservedCheckout { stats: BTreeMap, evidence: BTreeMap, guard: Option>, + subscription: Option, } impl ObservedStat { @@ -1440,13 +1451,13 @@ mod stat_tests { } #[test] - fn verified_bind_hook_without_a_frontier_strictly_verifies_membership() { + 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, _) = driver.verified_bind(&access, base).unwrap(); + 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), @@ -1466,7 +1477,7 @@ mod stat_tests { std::fs::write(file, vec![b'x'; 20 + attempt]).unwrap(); })); let base = driver.installed_snapshot().generation().clone(); - let (snapshot, _) = driver.verified_bind(&access, base).unwrap(); + 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!( @@ -1610,6 +1621,7 @@ pub struct CheckoutDriver { 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)] @@ -1718,14 +1730,21 @@ impl CheckoutDriver { } /// Verifies this checkout against `base` and returns a reconciliation cut. - /// Unsupported freshness proofs use strict reconciliation instead. This - /// does not publish or install the cut; those steps still validate its epoch - /// and installed-bundle identity. The hook is independent of plane selection. + /// 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. 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", + )); + } if let Some(token) = self .advisory_token .lock() @@ -1747,11 +1766,189 @@ impl CheckoutDriver { now, ); } - self.v3_reconcile.store( - base.v3_route().is_some(), - std::sync::atomic::Ordering::Release, + let bootstrap = base.base_header().is_none(); + 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)?; + if self + .planes + .iter() + .all(|plane| plane.plane() == FamilyPlane::Trigram) + { + 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 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 + .planes + .iter() + .find_map(|plane| plane.bootstrap_policy()) + .ok_or_else(|| { + SiblingLoader::error("v3 bootstrap requires a registered lexical plane") + })?; + if self.producers.trigram != policy.fingerprint_hex() { + return Err(SiblingLoader::error( + "v3 bootstrap lexical producer mismatch", + )); + } + 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, + super::manifest_v2::EntryV2::regular(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()))?; + let resolved = + match super::base_refs::resolve_base(&route.storage, &route.family, &expected.scope) { + Ok(base) => base, + Err(_) => { + let paths: Vec<_> = manifest.entries().map(|(path, _)| path.clone()).collect(); + 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 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(), ); - SiblingLoader::reconciled_snapshot(self, access, base) + 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))) + } + + #[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) { @@ -1761,6 +1958,10 @@ impl CheckoutDriver { .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) } @@ -1815,6 +2016,7 @@ impl CheckoutDriver { 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 { @@ -1908,6 +2110,7 @@ impl CheckoutDriver { /// 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() @@ -2087,16 +2290,86 @@ impl FirstLoadDriver for CheckoutDriver { ) }) .unwrap_or_default(); - let mut entries = BTreeMap::new(); - let mut stats = BTreeMap::new(); - let mut evidence = BTreeMap::new(); + 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) + && !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()))?; @@ -2168,9 +2441,11 @@ impl FirstLoadDriver for CheckoutDriver { }) }) && self.planes.iter().filter(|p| p.applies_to(&path)).all(|p| { - record - .key(p.plane()) - .is_some_and(|key| v3_contains(&route, &key)) + 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 @@ -2277,6 +2552,7 @@ impl FirstLoadDriver for CheckoutDriver { stats, evidence, guard: guard.clone(), + subscription, }; let mut cut = installed.install_cut().cloned(); if let Some(cut) = &mut cut { @@ -2464,6 +2740,11 @@ impl FirstLoadDriver for CheckoutDriver { 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 .fetch_add(1, std::sync::atomic::Ordering::Relaxed); @@ -4090,7 +4371,7 @@ impl CompositePlane for CallgraphBridge { &crate::blob_store::CallgraphKey::from_source_digest( *content.as_bytes(), language, - crate::blob_store::CALLGRAPH_PRODUCER_VERSION, + super::callgraph::PRODUCER, ) .full_key(), )) @@ -4856,6 +5137,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)); @@ -4943,6 +5238,9 @@ impl TrigramBridge { } } impl CompositePlane for TrigramBridge { + fn bootstrap_policy(&self) -> Option { + Some(self.policy) + } fn plane(&self) -> FamilyPlane { FamilyPlane::Trigram } @@ -5423,3 +5721,152 @@ mod checkout_kind_tests { } } } + +#[cfg(test)] +mod v3_bootstrap_tests { + use super::*; + + fn inventory(root: &std::path::Path) -> BTreeMap> { + fn visit( + base: &std::path::Path, + path: &std::path::Path, + files: &mut BTreeMap>, + ) { + 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(); + 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 source = b"fn fresh_checkout() { let marker = 42; }\n"; + std::fs::write(root.path().join("file.rs"), source).unwrap(); + std::fs::File::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 = (inventory(&legacy_v1), inventory(&legacy_v2)); + 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], + ) + .unwrap(); + let access = driver.access(); + assert!(driver.owner.open_store(FamilyPlane::Trigram).is_err()); + assert_eq!((inventory(&legacy_v1), inventory(&legacy_v2)), 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()); + 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!((inventory(&legacy_v1), inventory(&legacy_v2)), 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" + ); + assert_eq!((inventory(&legacy_v1), inventory(&legacy_v2)), before); + } +} diff --git a/crates/aft/src/views/registry.rs b/crates/aft/src/views/registry.rs index 5ed7326166..beef416018 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()) } @@ -1084,6 +1182,9 @@ impl ViewRegistration { /// 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())); @@ -1211,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], + ); + } } } @@ -1723,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) diff --git a/crates/aft/src/views/semantic_runtime.rs b/crates/aft/src/views/semantic_runtime.rs index 3f6456efc7..4a3ad8ed59 100644 --- a/crates/aft/src/views/semantic_runtime.rs +++ b/crates/aft/src/views/semantic_runtime.rs @@ -728,11 +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() - .any(|(_, entry)| entry.reconciliation != super::readiness::ReconciledDelta::Reconciled) + snapshot.needs_reconcile() } /// Whether reconciling `snapshot` needs a cold-build slot. A reconcile walks diff --git a/crates/aft/src/views/snapshot.rs b/crates/aft/src/views/snapshot.rs index 6a3069034e..a932bf713c 100644 --- a/crates/aft/src/views/snapshot.rs +++ b/crates/aft/src/views/snapshot.rs @@ -821,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 + }), }), } } @@ -1665,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, @@ -1991,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/verification.rs b/crates/aft/src/views/verification.rs index eb0c74874b..4ece51f167 100644 --- a/crates/aft/src/views/verification.rs +++ b/crates/aft/src/views/verification.rs @@ -203,18 +203,14 @@ impl Policy { hash.update(b"aft/views-v3/conversion-policy/1"); let queries = crate::git_index_reader::GitQueries::new(root.to_path_buf(), Duration::from_secs(2)); - let output = queries.config()?; - if !output.status.success() { - return Ok(Self { - digest: "non-git".into(), - reusable: false, - }); - } - hash.update(&output.stdout); - let mut reusable = true; + let output = queries.config(); + 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); // Config --show-origin records include targets and worktree config. // Hash their bytes as well as the effective values, including empty files. - for item in output.stdout.split(|b| *b == 0) { + 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( @@ -360,6 +356,7 @@ pub(crate) struct BindGuard { pub index: Option<(NativeStat, [u8; 32])>, pub policy: Policy, pub inputs: Vec, + pub subscription: Option, } impl BindGuard { @@ -407,6 +404,7 @@ impl BindGuard { index, policy, inputs, + subscription: crate::watcher_backend::subscription::current(root), }) } pub fn validate(&self, root: &Path) -> io::Result<()> { diff --git a/crates/aft/src/watcher_backend/mod.rs b/crates/aft/src/watcher_backend/mod.rs index 1f901c54d6..795ccedf00 100644 --- a/crates/aft/src/watcher_backend/mod.rs +++ b/crates/aft/src/watcher_backend/mod.rs @@ -7,6 +7,7 @@ pub(crate) mod cookie; #[cfg(test)] mod cookie_tests; pub(crate) mod flush; +pub(crate) mod subscription; #[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..e1a9cb9399 --- /dev/null +++ b/crates/aft/src/watcher_backend/subscription.rs @@ -0,0 +1,85 @@ +//! 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}; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) struct Continuity { + pub generation: u64, + pub gap: u64, +} +struct State { + generation: u64, + gap: AtomicU64, +} +pub(crate) struct Subscription { + _state: Arc, +} +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) -> Subscription { + let state = Arc::new(State { + generation: NEXT.fetch_add(1, Ordering::Relaxed), + gap: AtomicU64::new(0), + }); + roots() + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .insert(root.to_path_buf(), Arc::downgrade(&state)); + Subscription { _state: state } +} + +pub(crate) fn current(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(Continuity { + generation: state.generation, + gap: state.gap.load(Ordering::Acquire), + }), + None => { + roots.remove(root); + None + } + } +} + +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 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()); + 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()); + 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 b91224fe79..0276a8e610 100644 --- a/crates/aft/src/watcher_filter.rs +++ b/crates/aft/src/watcher_filter.rs @@ -2293,6 +2293,7 @@ pub fn run_watcher_thread( return; } crate::slog_info!("watcher started: {}", root_path.display()); + let _subscription = crate::watcher_backend::subscription::start(&root_path); let mut filter = WatcherFilterThread::new( config, matcher, From b0908be89d9e5f6eabcc3ac6d0fd07d7f5cfee77 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Mon, 26 Oct 2026 20:35:12 +0100 Subject: [PATCH 030/100] mason: align advisory token wire types and current registry floor Co-authored-by: alfonso-aft <289616620+alfonso-aft@users.noreply.github.com> --- crates/aft/src/commands/view_token.rs | 20 +++++++++++++------ crates/aft/src/reader_floor.rs | 2 +- .../src/__tests__/view-token.test.ts | 6 +++--- packages/aft-bridge/src/view-token.ts | 8 ++++++-- 4 files changed, 24 insertions(+), 12 deletions(-) diff --git a/crates/aft/src/commands/view_token.rs b/crates/aft/src/commands/view_token.rs index 29416fb372..fb14ca48a2 100644 --- a/crates/aft/src/commands/view_token.rs +++ b/crates/aft/src/commands/view_token.rs @@ -8,21 +8,29 @@ use crate::{ pub fn handle_view_token(request: &RawRequest, context: &AppContext) -> Response { if !context.config().views.enabled { - return Response::error(&request.id, "view tokens require views to be 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, "root is required"); + 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, "expected_tree is required"); + 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 requires a configured repository"); + 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, &error.to_string()), + Err(error) => return Response::error(&request.id, "VIEW_TOKEN", &error.to_string()), }; let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) @@ -39,7 +47,7 @@ pub fn handle_view_token(request: &RawRequest, context: &AppContext) -> Response &request.id, serde_json::json!({ "token": token.token, "expires_ms": token.expires_ms, "advisory": true }), ), - Err(error) => Response::error(&request.id, &error.to_string()), + Err(error) => Response::error(&request.id, "VIEW_TOKEN", &error.to_string()), } } diff --git a/crates/aft/src/reader_floor.rs b/crates/aft/src/reader_floor.rs index 67c786e59d..04206c2b14 100644 --- a/crates/aft/src/reader_floor.rs +++ b/crates/aft/src/reader_floor.rs @@ -787,7 +787,7 @@ pub(crate) mod tests { let formats = persisted_format::formats_json(); // The registry reads the base-reference protocol (2/2); plane stores // keep format 1. Neither appears in the startup baseline floor. - for (name, version) in [("views_v3_registry", 2), ("views_v3_store", 1)] { + 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)); diff --git a/packages/aft-bridge/src/__tests__/view-token.test.ts b/packages/aft-bridge/src/__tests__/view-token.test.ts index 561609bd30..10e22dc51b 100644 --- a/packages/aft-bridge/src/__tests__/view-token.test.ts +++ b/packages/aft-bridge/src/__tests__/view-token.test.ts @@ -7,7 +7,7 @@ test("view token uses a management request, not an agent tool", async () => { const transport = { send: async (...args: unknown[]) => { calls.push(args); - return { success: true, data: { token: "opaque", expires_ms: 100, advisory: true } }; + return { success: true, token: "opaque", expires_ms: 100, advisory: true }; }, } as unknown as AftProjectTransport; expect(await mintViewToken(transport, "/future/root", "abc")).toEqual({ @@ -18,8 +18,8 @@ test("view token uses a management request, not an agent tool", async () => { test("view token refuses a non-advisory or failed response", async () => { for (const response of [ - { success: false, error: "views disabled", data: {} }, - { success: true, data: { token: "opaque", expires_ms: 100, advisory: false } }, + { 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/view-token.ts b/packages/aft-bridge/src/view-token.ts index b2c4fbe345..9f693acb12 100644 --- a/packages/aft-bridge/src/view-token.ts +++ b/packages/aft-bridge/src/view-token.ts @@ -17,9 +17,13 @@ export async function mintViewToken( expected_tree: expectedTree, }); if (!response.success) { - throw new Error(response.error ?? "View token mint failed"); + throw new Error(typeof response.message === "string" ? response.message : "View token mint failed"); } - const value = response.data; + 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" || From 3c93af3264704a782eb32328788682d85d0ebaff Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Mon, 26 Oct 2026 20:38:19 +0100 Subject: [PATCH 031/100] mason: use the public trigram attachment type in v3 materialization Co-authored-by: alfonso-aft <289616620+alfonso-aft@users.noreply.github.com> --- crates/aft/src/reader_floor.rs | 2 +- crates/aft/src/views/first_load.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/aft/src/reader_floor.rs b/crates/aft/src/reader_floor.rs index 04206c2b14..7b67b387ba 100644 --- a/crates/aft/src/reader_floor.rs +++ b/crates/aft/src/reader_floor.rs @@ -785,7 +785,7 @@ pub(crate) mod tests { #[test] fn v3_registration_is_advertised_but_not_prepared() { let formats = persisted_format::formats_json(); - // The registry reads the base-reference protocol (2/2); plane stores + // 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}"); diff --git a/crates/aft/src/views/first_load.rs b/crates/aft/src/views/first_load.rs index a6dd686002..2ca0f92d42 100644 --- a/crates/aft/src/views/first_load.rs +++ b/crates/aft/src/views/first_load.rs @@ -5323,7 +5323,7 @@ impl CompositePlane for TrigramBridge { .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::()) + .and_then(|a| a.downcast_ref::()) .filter(|a| a.content == *content && a.policy == self.policy.fingerprint()) .ok_or_else(|| { SiblingLoader::error( From 77d383a02f9f10be42530857890e98391d2cc2b6 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Mon, 26 Oct 2026 20:39:51 +0100 Subject: [PATCH 032/100] mason: keep stable-read stats distinct from persisted evidence rows Co-authored-by: alfonso-aft <289616620+alfonso-aft@users.noreply.github.com> --- crates/aft/src/views/first_load.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/aft/src/views/first_load.rs b/crates/aft/src/views/first_load.rs index 2ca0f92d42..6ec588c991 100644 --- a/crates/aft/src/views/first_load.rs +++ b/crates/aft/src/views/first_load.rs @@ -2477,7 +2477,7 @@ impl FirstLoadDriver for CheckoutDriver { } } } - let (bytes, evidence) = + let (bytes, stat_evidence) = match self.stable_read(&path, &absolute, recorded_ns, delivery_seq)? { StableRead::Absent => continue, StableRead::Bytes(bytes, evidence) => (bytes, evidence), @@ -2488,7 +2488,7 @@ impl FirstLoadDriver for CheckoutDriver { continue; } }; - stats.insert(path.clone(), evidence); + stats.insert(path.clone(), stat_evidence); let content = crate::blob_store::v2::ContentHash::of(&bytes); #[cfg(test)] { From 638be1a46dc6919f34cd1751d0bf5253e16830ca Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Mon, 26 Oct 2026 21:09:38 +0100 Subject: [PATCH 033/100] mason: recover overflow only after a strict walk of its exact epoch Co-authored-by: alfonso-aft <289616620+alfonso-aft@users.noreply.github.com> --- crates/aft/src/views/first_load.rs | 11 ++- .../first_load/overflow_recovery_tests.rs | 90 +++++++++++++++++++ 2 files changed, 100 insertions(+), 1 deletion(-) create mode 100644 crates/aft/src/views/first_load/overflow_recovery_tests.rs diff --git a/crates/aft/src/views/first_load.rs b/crates/aft/src/views/first_load.rs index 6ec588c991..a9e0ee7ca4 100644 --- a/crates/aft/src/views/first_load.rs +++ b/crates/aft/src/views/first_load.rs @@ -14,6 +14,10 @@ use super::snapshot::{ }; use super::RelPath; +#[cfg(test)] +#[path = "first_load/overflow_recovery_tests.rs"] +mod overflow_recovery_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. @@ -2195,7 +2199,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" ))), 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..57f60e3dbb --- /dev/null +++ b/crates/aft/src/views/first_load/overflow_recovery_tests.rs @@ -0,0 +1,90 @@ +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()); + 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 + ); +} From bf50a259510a53c305edb5019d6ab876774bcae2 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Mon, 26 Oct 2026 21:23:24 +0100 Subject: [PATCH 034/100] mason: close provenance gaps and guard steady refresh by subscription continuity Co-authored-by: alfonso-aft <289616620+alfonso-aft@users.noreply.github.com> --- crates/aft/src/git_index_reader.rs | 40 ++- crates/aft/src/views/first_load.rs | 256 +++++++++++++++++- crates/aft/src/views/io.rs | 3 + crates/aft/src/views/registry.rs | 63 ++++- crates/aft/src/views/verification.rs | 374 +++++++++++++++++++++++++-- docs/design/views-v3-format.md | 9 + 6 files changed, 705 insertions(+), 40 deletions(-) diff --git a/crates/aft/src/git_index_reader.rs b/crates/aft/src/git_index_reader.rs index 2356bea40e..4de1a275fd 100644 --- a/crates/aft/src/git_index_reader.rs +++ b/crates/aft/src/git_index_reader.rs @@ -1,8 +1,7 @@ //! Read-only Git index snapshots and counted, deadline-bound Git metadata queries. //! -//! Nothing in production routes through this module yet. A snapshot records only -//! the index bytes observed, not proof that worktree bytes are clean. Callers must -//! verify worktree content independently. +//! 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; @@ -549,6 +548,21 @@ impl GitQueries { 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"); @@ -583,6 +597,9 @@ fn pump( .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"); @@ -1288,3 +1305,20 @@ mod tests { 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/views/first_load.rs b/crates/aft/src/views/first_load.rs index a9e0ee7ca4..0e24c00831 100644 --- a/crates/aft/src/views/first_load.rs +++ b/crates/aft/src/views/first_load.rs @@ -978,7 +978,7 @@ mod tests { drop(generation); } - fn inventory(root: &std::path::Path) -> BTreeMap> { + fn legacy_inventory(storage: &std::path::Path) -> BTreeMap> { fn visit( root: &std::path::Path, dir: &std::path::Path, @@ -1364,9 +1364,9 @@ 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, @@ -1971,6 +1971,7 @@ impl CheckoutDriver { } pub fn installed_bundle(&self) -> Arc { + self.check_subscription(); self.installed .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) @@ -2332,6 +2333,7 @@ impl FirstLoadDriver for CheckoutDriver { .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) && !policy_or_directory; let changed_files = if continuous { self.walker.changed_files(self.owner.root(), &candidates)? @@ -3172,6 +3174,7 @@ impl QueryState for CheckoutDriver { access: &ViewAccess, plane: FamilyPlane, ) -> (Snapshot, Vec) { + self.check_subscription(); let installed = self .installed .lock() @@ -5369,6 +5372,28 @@ impl CompositePlane for TrigramBridge { } 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()) + .and_then(|guard| guard.subscription) + .is_some_and(|expected| { + crate::watcher_backend::subscription::current(self.owner.root()) + != Some(expected) + }) + }; + if stale { + self.record_watcher_overflow(); + } + } + fn source_stat( &self, path: &std::path::Path, @@ -5381,7 +5406,7 @@ impl CheckoutDriver { match std::fs::symlink_metadata(path) { Ok(metadata) if metadata.is_file() => { let mut stat = ObservedStat::new(&metadata, recorded_ns); - stat.native = super::verification::NativeStat::read(path).ok(); + stat.native = super::verification::NativeStat::strict_read(path).ok(); Ok(Some(stat)) } Ok(_) => Ok(None), @@ -5406,6 +5431,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) @@ -5735,12 +5761,152 @@ mod checkout_kind_tests { mod v3_bootstrap_tests { use super::*; + 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(), + ); + 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 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); + } + } + fn inventory(root: &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; } @@ -5757,7 +5923,8 @@ mod v3_bootstrap_tests { } } let mut files = BTreeMap::new(); - visit(root, root, &mut files); + let root = storage.join("blobs"); + visit(&root, &root, &mut files); files } @@ -5766,6 +5933,7 @@ mod v3_bootstrap_tests { 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 source = b"fn fresh_checkout() { let marker = 42; }\n"; std::fs::write(root.path().join("file.rs"), source).unwrap(); std::fs::File::open(root.path().join("file.rs")) @@ -5781,7 +5949,7 @@ mod v3_bootstrap_tests { std::fs::create_dir_all(dir).unwrap(); std::fs::write(dir.join("untouched"), b"legacy sentinel").unwrap(); } - let before = (inventory(&legacy_v1), inventory(&legacy_v2)); + let before = legacy_inventory(storage.path()); let policy = crate::blob_store::v2::TrigramPolicy { max_file_size: 1 << 20, }; @@ -5798,12 +5966,13 @@ mod v3_bootstrap_tests { root.path(), producers.clone(), Arc::new(ConfiguredMembershipWalker), - vec![plane], + vec![plane.clone()], ) - .unwrap(); + .unwrap() + .with_adapters(vec![plane.adapter.clone()]); let access = driver.access(); assert!(driver.owner.open_store(FamilyPlane::Trigram).is_err()); - assert_eq!((inventory(&legacy_v1), inventory(&legacy_v2)), before); + 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()); @@ -5820,6 +5989,10 @@ mod v3_bootstrap_tests { )); 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(); @@ -5832,7 +6005,7 @@ mod v3_bootstrap_tests { assert!( super::super::verification::evidence_path(&route.member_dir(), &generation).exists() ); - assert_eq!((inventory(&legacy_v1), inventory(&legacy_v2)), before); + assert_eq!(legacy_inventory(storage.path()), before); let ledger = crate::blob_store::v3::LedgerReader::open_existing(storage.path(), "family").unwrap(); assert_eq!( @@ -5876,6 +6049,65 @@ mod v3_bootstrap_tests { work.reads, 0, "generation-paired evidence avoids rereading stable source" ); - assert_eq!((inventory(&legacy_v1), inventory(&legacy_v2)), before); + 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/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/registry.rs b/crates/aft/src/views/registry.rs index beef416018..2ab2581a60 100644 --- a/crates/aft/src/views/registry.rs +++ b/crates/aft/src/views/registry.rs @@ -1625,8 +1625,8 @@ pub fn consume_view_token( Err(_) => return Ok(TokenUse::IdentityMismatch), }; let identities = match ( - super::verification::NativeStat::read(&root), - super::verification::NativeStat::read(&directory), + 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), @@ -1982,6 +1982,65 @@ 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(); diff --git a/crates/aft/src/views/verification.rs b/crates/aft/src/views/verification.rs index 4ece51f167..5521990ffb 100644 --- a/crates/aft/src/views/verification.rs +++ b/crates/aft/src/views/verification.rs @@ -10,6 +10,28 @@ 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)] @@ -24,6 +46,11 @@ pub(crate) enum FileIdentity { id: [u8; 16], creation: i64, }, + StrictOnly { + volume: u64, + index: u64, + birth: Option, + }, } #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] @@ -33,6 +60,7 @@ pub(crate) struct NativeStat { pub modified_ns: i128, pub changed_ns: i128, pub mode: u32, + pub reuse_supported: bool, } impl NativeStat { @@ -55,6 +83,7 @@ impl NativeStat { 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)] @@ -71,7 +100,69 @@ impl NativeStat { } } pub fn reusable(&self, current: &Self, observed_ns: u128) -> bool { - self == current && self.modified_ns >= 0 && (self.modified_ns as u128) < observed_ns + 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 } } @@ -144,6 +235,7 @@ fn windows_stat(path: &Path) -> io::Result { modified_ns: unix_ns(basic.LastWriteTime), changed_ns: unix_ns(basic.ChangeTime), mode: basic.FileAttributes, + reuse_supported: true, }) } @@ -199,15 +291,71 @@ fn metadata_source(path: &Path, hash: &mut blake3::Hasher) -> io::Result<()> { impl Policy { pub fn capture(root: &Path, metadata: &[PathBuf]) -> io::Result { - let mut hash = blake3::Hasher::new(); - hash.update(b"aft/views-v3/conversion-policy/1"); let queries = crate::git_index_reader::GitQueries::new(root.to_path_buf(), Duration::from_secs(2)); - let output = queries.config(); + #[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) { @@ -229,9 +377,28 @@ impl Policy { { reusable = false; } - if let Some(path) = item.strip_prefix(b"core.attributesfile\n") { - metadata_source(&path_from_bytes(path)?, &mut hash)?; - 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 { @@ -246,16 +413,6 @@ impl Policy { reusable = false; } } - if let Some(home) = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE")) { - for relative in [".gitattributes", ".config/git/attributes"] { - let path = PathBuf::from(&home).join(relative); - metadata_source(&path, &mut hash)?; - reusable &= !path.exists(); - } - } - let system = Path::new("/etc/gitattributes"); - metadata_source(system, &mut hash)?; - reusable &= !system.exists(); Ok(Self { digest: hash.finalize().to_hex().to_string(), reusable, @@ -289,7 +446,7 @@ pub(crate) fn now_ns() -> u128 { /// 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"] { + for name in [".gitattributes", ".gitignore", ".ignore", ".aftignore"] { out.push(root.join(name)); } for entry in fs::read_dir(root)? { @@ -324,6 +481,22 @@ pub(crate) fn policy_inputs(root: &Path) -> io::Result> { 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) @@ -365,10 +538,10 @@ impl BindGuard { Some(inputs) => inputs.to_vec(), None => policy_inputs(root)?, }; - let policy = Policy::capture(root, &inputs)?; - let root_identity = NativeStat::read(root)?.identity; + let mut policy = Policy::capture(root, &inputs)?; + let root_identity = NativeStat::strict_read(root)?.identity; let directory = match gitdir(root) { - Ok(path) => Some((path.clone(), NativeStat::read(&path)?.identity)), + Ok(path) => Some((path.clone(), NativeStat::strict_read(&path)?.identity)), Err(e) if e.kind() == io::ErrorKind::NotFound => None, Err(e) => return Err(e), }; @@ -384,14 +557,40 @@ impl BindGuard { head_tree = Some(head.stdout); } let path = directory.join("index"); - match NativeStat::read(&path) { + match NativeStat::strict_read(&path) { Ok(before) => { let bytes = fs::read(&path)?; - let after = NativeStat::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), @@ -540,6 +739,75 @@ pub(crate) fn read_evidence( 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) @@ -553,6 +821,60 @@ mod tests { ); } + #[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"]); + for (key, value) in [ + ("core.autocrlf", "true"), + ("core.eol", "crlf"), + ("core.symlinks", "false"), + ("core.ignorecase", "true"), + ("core.checkStat", "minimal"), + ("core.trustctime", "false"), + ("core.fileMode", "false"), + ] { + 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(); @@ -619,6 +941,9 @@ mod tests { 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] @@ -631,6 +956,9 @@ mod tests { 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(); diff --git a/docs/design/views-v3-format.md b/docs/design/views-v3-format.md index 244ebb82ae..734b0e2cfc 100644 --- a/docs/design/views-v3-format.md +++ b/docs/design/views-v3-format.md @@ -685,6 +685,15 @@ 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. +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 assigned schema and encoding before publishing any such bytes. Registration From 61503e87838839374cfeea59f0836fc9629349d9 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Mon, 26 Oct 2026 21:25:03 +0100 Subject: [PATCH 035/100] mason: cover view-token management dispatch without enabling views Co-authored-by: alfonso-aft <289616620+alfonso-aft@users.noreply.github.com> --- crates/aft/src/main.rs | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/crates/aft/src/main.rs b/crates/aft/src/main.rs index cf562ef377..e127411d73 100644 --- a/crates/aft/src/main.rs +++ b/crates/aft/src/main.rs @@ -3118,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, From 8d89b58a3811bc8c19f4d9c4cbc9f5ad17347397 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Mon, 26 Oct 2026 21:26:30 +0100 Subject: [PATCH 036/100] mason: preserve deterministic registration-walk test interception Co-authored-by: alfonso-aft <289616620+alfonso-aft@users.noreply.github.com> --- crates/aft/src/watcher_backend/inotify.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/aft/src/watcher_backend/inotify.rs b/crates/aft/src/watcher_backend/inotify.rs index bbcdc5b2f9..45f19cd054 100644 --- a/crates/aft/src/watcher_backend/inotify.rs +++ b/crates/aft/src/watcher_backend/inotify.rs @@ -746,6 +746,8 @@ fn register_initial_tree( return Err(notify::Error::path_not_found().add_path(directory)); } installed.insert(directory.clone()); + #[cfg(test)] + tests::block_watch_walk(&directory); for entry in crate::watcher_filter::exclusion_read_dir(&directory).map_err(notify::Error::io)? { From 6ca230cf3a89c6a5451c65e0b44699375b7854b3 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Mon, 26 Oct 2026 21:29:52 +0100 Subject: [PATCH 037/100] mason: return only validated advisory-token fields from bridge minting Co-authored-by: alfonso-aft <289616620+alfonso-aft@users.noreply.github.com> --- packages/aft-bridge/src/view-token.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/aft-bridge/src/view-token.ts b/packages/aft-bridge/src/view-token.ts index 9f693acb12..ac040a42a4 100644 --- a/packages/aft-bridge/src/view-token.ts +++ b/packages/aft-bridge/src/view-token.ts @@ -31,5 +31,5 @@ export async function mintViewToken( ) { throw new Error("Invalid advisory view token response"); } - return value as unknown as ViewToken; + return { token: value.token, expires_ms: value.expires_ms, advisory: true }; } From be2bbbe9a90bca301e7021990df8a366998ef7e3 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Mon, 26 Oct 2026 21:46:33 +0100 Subject: [PATCH 038/100] mason: keep legacy and v3 inventory test helpers in their own modules Co-authored-by: alfonso-aft <289616620+alfonso-aft@users.noreply.github.com> --- crates/aft/src/views/first_load.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/aft/src/views/first_load.rs b/crates/aft/src/views/first_load.rs index 0e24c00831..5f554bf340 100644 --- a/crates/aft/src/views/first_load.rs +++ b/crates/aft/src/views/first_load.rs @@ -978,7 +978,7 @@ mod tests { drop(generation); } - fn legacy_inventory(storage: &std::path::Path) -> BTreeMap> { + fn inventory(root: &std::path::Path) -> BTreeMap> { fn visit( root: &std::path::Path, dir: &std::path::Path, @@ -5898,7 +5898,7 @@ mod v3_bootstrap_tests { } } - fn inventory(root: &std::path::Path) -> BTreeMap> { + fn legacy_inventory(storage: &std::path::Path) -> BTreeMap> { fn visit( base: &std::path::Path, path: &std::path::Path, From 3825bb0fea5d13b044773c74469f843ee60d2db0 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Mon, 26 Oct 2026 21:46:34 +0100 Subject: [PATCH 039/100] mason: reclaim stat evidence through the protected generation sweep Co-authored-by: alfonso-aft <289616620+alfonso-aft@users.noreply.github.com> --- crates/aft/src/views/generation.rs | 69 +++++++- .../views/generation/stat_evidence_tests.rs | 155 ++++++++++++++++++ crates/aft/src/views/verification.rs | 7 +- docs/design/views-v3-format.md | 11 ++ 4 files changed, 234 insertions(+), 8 deletions(-) create mode 100644 crates/aft/src/views/generation/stat_evidence_tests.rs diff --git a/crates/aft/src/views/generation.rs b/crates/aft/src/views/generation.rs index 0520263bf2..c955735362 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 { @@ -820,7 +867,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 +903,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); @@ -912,9 +961,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 +1121,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 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..3bc62b70e3 --- /dev/null +++ b/crates/aft/src/views/generation/stat_evidence_tests.rs @@ -0,0 +1,155 @@ +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(); + assert_eq!( + view.publish_v2(&generation, previous.as_deref(), &manifest, 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::File::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/verification.rs b/crates/aft/src/views/verification.rs index 5521990ffb..ad5fdbbda6 100644 --- a/crates/aft/src/views/verification.rs +++ b/crates/aft/src/views/verification.rs @@ -625,8 +625,9 @@ pub(crate) fn evidence_path(dir: &Path, generation: &str) -> PathBuf { dir.join(format!("stat-evidence-{generation}.sqlite")) } -/// Writes the evidence before the CAS. Its generation and content digests are -/// checked on read, so an unpublished or obsolete sidecar supplies no proof. +/// 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, @@ -644,7 +645,7 @@ pub(crate) fn write_evidence( let dir = route.member_dir(); let destination = evidence_path(&dir, generation); let staging = dir.join(format!( - "stat-evidence-{}.staging", + ".stat-evidence-{generation}.sqlite.tmp.{}", super::base_refs::fresh_nonce() )); let result = (|| -> Result<(), Box> { diff --git a/docs/design/views-v3-format.md b/docs/design/views-v3-format.md index 734b0e2cfc..0cf7312021 100644 --- a/docs/design/views-v3-format.md +++ b/docs/design/views-v3-format.md @@ -685,6 +685,17 @@ 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 From d0fdd01fa0352c6eeb8143ba8238049f4de84d2e Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Mon, 26 Oct 2026 21:54:02 +0100 Subject: [PATCH 040/100] mason: refuse initial coverage when the root disappears before registration Co-authored-by: alfonso-aft <289616620+alfonso-aft@users.noreply.github.com> --- crates/aft/src/watcher_backend/inotify.rs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/crates/aft/src/watcher_backend/inotify.rs b/crates/aft/src/watcher_backend/inotify.rs index 45f19cd054..8625a0e2c7 100644 --- a/crates/aft/src/watcher_backend/inotify.rs +++ b/crates/aft/src/watcher_backend/inotify.rs @@ -757,6 +757,9 @@ fn register_initial_tree( } } } + if !installed.contains(root) { + return Err(notify::Error::path_not_found().add_path(root.to_path_buf())); + } Ok(installed) } @@ -898,6 +901,19 @@ pub(super) mod tests { } } + #[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 watcher_cookie_inotify_recovery_precedes_root_cookie_without_command_tracking() { let temp = tempfile::tempdir().unwrap(); From b05d949824108551ec215cec165a8fc1f5a7d1b9 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Mon, 26 Oct 2026 21:56:48 +0100 Subject: [PATCH 041/100] mason: publish sweep fixtures through the prepared generation CAS Co-authored-by: alfonso-aft <289616620+alfonso-aft@users.noreply.github.com> --- crates/aft/src/views/generation/stat_evidence_tests.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/crates/aft/src/views/generation/stat_evidence_tests.rs b/crates/aft/src/views/generation/stat_evidence_tests.rs index 3bc62b70e3..82efee0d6a 100644 --- a/crates/aft/src/views/generation/stat_evidence_tests.rs +++ b/crates/aft/src/views/generation/stat_evidence_tests.rs @@ -33,9 +33,11 @@ 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.publish_v2(&generation, previous.as_deref(), &manifest, None) - .unwrap(), + view.commit_v2(prepared, None).unwrap(), PublishV2::Published ); wait_for_generation_sweep_for_test(view.view_dir()); From 3747fb0b44b44e33e079049e8f0e3a7347b4f623 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Mon, 26 Oct 2026 22:03:51 +0100 Subject: [PATCH 042/100] mason: open writable handles for portable timestamp fixtures Co-authored-by: alfonso-aft <289616620+alfonso-aft@users.noreply.github.com> --- crates/aft/src/views/first_load.rs | 4 +++- crates/aft/src/views/generation/stat_evidence_tests.rs | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/crates/aft/src/views/first_load.rs b/crates/aft/src/views/first_load.rs index 5f554bf340..cfb610c967 100644 --- a/crates/aft/src/views/first_load.rs +++ b/crates/aft/src/views/first_load.rs @@ -5936,7 +5936,9 @@ mod v3_bootstrap_tests { let _attributes = super::super::verification::known_attribute_sources_for_test(root.path()); let source = b"fn fresh_checkout() { let marker = 42; }\n"; std::fs::write(root.path().join("file.rs"), source).unwrap(); - std::fs::File::open(root.path().join("file.rs")) + std::fs::OpenOptions::new() + .write(true) + .open(root.path().join("file.rs")) .unwrap() .set_times( std::fs::FileTimes::new() diff --git a/crates/aft/src/views/generation/stat_evidence_tests.rs b/crates/aft/src/views/generation/stat_evidence_tests.rs index 82efee0d6a..9ec8bbbde5 100644 --- a/crates/aft/src/views/generation/stat_evidence_tests.rs +++ b/crates/aft/src/views/generation/stat_evidence_tests.rs @@ -91,7 +91,9 @@ fn stat_evidence_staging_never_ages_out_under_a_prepared_generation_pin() { .unwrap(); let staging = stage(&view, &generation.to_string(), "crashed-writer"); fs::write(&staging, b"partly written database").unwrap(); - fs::File::open(&staging) + fs::OpenOptions::new() + .write(true) + .open(&staging) .unwrap() .set_times( fs::FileTimes::new().set_modified(std::time::UNIX_EPOCH + Duration::from_secs(60)), From 9d40040453eb405ab192455bfb520b3fc006cf54 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Mon, 26 Oct 2026 22:16:51 +0100 Subject: [PATCH 043/100] mason: preserve canonical watcher gaps and require live steady subscriptions Keep the sparse-v3 fixture's sparse/restart/no-v2 assertions unchanged, but establish a real reconciled and installed driver cut before publication. Merely naming its generation installed-worker no longer satisfies the final CAS identity check. Co-authored-by: alfonso-aft <289616620+alfonso-aft@users.noreply.github.com> --- crates/aft/src/views/first_load.rs | 52 ++++++++++++++++++++++++++---- 1 file changed, 46 insertions(+), 6 deletions(-) diff --git a/crates/aft/src/views/first_load.rs b/crates/aft/src/views/first_load.rs index cfb610c967..02c255402c 100644 --- a/crates/aft/src/views/first_load.rs +++ b/crates/aft/src/views/first_load.rs @@ -2095,8 +2095,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()); - // A known path cannot repair an earlier stream-wide delivery gap. - delta.set_watcher(installed.snapshot.watcher()); + // One known-path event cannot prove that delivery remained continuous + // for the whole watcher stream. installed.snapshot = delta .snapshot() .with_readers(installed.readers.clone()) @@ -4216,6 +4216,17 @@ mod composite_tests { ), ); 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(); @@ -5383,10 +5394,10 @@ impl CheckoutDriver { .snapshot .install_cut() .and_then(|cut| cut.verification.as_ref()) - .and_then(|guard| guard.subscription) - .is_some_and(|expected| { - crate::watcher_backend::subscription::current(self.owner.root()) - != Some(expected) + .is_some_and(|guard| { + guard.subscription.is_none() + || crate::watcher_backend::subscription::current(self.owner.root()) + != guard.subscription }) }; if stale { @@ -5838,6 +5849,32 @@ mod v3_bootstrap_tests { ); } + #[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(), + ); + 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()); + } + #[test] fn v3_lazy_attachments_probe_the_actual_producer_key_before_extraction() { let _serial = crate::blob_store::v3::serial_for_test(); @@ -5934,6 +5971,9 @@ mod v3_bootstrap_tests { 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(), + ); let source = b"fn fresh_checkout() { let marker = 42; }\n"; std::fs::write(root.path().join("file.rs"), source).unwrap(); std::fs::OpenOptions::new() From 4cb0779b943588615b575a22690d335031e29a51 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Mon, 26 Oct 2026 22:16:53 +0100 Subject: [PATCH 044/100] mason: skip source admission probes for already reconciled deltas Co-authored-by: alfonso-aft <289616620+alfonso-aft@users.noreply.github.com> --- crates/aft/src/views/semantic_runtime.rs | 5 +++++ crates/aft/src/views/semantic_runtime_tests.rs | 4 ++++ 2 files changed, 9 insertions(+) diff --git a/crates/aft/src/views/semantic_runtime.rs b/crates/aft/src/views/semantic_runtime.rs index 4a3ad8ed59..290bd149f8 100644 --- a/crates/aft/src/views/semantic_runtime.rs +++ b/crates/aft/src/views/semantic_runtime.rs @@ -748,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 86b15bc598..e5894deb72 100644 --- a/crates/aft/src/views/semantic_runtime_tests.rs +++ b/crates/aft/src/views/semantic_runtime_tests.rs @@ -37,8 +37,12 @@ fn reconciled_delta_does_not_request_another_source_walk() { entry.reconciliation = super::super::readiness::ReconciledDelta::Reconciled; delta.apply(path.clone(), entry); assert!(!super::needs_reload(&delta.snapshot())); + let root = tempfile::tempdir().unwrap(); + let missing = root.path().join("source-probe-must-not-run"); + assert!(!super::needs_cold_reconcile(&delta.snapshot(), &missing)); delta.record_intent(path); assert!(super::needs_reload(&delta.snapshot())); + assert!(super::needs_cold_reconcile(&delta.snapshot(), &missing)); } /// The test prints public setup, query answers, a v3 file inventory and exact From 16716b7462a373b2d8709d76f7296e76ad7f9012 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Mon, 26 Oct 2026 22:23:28 +0100 Subject: [PATCH 045/100] mason: keep unsupported roots out of watcher-trusted steady refresh Co-authored-by: alfonso-aft <289616620+alfonso-aft@users.noreply.github.com> --- crates/aft/src/views/first_load.rs | 16 ++++++++++++++-- crates/aft/src/views/verification.rs | 9 ++++++++- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/crates/aft/src/views/first_load.rs b/crates/aft/src/views/first_load.rs index 02c255402c..47ed534c83 100644 --- a/crates/aft/src/views/first_load.rs +++ b/crates/aft/src/views/first_load.rs @@ -2333,7 +2333,9 @@ impl FirstLoadDriver for CheckoutDriver { .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 + .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)? @@ -5395,7 +5397,8 @@ impl CheckoutDriver { .install_cut() .and_then(|cut| cut.verification.as_ref()) .is_some_and(|guard| { - guard.subscription.is_none() + !guard.steady_supported + || guard.subscription.is_none() || crate::watcher_backend::subscription::current(self.owner.root()) != guard.subscription }) @@ -5873,6 +5876,15 @@ mod v3_bootstrap_tests { 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] diff --git a/crates/aft/src/views/verification.rs b/crates/aft/src/views/verification.rs index ad5fdbbda6..918ccde52a 100644 --- a/crates/aft/src/views/verification.rs +++ b/crates/aft/src/views/verification.rs @@ -530,6 +530,9 @@ pub(crate) struct BindGuard { pub policy: Policy, pub inputs: Vec, pub subscription: 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 { @@ -539,7 +542,10 @@ impl BindGuard { None => policy_inputs(root)?, }; let mut policy = Policy::capture(root, &inputs)?; - let root_identity = NativeStat::strict_read(root)?.identity; + let root_stat = NativeStat::strict_read(root)?; + let steady_supported = root_stat.reuse_supported + && cfg!(any(target_os = "linux", target_os = "macos", windows)); + 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, @@ -604,6 +610,7 @@ impl BindGuard { policy, inputs, subscription: crate::watcher_backend::subscription::current(root), + steady_supported, }) } pub fn validate(&self, root: &Path) -> io::Result<()> { From 2fe473180189ecf258c69f8541916c08da05ae4b Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Mon, 26 Oct 2026 23:53:00 +0100 Subject: [PATCH 046/100] mason: fence v3 final validation and CAS with driver ownership Keep artifact construction and admission outside the ownership fence. Preserve the old publication helper as an unfenced delegate, serialize the driver's installed-bundle transition with validate+CAS, and release a completed lexical-only bootstrap claim even when publication is refused. Co-authored-by: alfonso-aft <289616620+alfonso-aft@users.noreply.github.com> --- crates/aft/src/views/base_refs.rs | 52 ++++- crates/aft/src/views/first_load.rs | 37 +++- .../first_load/publication_fence_tests.rs | 191 ++++++++++++++++++ 3 files changed, 273 insertions(+), 7 deletions(-) create mode 100644 crates/aft/src/views/first_load/publication_fence_tests.rs diff --git a/crates/aft/src/views/base_refs.rs b/crates/aft/src/views/base_refs.rs index 4b2d5f6c6c..27f7029358 100644 --- a/crates/aft/src/views/base_refs.rs +++ b/crates/aft/src/views/base_refs.rs @@ -696,6 +696,25 @@ pub fn publish_worker_with( 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 short 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, @@ -850,16 +869,39 @@ pub fn publish_worker_with( if let Err(error) = verified { return Err(abort(error, marker)); } - if let Err(error) = final_validate() { - return Err(abort(error, marker)); - } // (4) The CAS. From here on the reference may be durable. - let outcome = worker_store.commit_v2(prepared, observer); + 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(super::ViewError::Io(error)), + Some(error) => Err(error.into()), None => outcome, }; let landed = match outcome { diff --git a/crates/aft/src/views/first_load.rs b/crates/aft/src/views/first_load.rs index 47ed534c83..ea20fe8fab 100644 --- a/crates/aft/src/views/first_load.rs +++ b/crates/aft/src/views/first_load.rs @@ -18,6 +18,10 @@ use super::RelPath; #[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. @@ -1782,7 +1786,7 @@ impl CheckoutDriver { if !bootstrap { return Ok((snapshot, revision)); } - let own = self.build_own_generation(access, &snapshot, DERIVED_CALLGRAPH_SEEDING)?; + let own = self.build_own_generation(access, &snapshot, DERIVED_CALLGRAPH_SEEDING); if self .planes .iter() @@ -1799,6 +1803,7 @@ impl CheckoutDriver { .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(), @@ -3078,7 +3083,12 @@ impl CheckoutDriver { .flatten(), }; let planes = &self.planes; - let outcome = super::base_refs::publish_worker_with( + 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, @@ -3124,6 +3134,29 @@ impl CheckoutDriver { 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 { 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 + ); +} From 2a2ffa2ed7fd1f7735bbf130849ad06c9e0706a7 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Tue, 27 Oct 2026 00:16:11 +0100 Subject: [PATCH 047/100] mason: retain partial shared watchers with typed untrusted coverage Preserve views-off startup when a descendant cannot be listed or watched. Initial coverage now names gaps on the retained native handle; v3 trust refuses incomplete or opaque handles. This intentionally replaces the temporary whole-watcher-refusal test with partial-service and strict-v3 controls, per the compatibility ruling. Configure's generic attachment API is unchanged. Co-authored-by: alfonso-aft <289616620+alfonso-aft@users.noreply.github.com> --- crates/aft/src/views/first_load.rs | 3 + .../first_load/overflow_recovery_tests.rs | 6 +- crates/aft/src/views/verification.rs | 10 +- crates/aft/src/watcher_backend/inotify.rs | 279 ++++++++++++++++-- crates/aft/src/watcher_backend/mod.rs | 54 ++++ .../aft/src/watcher_backend/subscription.rs | 86 +++++- crates/aft/src/watcher_filter.rs | 5 +- 7 files changed, 404 insertions(+), 39 deletions(-) diff --git a/crates/aft/src/views/first_load.rs b/crates/aft/src/views/first_load.rs index ea20fe8fab..6bae55a45c 100644 --- a/crates/aft/src/views/first_load.rs +++ b/crates/aft/src/views/first_load.rs @@ -5841,6 +5841,7 @@ mod v3_bootstrap_tests { } 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(); @@ -5902,6 +5903,7 @@ mod v3_bootstrap_tests { 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()) @@ -6018,6 +6020,7 @@ mod v3_bootstrap_tests { 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(); diff --git a/crates/aft/src/views/first_load/overflow_recovery_tests.rs b/crates/aft/src/views/first_load/overflow_recovery_tests.rs index 57f60e3dbb..5aa2f2d39a 100644 --- a/crates/aft/src/views/first_load/overflow_recovery_tests.rs +++ b/crates/aft/src/views/first_load/overflow_recovery_tests.rs @@ -10,8 +10,10 @@ fn fixture() -> ( 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()); + 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, }; diff --git a/crates/aft/src/views/verification.rs b/crates/aft/src/views/verification.rs index 918ccde52a..0f53132c47 100644 --- a/crates/aft/src/views/verification.rs +++ b/crates/aft/src/views/verification.rs @@ -530,6 +530,7 @@ pub(crate) struct BindGuard { 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, @@ -543,8 +544,12 @@ impl BindGuard { }; 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)); + && 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)), @@ -609,7 +614,8 @@ impl BindGuard { index, policy, inputs, - subscription: crate::watcher_backend::subscription::current(root), + subscription: registration.as_ref().map(|status| status.continuity), + initial_coverage: registration.map(|status| status.coverage), steady_supported, }) } diff --git a/crates/aft/src/watcher_backend/inotify.rs b/crates/aft/src/watcher_backend/inotify.rs index 8625a0e2c7..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, @@ -122,18 +126,56 @@ impl ProjectWatcher { let boundary = exclusion_state .boundary() .ok_or_else(|| notify::Error::path_not_found().add_path(root.clone()))?; - let installed = - register_initial_tree(boundary, &matcher, &exclusion_paths, &mut |directory| { - watch_directory(&mut watcher, directory, &mut watch, &counters) - })?; + 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; } } } @@ -452,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) + }, }) } @@ -494,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, @@ -508,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) @@ -722,45 +773,119 @@ impl DirectoryIdentity { } } -/// Arm each parent before listing it. Children created during registration -/// are then either enumerated or reported on the already armed parent stream. +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> { +) -> 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) - || !boundary.allows_directory(&directory) { continue; } - if !watch(&directory)? { - return Err(notify::Error::path_not_found().add_path(directory)); + 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); - for entry in - crate::watcher_filter::exclusion_read_dir(&directory).map_err(notify::Error::io)? - { - let entry = entry.map_err(notify::Error::io)?; - if entry.file_type().map_err(notify::Error::io)?.is_dir() { - stack.push(entry.path()); + #[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(installed) + Ok(InitialRegistration { + watched: installed, + gaps, + }) } fn collect_watch_directories( @@ -841,6 +966,22 @@ 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(); @@ -871,11 +1012,12 @@ pub(super) mod tests { root.join("during/nested") ] ); - assert_eq!(installed.len(), 3); + assert_eq!(installed.watched.len(), 3); + assert!(installed.gaps.is_empty()); } #[test] - fn initial_subscription_refuses_a_missing_watch_or_incomplete_listing() { + 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(); @@ -897,7 +1039,12 @@ pub(super) mod tests { } }, ); - assert!(result.is_err()); + let registration = result.unwrap(); + assert!(registration.watched.contains(&root)); + assert!(registration + .gaps + .iter() + .any(|gap| gap.path == root.join("child"))); } } @@ -914,6 +1061,88 @@ pub(super) mod tests { 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(); diff --git a/crates/aft/src/watcher_backend/mod.rs b/crates/aft/src/watcher_backend/mod.rs index 795ccedf00..b372e42284 100644 --- a/crates/aft/src/watcher_backend/mod.rs +++ b/crates/aft/src/watcher_backend/mod.rs @@ -9,6 +9,60 @@ 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; #[cfg(target_os = "linux")] diff --git a/crates/aft/src/watcher_backend/subscription.rs b/crates/aft/src/watcher_backend/subscription.rs index e1a9cb9399..64e88a30ca 100644 --- a/crates/aft/src/watcher_backend/subscription.rs +++ b/crates/aft/src/watcher_backend/subscription.rs @@ -7,6 +7,8 @@ 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, @@ -15,9 +17,30 @@ pub(crate) struct Continuity { 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>> { @@ -25,27 +48,28 @@ fn roots() -> &'static Mutex>> { ROOTS.get_or_init(Default::default) } -pub(crate) fn start(root: &Path) -> Subscription { +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 } + Subscription { + _state: state, + root: root.to_path_buf(), + } } -pub(crate) fn current(root: &Path) -> Option { +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(Continuity { - generation: state.generation, - gap: state.gap.load(Ordering::Acquire), - }), + Some(state) => Some(state), None => { roots.remove(root); None @@ -53,6 +77,25 @@ pub(crate) fn current(root: &Path) -> Option { } } +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() @@ -66,15 +109,40 @@ pub(crate) fn gap(root: &Path) { #[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()); + 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()); + let second = super::start(root.path(), super::InitialCoverage::Complete); let after = super::current(root.path()).unwrap(); assert_ne!(before.generation, after.generation); drop(first); diff --git a/crates/aft/src/watcher_filter.rs b/crates/aft/src/watcher_filter.rs index 0276a8e610..c76df5b841 100644 --- a/crates/aft/src/watcher_filter.rs +++ b/crates/aft/src/watcher_filter.rs @@ -2293,7 +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); + let _subscription = crate::watcher_backend::subscription::start( + &root_path, + crate::watcher_backend::initial_coverage_of(&_watcher), + ); let mut filter = WatcherFilterThread::new( config, matcher, From 960ac07c2bd590bd0d8e4dd7a14ba53ae1ec5235 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Tue, 27 Oct 2026 00:16:47 +0100 Subject: [PATCH 048/100] mason: refuse unowned fresh planes and complete source metadata guards Fresh bootstrap is lexical-only until the G/H base completion coordinators exist; typed preflight refuses unsupported planes before reads or claims. Existing bases remain plane-independent. Reuse the source stamp already taken before the read instead of adding a duplicate stat, and preserve resolution-input metadata on cached callgraph attachments. Co-authored-by: alfonso-aft <289616620+alfonso-aft@users.noreply.github.com> --- crates/aft/src/views/base_refs.rs | 2 +- crates/aft/src/views/first_load.rs | 327 +++++++++++++++++++++++++++-- 2 files changed, 312 insertions(+), 17 deletions(-) diff --git a/crates/aft/src/views/base_refs.rs b/crates/aft/src/views/base_refs.rs index 27f7029358..eb700352f9 100644 --- a/crates/aft/src/views/base_refs.rs +++ b/crates/aft/src/views/base_refs.rs @@ -708,7 +708,7 @@ pub fn publish_worker_with( /// 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 short fence. +/// once. Artifact construction and admission happen before this fence. pub fn publish_worker_guarded( request: WorkerPublication<'_>, observer: Option<&dyn DurabilityObserver>, diff --git a/crates/aft/src/views/first_load.rs b/crates/aft/src/views/first_load.rs index 6bae55a45c..b33b572a4e 100644 --- a/crates/aft/src/views/first_load.rs +++ b/crates/aft/src/views/first_load.rs @@ -1676,13 +1676,72 @@ pub(crate) struct ReconcileWork { pub attachments: 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() + .map(|plane| plane.plane()) + .find(|plane| *plane != FamilyPlane::Trigram) + { + return Err(FreshBootstrapRefusal::NonLexicalPlane(plane)); + } + if self.producers.semantic.is_some() { + return Err(FreshBootstrapRefusal::NonLexicalPlane( + FamilyPlane::Semantic, + )); + } + if self.producers.callgraph != super::semantic_runtime::UNREGISTERED_PRODUCER { + 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 { @@ -1693,7 +1752,11 @@ impl CheckoutDriver { .unwrap_or_else(std::sync::PoisonError::into_inner) .seq }; - let before = self.source_stat(absolute, recorded_ns)?; + 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)] @@ -1742,6 +1805,8 @@ impl CheckoutDriver { /// 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, @@ -1753,6 +1818,15 @@ impl CheckoutDriver { "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() @@ -1774,7 +1848,6 @@ impl CheckoutDriver { now, ); } - let bootstrap = base.base_header().is_none(); self.v3_reconcile .store(true, std::sync::atomic::Ordering::Release); let base = if bootstrap { @@ -1832,17 +1905,8 @@ impl CheckoutDriver { seed: Arc, ) -> Result, PlaneError> { let policy = self - .planes - .iter() - .find_map(|plane| plane.bootstrap_policy()) - .ok_or_else(|| { - SiblingLoader::error("v3 bootstrap requires a registered lexical plane") - })?; - if self.producers.trigram != policy.fingerprint_hex() { - return Err(SiblingLoader::error( - "v3 bootstrap lexical producer mismatch", - )); - } + .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( @@ -2496,7 +2560,7 @@ impl FirstLoadDriver for CheckoutDriver { } } let (bytes, stat_evidence) = - match self.stable_read(&path, &absolute, recorded_ns, delivery_seq)? { + match self.stable_read(&path, &absolute, recorded_ns, delivery_seq, before)? { StableRead::Absent => continue, StableRead::Bytes(bytes, evidence) => (bytes, evidence), StableRead::Pending => { @@ -4619,10 +4683,14 @@ impl CompositePlane for CallgraphBridge { "cached callgraph row disappeared before protection".into(), )); } - if let Some(super::manifest_v2::EntryV2::Regular { planes, .. }) = - delta.get_mut(path) + 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; } @@ -5367,6 +5435,9 @@ impl CompositePlane for TrigramBridge { 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() { @@ -5808,6 +5879,112 @@ mod checkout_kind_tests { 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, @@ -5979,9 +6156,127 @@ mod v3_bootstrap_tests { 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, From 908f4852c9ef293fe263f95294f4eba59c2e7aa3 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Tue, 27 Oct 2026 00:16:49 +0100 Subject: [PATCH 049/100] mason: keep newer-registry refusal tests above the supported format Registry format 3 is now supported. Probe the compiled maximum plus one so the existing newer-format refusal assertion remains a newer-format test rather than schema drift within a supported version. Co-authored-by: alfonso-aft <289616620+alfonso-aft@users.noreply.github.com> --- crates/aft/src/blob_store/v3/tests.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/aft/src/blob_store/v3/tests.rs b/crates/aft/src/blob_store/v3/tests.rs index fc905f832a..d568cee51e 100644 --- a/crates/aft/src/blob_store/v3/tests.rs +++ b/crates/aft/src/blob_store/v3/tests.rs @@ -859,15 +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() - // Registry 2 is the base-reference protocol this build reads; the - // first newer version is 3. - .execute("UPDATE format_meta SET format_version=3", []) + // 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, 3); + assert_eq!(error.found, u64::from(future)); assert_eq!(error.source, persisted_format::RefusalSource::Artifact); } error => panic!("{error}"), From 32fa34792d131cc48fbb9ebeaf65788e87a6a23b Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Tue, 27 Oct 2026 00:26:20 +0100 Subject: [PATCH 050/100] mason: make the source-probe control exercise a nonregular path Missing paths intentionally cost zero bytes, so the earlier missing-file fixture could not distinguish a skipped probe. Keep the same assertions but use a directory at the live file path: the probe must classify it as large after an intent, and must not run for an already reconciled delta. Co-authored-by: alfonso-aft <289616620+alfonso-aft@users.noreply.github.com> --- crates/aft/src/views/semantic_runtime_tests.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/aft/src/views/semantic_runtime_tests.rs b/crates/aft/src/views/semantic_runtime_tests.rs index e5894deb72..cc03105d59 100644 --- a/crates/aft/src/views/semantic_runtime_tests.rs +++ b/crates/aft/src/views/semantic_runtime_tests.rs @@ -38,11 +38,11 @@ fn reconciled_delta_does_not_request_another_source_walk() { delta.apply(path.clone(), entry); assert!(!super::needs_reload(&delta.snapshot())); let root = tempfile::tempdir().unwrap(); - let missing = root.path().join("source-probe-must-not-run"); - assert!(!super::needs_cold_reconcile(&delta.snapshot(), &missing)); + 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(), &missing)); + assert!(super::needs_cold_reconcile(&delta.snapshot(), root.path())); } /// The test prints public setup, query answers, a v3 file inventory and exact From 9f9310ee3e3622425b6554273d3c728c40828bd9 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Mon, 26 Oct 2026 20:03:21 +0100 Subject: [PATCH 051/100] mason: route opt-in lexical queries through checkout v3 views Co-authored-by: alfonso-aft <289616620+alfonso-aft@users.noreply.github.com> --- .../views-f-lexical-routing.json | 15 + crates/aft/src/commands/configure.rs | 10 +- crates/aft/src/commands/glob.rs | 9 +- .../commands/semantic_search/exact_lane.rs | 34 ++- .../aft/src/commands/semantic_search/mod.rs | 158 +++++++---- crates/aft/src/context.rs | 93 ++++++ crates/aft/src/feature_status.rs | 7 + crates/aft/src/grep_executor.rs | 18 +- crates/aft/src/runtime_drain.rs | 15 +- crates/aft/src/search_index.rs | 55 ++++ crates/aft/src/views/trigram.rs | 267 +++++++++++++++++- 11 files changed, 611 insertions(+), 70 deletions(-) create mode 100644 benchmarks/aft-search/slice-descriptors/views-f-lexical-routing.json diff --git a/benchmarks/aft-search/slice-descriptors/views-f-lexical-routing.json b/benchmarks/aft-search/slice-descriptors/views-f-lexical-routing.json new file mode 100644 index 0000000000..d2ac17aff2 --- /dev/null +++ b/benchmarks/aft-search/slice-descriptors/views-f-lexical-routing.json @@ -0,0 +1,15 @@ +{ + "slice_class": "ranking", + "kind": "paging", + "targeted_mechanism": "none", + "fixtures": [ + "views::trigram::tests::lexical_views_projection_matches_legacy_masks_ranks_and_membership", + "lexical_views_public" + ], + "fenced_files": [ + "crates/aft/src/search_index.rs", + "crates/aft/src/commands/semantic_search/mod.rs", + "crates/aft/src/commands/semantic_search/exact_lane.rs" + ], + "note": "Views-on lexical acquisition projects verified v3 payloads into the existing read-only query engine. Views-off acquisition, scoring, fusion, admission and paging rules are unchanged. Parity rows, including rank order, must remain byte-identical; this descriptor does not waive any quality difference. Platform measurements and the release-binary quality replay are deferred to the views measurement step." +} diff --git a/crates/aft/src/commands/configure.rs b/crates/aft/src/commands/configure.rs index 6fb5bf52eb..8f88a375bb 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() diff --git a/crates/aft/src/commands/glob.rs b/crates/aft/src/commands/glob.rs index 8f7b82e05a..3dba4e20bc 100644 --- a/crates/aft/src/commands/glob.rs +++ b/crates/aft/src/commands/glob.rs @@ -590,7 +590,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/semantic_search/exact_lane.rs b/crates/aft/src/commands/semantic_search/exact_lane.rs index a9a063c3b3..e0e573d5e7 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 a read projection without requiring a mutable legacy 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..4d6a0cfbc9 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!( @@ -758,6 +777,7 @@ fn cancelled_search_response_from_id(request_id: &str) -> Response { } pub fn handle_semantic_search(req: &RawRequest, ctx: &AppContext) -> Response { + let _pin = ctx.pin_config(); let mut response = handle_semantic_search_with_disk_fallback(req, ctx, false); if let Some(reason) = ctx.cache_unavailable_reason() { let code = crate::root_cache::cache_status_code(&reason); @@ -926,6 +946,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 +4019,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 +4866,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 +4883,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 +4894,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 +4981,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 +5049,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 +5119,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 +5162,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 +7414,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/context.rs b/crates/aft/src/context.rs index 46c3f11d68..55b58bd9f2 100644 --- a/crates/aft/src/context.rs +++ b/crates/aft/src/context.rs @@ -294,6 +294,8 @@ 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()) }; } #[cfg(test)] @@ -384,6 +386,8 @@ 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)); CHECKOUT_QUERY_OUTCOMES.with(|outcomes| { outcomes .borrow_mut() @@ -4549,6 +4553,8 @@ 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], /// 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 @@ -5396,6 +5402,8 @@ 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(), checkout_semantic: Arc::default(), view_disk_limits_next_ms, checkout_query_runtime: RwLock::new(None), @@ -10519,6 +10527,9 @@ 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(driver) = self.checkout_driver.get() { driver.record_absolute_change(path); } @@ -11928,6 +11939,85 @@ impl AppContext { &self.search_index } + 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()) + } + + /// 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() + }; + 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)) + } + pub(crate) fn search_exact_memo( &self, ) -> Arc { @@ -12371,6 +12461,9 @@ impl AppContext { } fn search_index_shutdown_outcome(&self) -> SearchIndexShutdownOutcome { + if self.config().views.enabled { + return SearchIndexShutdownOutcome::Skipped; + } use SearchIndexShutdownOutcome::{Failed, Flushed, Skipped}; if self.cache_unavailable_reason().is_some() { return Failed("cache root is unavailable"); diff --git a/crates/aft/src/feature_status.rs b/crates/aft/src/feature_status.rs index 64e0014396..0a2f255dc0 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)) => { 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/runtime_drain.rs b/crates/aft/src/runtime_drain.rs index ffb909dbc0..ac86e756d8 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() @@ -2533,7 +2540,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 +2930,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 +3128,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()) { diff --git a/crates/aft/src/search_index.rs b/crates/aft/src/search_index.rs index e96cafd0c7..d4509a6efa 100644 --- a/crates/aft/src/search_index.rs +++ b/crates/aft/src/search_index.rs @@ -742,6 +742,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) diff --git a/crates/aft/src/views/trigram.rs b/crates/aft/src/views/trigram.rs index f178485dd5..e7c1038caf 100644 --- a/crates/aft/src/views/trigram.rs +++ b/crates/aft/src/views/trigram.rs @@ -1,7 +1,7 @@ //! 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` @@ -26,6 +26,196 @@ use super::segment_store::{rel_path_to_os, SegmentReader, TrigramFlag, TrigramPa use super::snapshot::{DiskState, Snapshot, Source, WatcherState}; use super::RelPath; +/// 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, + driver: Arc, + loader: super::first_load::SiblingLoader, + access: super::contracts::ViewAccess, + query: std::sync::Mutex<()>, + ready: std::sync::atomic::AtomicBool, +} + +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 registry = super::registry::FamilyRegistry::open(&storage.artifacts, &family) + .map_err(|error| error.to_string())?; + let owner = registry + .register_view_with_storage( + &storage.durable, + &crate::path_identity::project_scope_key(&root), + &root, + ) + .map_err(|error| error.to_string())?; + 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( + owner.clone(), + super::manifest_v2::Producers { + trigram: policy.fingerprint_hex(), + semantic: None, + callgraph: super::semantic_runtime::UNREGISTERED_PRODUCER.into(), + }, + None, + Arc::new(super::first_load::ConfiguredMembershipWalker), + vec![bridge], + ) + .with_adapters(vec![adapter.clone()]), + ); + driver.register_write_intent(); + Ok(Self { + root, + storage, + family, + policy, + loader: super::first_load::SiblingLoader::new(driver.clone(), vec![adapter]), + driver, + access: super::contracts::ViewAccess::Owner(owner), + 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.driver.record_absolute_change(path); + } + + pub(crate) fn is_ready(&self) -> bool { + self.ready.load(std::sync::atomic::Ordering::Acquire) + } + + /// 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 { + use super::contracts::PlaneLoader; + self.loader + .load(&self.access) + .map(|outcome| outcome.snapshot) + .map_err(|error| error.to_string()) + } + + pub(crate) fn snapshot(&self) -> Result { + let result = self.project(); + self.ready + .store(result.is_ok(), std::sync::atomic::Ordering::Release); + result + } + + 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.acquire()?; + if snapshot.pending_intent().next().is_some() || snapshot.watcher() != WatcherState::Healthy + { + return Err("lexical view has unverified writes".into()); + } + let source = crate::blob_store::v3::PlaneReader::open_existing( + &self.storage.artifacts, + &self.family, + FamilyPlane::Trigram, + ) + .map_err(|error| error.to_string())?; + let mut rows = Vec::new(); + for (path, disk) in snapshot.membership() { + let DiskState::Present { content, size } = disk else { + continue; + }; + let absolute = self + .root + .join(rel_path_to_os(&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())); + } + let bytes = source + .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, + ), + ) + } +} + /// Generation-local replacements and tombstones relative to the shared segment. /// Missing payloads are gaps, not empty postings. #[derive(Debug)] @@ -1083,6 +1273,79 @@ impl super::contracts::PlaneAdapter for TrigramAdapter { #[cfg(test)] mod tests { + #[test] + fn lexical_views_projection_matches_legacy_masks_ranks_and_membership() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().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 view = LexicalView::new( + root.clone(), + crate::root_cache::StoragePaths::same(&storage), + "lexical-parity".into(), + policy, + ) + .unwrap(); + 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!(view + .driver + .installed_snapshot() + .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(); From caf1270261f1ef60d937caaef71427b70fde0dcd Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Mon, 26 Oct 2026 20:25:49 +0100 Subject: [PATCH 052/100] mason: isolate lexical snapshot acquisition and exercise public route parity The initial strict loader folded no-base generations through v2. Refuse that constructor until the v3-only bind source is connected; test snapshot sources use the existing v3 base builder. Add public same-tree legacy parity, edits, restart, forced-kill handshake and writer-stop coverage without changing the views-off contract. Co-authored-by: alfonso-aft <289616620+alfonso-aft@users.noreply.github.com> --- crates/aft/src/commands/configure.rs | 298 +++++++++++++++++- .../commands/semantic_search/exact_lane.rs | 2 +- crates/aft/src/context.rs | 19 ++ crates/aft/src/views/trigram.rs | 177 +++++++---- 4 files changed, 438 insertions(+), 58 deletions(-) diff --git a/crates/aft/src/commands/configure.rs b/crates/aft/src/commands/configure.rs index 8f88a375bb..c2b5179f7a 100644 --- a/crates/aft/src/commands/configure.rs +++ b/crates/aft/src/commands/configure.rs @@ -8602,10 +8602,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(); } @@ -15345,6 +15352,289 @@ 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_test_source( + ctx: &AppContext, + ) -> Arc { + let root = ctx.canonical_cache_root(); + let source = crate::views::trigram::BaseBuilderTestSource::new( + &root, + &ctx.storage_paths().artifacts, + &ctx.memoized_artifact_cache_key(&root), + crate::blob_store::v2::TrigramPolicy { + max_file_size: ctx.config().search_index_max_file_size, + }, + ); + ctx.install_lexical_source_for_test(source.clone()); + source + } + + 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(), + 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 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); + let source = lexical_views_test_source(&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); + + 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); + std::fs::remove_file(root.join("shared.ts")).unwrap(); + std::fs::write(root.join(".gitignore"), "").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("new.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 + ); + let cache = + resolve_cache_dir_with_key(&ctx.memoized_artifact_cache_key(&root), Some(&storage)); + // The owner fixture already wrote a cache. A routed checkout must not + // replace it with its own content, even after refresh and shutdown. + let owner_cache = std::fs::read(cache.join("cache.bin")).unwrap(); + 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 + ); + restarted.install_lexical_source_for_test(source); + 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_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]); + 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 _source = lexical_views_test_source(&ctx); + 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); + } + + #[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); + let _source = lexical_views_test_source(&ctx); + 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); + let _source = lexical_views_test_source(&ctx); + lexical_views_assert_public_parity(&ctx, &root); + } + fn configure_search_with_max_file_size( root: &std::path::Path, storage: &std::path::Path, diff --git a/crates/aft/src/commands/semantic_search/exact_lane.rs b/crates/aft/src/commands/semantic_search/exact_lane.rs index e0e573d5e7..13a839c17c 100644 --- a/crates/aft/src/commands/semantic_search/exact_lane.rs +++ b/crates/aft/src/commands/semantic_search/exact_lane.rs @@ -537,7 +537,7 @@ impl ExactLane { ) } - /// Accepts a read projection without requiring a mutable legacy index. + /// Accepts an optional SearchIndexSnapshot without requiring a mutable index. pub(crate) fn search_scoped_snapshot( &self, snapshot: Option<&crate::search_index::SearchIndexSnapshot>, diff --git a/crates/aft/src/context.rs b/crates/aft/src/context.rs index 55b58bd9f2..fe7bcbbf02 100644 --- a/crates/aft/src/context.rs +++ b/crates/aft/src/context.rs @@ -11955,6 +11955,25 @@ impl AppContext { .is_some_and(|view| view.is_ready()) } + #[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. diff --git a/crates/aft/src/views/trigram.rs b/crates/aft/src/views/trigram.rs index e7c1038caf..b7ac9fd9df 100644 --- a/crates/aft/src/views/trigram.rs +++ b/crates/aft/src/views/trigram.rs @@ -34,13 +34,18 @@ pub(crate) struct LexicalView { storage: crate::root_cache::StoragePaths, family: String, policy: TrigramPolicy, - driver: Arc, - loader: super::first_load::SiblingLoader, - access: super::contracts::ViewAccess, + 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); +} + impl LexicalView { pub(crate) fn matches( &self, @@ -56,57 +61,39 @@ impl LexicalView { } pub(crate) fn new( + _root: PathBuf, + _storage: crate::root_cache::StoragePaths, + _family: String, + _policy: TrigramPolicy, + ) -> Result { + // A legacy registration would write blobs/v2 even before loading. Until + // a v3-only source is available, refuse rather than silently creating it. + Err("v3-only lexical snapshot source unavailable".into()) + } + + #[cfg(test)] + pub(crate) fn with_source( root: PathBuf, storage: crate::root_cache::StoragePaths, family: String, policy: TrigramPolicy, - ) -> Result { - let registry = super::registry::FamilyRegistry::open(&storage.artifacts, &family) - .map_err(|error| error.to_string())?; - let owner = registry - .register_view_with_storage( - &storage.durable, - &crate::path_identity::project_scope_key(&root), - &root, - ) - .map_err(|error| error.to_string())?; - 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( - owner.clone(), - super::manifest_v2::Producers { - trigram: policy.fingerprint_hex(), - semantic: None, - callgraph: super::semantic_runtime::UNREGISTERED_PRODUCER.into(), - }, - None, - Arc::new(super::first_load::ConfiguredMembershipWalker), - vec![bridge], - ) - .with_adapters(vec![adapter.clone()]), - ); - driver.register_write_intent(); - Ok(Self { + source: Arc, + ) -> Self { + Self { root, storage, family, policy, - loader: super::first_load::SiblingLoader::new(driver.clone(), vec![adapter]), - driver, - access: super::contracts::ViewAccess::Owner(owner), + 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.driver.record_absolute_change(path); + self.source.record_change(path); } pub(crate) fn is_ready(&self) -> bool { @@ -116,11 +103,7 @@ impl LexicalView { /// 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 { - use super::contracts::PlaneLoader; - self.loader - .load(&self.access) - .map(|outcome| outcome.snapshot) - .map_err(|error| error.to_string()) + self.source.acquire() } pub(crate) fn snapshot(&self) -> Result { @@ -216,6 +199,98 @@ impl LexicalView { } } +#[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 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)] @@ -1287,13 +1362,14 @@ mod tests { let policy = TrigramPolicy { max_file_size: 1024 * 1024, }; - let view = LexicalView::new( + 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, - ) - .unwrap(); + source.clone(), + ); let projected = view.snapshot().unwrap(); let legacy = crate::search_index::SearchIndex::build_with_limit_serial(&root, policy.max_file_size) @@ -1336,12 +1412,7 @@ mod tests { .files ); } - assert!(view - .driver - .installed_snapshot() - .generation() - .v3_route() - .is_some()); + assert!(source.acquire().unwrap().generation().v3_route().is_some()); assert!(!storage.join("blobs/v2").exists()); assert!(!storage.join("views/v3").exists()); } From b276dcd5608d631c3ac497a8425be8a08b8bad9d Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Mon, 26 Oct 2026 20:31:59 +0100 Subject: [PATCH 053/100] mason: pin lexical reads and guard uncertain view snapshots Co-authored-by: alfonso-aft <289616620+alfonso-aft@users.noreply.github.com> --- crates/aft/src/commands/configure.rs | 115 ++++++++++++++++++++++++++- crates/aft/src/commands/glob.rs | 1 + crates/aft/src/commands/grep.rs | 1 + crates/aft/src/views/trigram.rs | 63 +++++++++++++++ 4 files changed, 178 insertions(+), 2 deletions(-) diff --git a/crates/aft/src/commands/configure.rs b/crates/aft/src/commands/configure.rs index c2b5179f7a..585ee1f9d9 100644 --- a/crates/aft/src/commands/configure.rs +++ b/crates/aft/src/commands/configure.rs @@ -15433,6 +15433,8 @@ mod tests { let request = RawRequest { id: format!("lexical-views-{command}"), command: command.into(), + lsp_hints: None, + session_id: None, params, }; let run = |ctx: &AppContext| match command { @@ -15510,8 +15512,10 @@ mod tests { ), before ); - let cache = - resolve_cache_dir_with_key(&ctx.memoized_artifact_cache_key(&root), Some(&storage)); + let cache = crate::search_index::resolve_cache_dir_with_key( + &ctx.memoized_artifact_cache_key(&root), + Some(&storage), + ); // The owner fixture already wrote a cache. A routed checkout must not // replace it with its own content, even after refresh and shutdown. let owner_cache = std::fs::read(cache.join("cache.bin")).unwrap(); @@ -15567,6 +15571,113 @@ mod tests { lexical_views_assert_public_parity(&ctx, &root); } + #[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 source = lexical_views_test_source(&ctx); + 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 { diff --git a/crates/aft/src/commands/glob.rs b/crates/aft/src/commands/glob.rs index 3dba4e20bc..957787e4c8 100644 --- a/crates/aft/src/commands/glob.rs +++ b/crates/aft/src/commands/glob.rs @@ -38,6 +38,7 @@ const MAX_DIRECTORY_SECTIONS: usize = 8; const MAX_DISPLAY_DIRECTORIES: usize = 6; pub fn handle_glob(req: &RawRequest, ctx: &AppContext) -> Response { + let _pin = ctx.pin_config(); ctx.with_read_policy_snapshot(&req.id, || { crate::context::confinement::with_floor_disclosure(|| { crate::bounded_io::command(req, ctx, handle_glob_inner) diff --git a/crates/aft/src/commands/grep.rs b/crates/aft/src/commands/grep.rs index 286a8bbd30..0332444a75 100644 --- a/crates/aft/src/commands/grep.rs +++ b/crates/aft/src/commands/grep.rs @@ -133,6 +133,7 @@ pub(crate) mod work_probe { } pub fn handle_grep(req: &RawRequest, ctx: &AppContext) -> Response { + let _pin = ctx.pin_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/views/trigram.rs b/crates/aft/src/views/trigram.rs index b7ac9fd9df..7a5bf940ed 100644 --- a/crates/aft/src/views/trigram.rs +++ b/crates/aft/src/views/trigram.rs @@ -283,6 +283,11 @@ impl LexicalSnapshotSource for BaseBuilderTestSource { 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) @@ -1348,6 +1353,64 @@ 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")); + } + #[test] fn lexical_views_projection_matches_legacy_masks_ranks_and_membership() { let temp = tempfile::tempdir().unwrap(); From f552c7621a1f40c83f2ae569067fa6eac6880d28 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Mon, 26 Oct 2026 20:44:43 +0100 Subject: [PATCH 054/100] mason: fence admitted legacy cache publications at views cutover Revoke only on views-on configuration publication. Keep final rename and its ledger credit under a shared publication permit; normal views-off workers may still publish after reconfigure or unbind. Deterministic channel hooks cover both orders without sleeps. Co-authored-by: alfonso-aft <289616620+alfonso-aft@users.noreply.github.com> --- crates/aft/src/commands/configure.rs | 14 ++- crates/aft/src/context.rs | 15 +++ crates/aft/src/runtime_drain.rs | 3 + crates/aft/src/search_index.rs | 178 +++++++++++++++++++++++++-- 4 files changed, 196 insertions(+), 14 deletions(-) diff --git a/crates/aft/src/commands/configure.rs b/crates/aft/src/commands/configure.rs index 585ee1f9d9..1104a48c98 100644 --- a/crates/aft/src/commands/configure.rs +++ b/crates/aft/src/commands/configure.rs @@ -5775,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); @@ -5782,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(); @@ -6010,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 }; diff --git a/crates/aft/src/context.rs b/crates/aft/src/context.rs index fe7bcbbf02..eedd50c006 100644 --- a/crates/aft/src/context.rs +++ b/crates/aft/src/context.rs @@ -4612,6 +4612,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, @@ -5438,6 +5440,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, @@ -8965,6 +8968,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); @@ -11939,6 +11947,12 @@ 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 @@ -12483,6 +12497,7 @@ impl AppContext { 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"); diff --git a/crates/aft/src/runtime_drain.rs b/crates/aft/src/runtime_drain.rs index ac86e756d8..60b61cf15e 100644 --- a/crates/aft/src/runtime_drain.rs +++ b/crates/aft/src/runtime_drain.rs @@ -2411,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, @@ -2453,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 }; diff --git a/crates/aft/src/search_index.rs b/crates/aft/src/search_index.rs index d4509a6efa..f0a78bcf22 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>, @@ -5364,14 +5444,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), @@ -10960,6 +11044,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>>, From f994a2e0d1fba3e319e225ea391b80b5a2e8e6b6 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Mon, 26 Oct 2026 20:56:21 +0100 Subject: [PATCH 055/100] mason: bind public lexical routes through v3-only checkout drivers Stack on step D e59e1318867545d9d5f552348a074140342a72ba. Use new_v3/access/verified_bind and real production acquisition in public parity, restart and kill fixtures. Forward watcher overflow and cookies to the lexical slot. Verified bind currently takes D's strict fallback; platform fast-path enablement and performance measurements remain deferred. Co-authored-by: alfonso-aft <289616620+alfonso-aft@users.noreply.github.com> --- crates/aft/src/commands/configure.rs | 57 ++++++------- crates/aft/src/context.rs | 17 ++++ crates/aft/src/search_index.rs | 1 + crates/aft/src/views/trigram.rs | 123 ++++++++++++++++++++++++--- 4 files changed, 158 insertions(+), 40 deletions(-) diff --git a/crates/aft/src/commands/configure.rs b/crates/aft/src/commands/configure.rs index 1104a48c98..b34e40859d 100644 --- a/crates/aft/src/commands/configure.rs +++ b/crates/aft/src/commands/configure.rs @@ -15381,22 +15381,6 @@ mod tests { oracle } - fn lexical_views_test_source( - ctx: &AppContext, - ) -> Arc { - let root = ctx.canonical_cache_root(); - let source = crate::views::trigram::BaseBuilderTestSource::new( - &root, - &ctx.storage_paths().artifacts, - &ctx.memoized_artifact_cache_key(&root), - crate::blob_store::v2::TrigramPolicy { - max_file_size: ctx.config().search_index_max_file_size, - }, - ); - ctx.install_lexical_source_for_test(source.clone()); - source - } - fn lexical_views_assert_public_parity(ctx: &AppContext, root: &Path) { let oracle = lexical_views_oracle(root); for (command, params, field) in [ @@ -15468,8 +15452,13 @@ mod tests { 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 (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(), @@ -15485,7 +15474,6 @@ mod tests { let ctx = test_context(); let bound = handle_configure_for_test(&configure_lexical_views(&root, &storage), &ctx); assert!(bound.success, "{:?}", bound.data); - let source = lexical_views_test_source(&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); @@ -15499,12 +15487,27 @@ mod tests { 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("new.ts")); + 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!( @@ -15514,13 +15517,10 @@ mod tests { ), before ); - let cache = crate::search_index::resolve_cache_dir_with_key( - &ctx.memoized_artifact_cache_key(&root), - Some(&storage), - ); // The owner fixture already wrote a cache. A routed checkout must not // replace it with its own content, even after refresh and shutdown. - let owner_cache = std::fs::read(cache.join("cache.bin")).unwrap(); + 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); @@ -15529,7 +15529,6 @@ mod tests { handle_configure_for_test(&configure_lexical_views(&root, &storage), &restarted) .success ); - restarted.install_lexical_source_for_test(source); lexical_views_assert_public_parity(&restarted, &root); assert_eq!(std::fs::read(cache.join("cache.bin")).unwrap(), owner_cache); } @@ -15563,7 +15562,6 @@ mod tests { ) .unwrap(); assert!(handle_configure_for_test(&configure_lexical_views(&root, &storage), &ctx).success); - let _source = lexical_views_test_source(&ctx); let (tx, rx) = crossbeam_channel::unbounded(); ctx.install_search_index_rx(rx, ctx.configure_generation()); tx.send(stale).unwrap(); @@ -15571,6 +15569,7 @@ mod tests { 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] @@ -15633,7 +15632,7 @@ mod tests { #[test] fn lexical_views_public_search_pins_one_snapshot_for_all_lexical_lanes() { struct Counted { - source: Arc, + source: Arc, calls: std::sync::atomic::AtomicUsize, } impl crate::views::trigram::LexicalSnapshotSource for Counted { @@ -15664,7 +15663,7 @@ mod tests { ) .success ); - let source = lexical_views_test_source(&ctx); + let source = ctx.lexical_source_for_test(); let counted = Arc::new(Counted { source, calls: Default::default(), @@ -15689,7 +15688,6 @@ mod tests { let root = PathBuf::from(root); let ctx = test_context(); assert!(handle_configure_for_test(&configure_lexical_views(&root, &storage), &ctx).success); - let _source = lexical_views_test_source(&ctx); lexical_views_assert_public_parity(&ctx, &root); println!("LEXICAL_VIEW_PUBLISHED"); use std::io::{Read, Write}; @@ -15744,7 +15742,6 @@ mod tests { .unwrap(); let ctx = test_context(); assert!(handle_configure_for_test(&configure_lexical_views(&root, &storage), &ctx).success); - let _source = lexical_views_test_source(&ctx); lexical_views_assert_public_parity(&ctx, &root); } diff --git a/crates/aft/src/context.rs b/crates/aft/src/context.rs index eedd50c006..bd0c76c999 100644 --- a/crates/aft/src/context.rs +++ b/crates/aft/src/context.rs @@ -9001,6 +9001,9 @@ 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(); + } if project_root_changed { self.path_restriction_root_memo.lock().take(); *self @@ -10548,6 +10551,9 @@ impl AppContext { 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(driver) = self.checkout_driver.get() { driver.record_watcher_overflow(); } @@ -10556,6 +10562,9 @@ impl AppContext { 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(driver) = self.checkout_driver.get() { driver.record_watcher_cookie(path); } @@ -11969,6 +11978,13 @@ impl AppContext { .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, @@ -12027,6 +12043,7 @@ impl AppContext { } 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| { diff --git a/crates/aft/src/search_index.rs b/crates/aft/src/search_index.rs index f0a78bcf22..d4d9d2aff1 100644 --- a/crates/aft/src/search_index.rs +++ b/crates/aft/src/search_index.rs @@ -3194,6 +3194,7 @@ impl SearchIndex { ) } + #[cfg(test)] pub(crate) fn rebuild_or_refresh_with_strategy( root: &Path, max_file_size: u64, diff --git a/crates/aft/src/views/trigram.rs b/crates/aft/src/views/trigram.rs index 7a5bf940ed..4153d5eccd 100644 --- a/crates/aft/src/views/trigram.rs +++ b/crates/aft/src/views/trigram.rs @@ -3,8 +3,9 @@ //! 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 @@ -44,6 +45,63 @@ pub(crate) struct LexicalView { 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())?; + Ok(self.driver.installed_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 { @@ -61,17 +119,47 @@ impl LexicalView { } pub(crate) fn new( - _root: PathBuf, - _storage: crate::root_cache::StoragePaths, - _family: String, - _policy: TrigramPolicy, + root: PathBuf, + storage: crate::root_cache::StoragePaths, + family: String, + policy: TrigramPolicy, ) -> Result { - // A legacy registration would write blobs/v2 even before loading. Until - // a v3-only source is available, refuse rather than silently creating it. - Err("v3-only lexical snapshot source unavailable".into()) + 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)) } - #[cfg(test)] pub(crate) fn with_source( root: PathBuf, storage: crate::root_cache::StoragePaths, @@ -96,10 +184,25 @@ impl LexicalView { 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 { From 7aad8091e18b6ed5242433b3cf88aa0cd4387255 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Mon, 26 Oct 2026 21:05:28 +0100 Subject: [PATCH 056/100] mason: initialize the lexical acquisition spy and strengthen off-boundary probes Co-authored-by: alfonso-aft <289616620+alfonso-aft@users.noreply.github.com> --- crates/aft/src/commands/configure.rs | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/crates/aft/src/commands/configure.rs b/crates/aft/src/commands/configure.rs index b34e40859d..55241232f0 100644 --- a/crates/aft/src/commands/configure.rs +++ b/crates/aft/src/commands/configure.rs @@ -15553,6 +15553,22 @@ mod tests { 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); @@ -15663,6 +15679,8 @@ mod tests { ) .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, From 71e5d7932d7826b19a36d0a3008471b52710df7b Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Mon, 26 Oct 2026 21:11:06 +0100 Subject: [PATCH 057/100] mason: scope lexical request pinning to views and cover parent searches Co-authored-by: alfonso-aft <289616620+alfonso-aft@users.noreply.github.com> --- crates/aft/src/commands/glob.rs | 3 ++- crates/aft/src/commands/grep.rs | 3 ++- crates/aft/src/commands/semantic_search/mod.rs | 3 ++- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/crates/aft/src/commands/glob.rs b/crates/aft/src/commands/glob.rs index 957787e4c8..bc9e36fcb8 100644 --- a/crates/aft/src/commands/glob.rs +++ b/crates/aft/src/commands/glob.rs @@ -38,7 +38,8 @@ const MAX_DIRECTORY_SECTIONS: usize = 8; const MAX_DISPLAY_DIRECTORIES: usize = 6; pub fn handle_glob(req: &RawRequest, ctx: &AppContext) -> Response { - let _pin = ctx.pin_config(); + 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) diff --git a/crates/aft/src/commands/grep.rs b/crates/aft/src/commands/grep.rs index 0332444a75..5b3ce6b755 100644 --- a/crates/aft/src/commands/grep.rs +++ b/crates/aft/src/commands/grep.rs @@ -133,7 +133,8 @@ pub(crate) mod work_probe { } pub fn handle_grep(req: &RawRequest, ctx: &AppContext) -> Response { - let _pin = ctx.pin_config(); + 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/semantic_search/mod.rs b/crates/aft/src/commands/semantic_search/mod.rs index 4d6a0cfbc9..785e61d0b5 100644 --- a/crates/aft/src/commands/semantic_search/mod.rs +++ b/crates/aft/src/commands/semantic_search/mod.rs @@ -777,7 +777,6 @@ fn cancelled_search_response_from_id(request_id: &str) -> Response { } pub fn handle_semantic_search(req: &RawRequest, ctx: &AppContext) -> Response { - let _pin = ctx.pin_config(); let mut response = handle_semantic_search_with_disk_fallback(req, ctx, false); if let Some(reason) = ctx.cache_unavailable_reason() { let code = crate::root_cache::cache_status_code(&reason); @@ -811,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) { From fc8a59469cc1583fbdc0164e3dd5cce09a828933 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Mon, 26 Oct 2026 21:37:54 +0100 Subject: [PATCH 058/100] mason: read empty v3 lexical generations without requiring a plane database Co-authored-by: alfonso-aft <289616620+alfonso-aft@users.noreply.github.com> --- crates/aft/src/commands/configure.rs | 18 ++++++++ crates/aft/src/views/trigram.rs | 67 +++++++++++++++++++++++----- 2 files changed, 73 insertions(+), 12 deletions(-) diff --git a/crates/aft/src/commands/configure.rs b/crates/aft/src/commands/configure.rs index 55241232f0..30f713cd15 100644 --- a/crates/aft/src/commands/configure.rs +++ b/crates/aft/src/commands/configure.rs @@ -15543,6 +15543,24 @@ mod tests { 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(); diff --git a/crates/aft/src/views/trigram.rs b/crates/aft/src/views/trigram.rs index 4153d5eccd..5765c01f3a 100644 --- a/crates/aft/src/views/trigram.rs +++ b/crates/aft/src/views/trigram.rs @@ -227,12 +227,7 @@ impl LexicalView { { return Err("lexical view has unverified writes".into()); } - let source = crate::blob_store::v3::PlaneReader::open_existing( - &self.storage.artifacts, - &self.family, - FamilyPlane::Trigram, - ) - .map_err(|error| error.to_string())?; + let mut source = None; let mut rows = Vec::new(); for (path, disk) in snapshot.membership() { let DiskState::Present { content, size } = disk else { @@ -266,7 +261,19 @@ impl LexicalView { { 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(|| { @@ -1199,6 +1206,20 @@ fn put_v3_payloads( 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 { @@ -1215,6 +1236,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, @@ -1316,12 +1364,7 @@ impl TrigramAdapter { if !generation.segment_ids().is_empty() { return Err(plane_error("a v3 generation names a trigram segment")); } - let source = crate::blob_store::v3::PlaneReader::open_existing( - &route.storage, - &route.family, - FamilyPlane::Trigram, - ) - .map_err(|e| plane_error(e.to_string()))?; + let source = self.v3_payloads(route, snapshot)?; let base = generation .base() .map_or(generation.as_ref(), |base| base.as_ref()); From 8ad263e9fccdb171c8091aea659a46fe2e75641b Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Tue, 27 Oct 2026 00:44:46 +0100 Subject: [PATCH 059/100] Declare views step F's search descriptor as engine_unwired Co-authored-by: alfonso-aft <289616620+alfonso-aft@users.noreply.github.com> --- .../land-views-f-lexical-routing.json | 15 +++++++++++++++ .../views-f-lexical-routing.json | 15 --------------- 2 files changed, 15 insertions(+), 15 deletions(-) create mode 100644 benchmarks/aft-search/slice-descriptors/land-views-f-lexical-routing.json delete mode 100644 benchmarks/aft-search/slice-descriptors/views-f-lexical-routing.json 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/views-f-lexical-routing.json b/benchmarks/aft-search/slice-descriptors/views-f-lexical-routing.json deleted file mode 100644 index d2ac17aff2..0000000000 --- a/benchmarks/aft-search/slice-descriptors/views-f-lexical-routing.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "slice_class": "ranking", - "kind": "paging", - "targeted_mechanism": "none", - "fixtures": [ - "views::trigram::tests::lexical_views_projection_matches_legacy_masks_ranks_and_membership", - "lexical_views_public" - ], - "fenced_files": [ - "crates/aft/src/search_index.rs", - "crates/aft/src/commands/semantic_search/mod.rs", - "crates/aft/src/commands/semantic_search/exact_lane.rs" - ], - "note": "Views-on lexical acquisition projects verified v3 payloads into the existing read-only query engine. Views-off acquisition, scoring, fusion, admission and paging rules are unchanged. Parity rows, including rank order, must remain byte-identical; this descriptor does not waive any quality difference. Platform measurements and the release-binary quality replay are deferred to the views measurement step." -} From 03cb871637ccf2c92970513bad188e3cf8dc8f0e Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Tue, 27 Oct 2026 02:25:52 +0100 Subject: [PATCH 060/100] mason: seal base call-graph donors and clone them only when sealed A sealed donor is a private, fully built derived graph switched to journal_mode=DELETE, checkpointed and synced, with no -wal/-shm/-journal sibling, plus an atomically published -seal record (fingerprint, size, journal mode). Readers open sealed files with immutable=1; IdentityConnection refuses write opens while the seal record exists. clone_sealed verifies the seal and copies into a fresh, never-reused name with clonefile (APFS), FICLONE (Linux) or a logged plain copy; a live (unsealed) database is refused. The generation sweep reclaims seal records with their database. Co-authored-by: alfonso-aft <289616620+alfonso-aft@users.noreply.github.com> (cherry picked from commit 4da050bd3d2a68effe2d0e2aa90ff7bbede9b7d5) --- crates/aft/src/callgraph_store/mod.rs | 22 +- crates/aft/src/db/file_identity.rs | 2 + crates/aft/src/views/donor.rs | 504 ++++++++++++++++++++++++++ crates/aft/src/views/generation.rs | 16 +- crates/aft/src/views/mod.rs | 1 + 5 files changed, 540 insertions(+), 5 deletions(-) create mode 100644 crates/aft/src/views/donor.rs 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/db/file_identity.rs b/crates/aft/src/db/file_identity.rs index 2e2bc939b6..e526a9e034 100644 --- a/crates/aft/src/db/file_identity.rs +++ b/crates/aft/src/db/file_identity.rs @@ -400,6 +400,7 @@ 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())?; #[cfg(test)] @@ -422,6 +423,7 @@ 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)?; diff --git a/crates/aft/src/views/donor.rs b/crates/aft/src/views/donor.rs new file mode 100644 index 0000000000..bc3b803d2a --- /dev/null +++ b/crates/aft/src/views/donor.rs @@ -0,0 +1,504 @@ +//! 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")); + } + std::fs::File::open(database) + .and_then(|file| file.sync_all()) + .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)?; + } + std::fs::File::open(destination) + .and_then(|file| file.sync_all()) + .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/generation.rs b/crates/aft/src/views/generation.rs index c955735362..f4fd8c6a9e 100644 --- a/crates/aft/src/views/generation.rs +++ b/crates/aft/src/views/generation.rs @@ -845,6 +845,7 @@ impl ViewStore { ".sqlite-wal", ".sqlite-shm", ".sqlite-journal", + ".sqlite-seal", ".ref", ] .iter() @@ -942,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-") @@ -1143,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/mod.rs b/crates/aft/src/views/mod.rs index 67e7433e8e..72825f1057 100644 --- a/crates/aft/src/views/mod.rs +++ b/crates/aft/src/views/mod.rs @@ -14,6 +14,7 @@ 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::{ From c68ae13c3bf2ebd9c086f552cb447040cae73914 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Tue, 27 Oct 2026 02:26:13 +0100 Subject: [PATCH 061/100] mason: bootstrap the call-graph plane through the base builder and clone sealed donors for workers Fresh v3 bootstrap now accepts planes that complete their own base entries (CompositePlane::completes_fresh_base / complete_v3_base). CallgraphBridge completes a fresh base from the bootstrap's strict-reconciliation attachments under the base build claim (taking over a dead builder's claim, refusing a live one), publishes a readiness successor whose derived graph is sealed as the base's donor, and the claim is released after the worker publishes. Semantic producers and other nonlexical planes stay refused. Base entries now carry resolution_input before the base identity is computed (base_builder::base_entry, used by build_base and the bootstrap's expected identity), and with a registered graph producer only call-graph inputs start pending, so a completed base projects. A v3 worker graph is a fresh clone of the base's sealed donor patched with the manifest diff (apply_manifest_diff_from_blob_reader); a cold build happens only for a reason from donor::ColdReason and is recorded. Fix: deleting a file a worker added (absent from the base) produced a tombstone for a non-inherited path, which ComposedGeneration::compose refuses; such paths are now dropped from the delta without a tombstone. Co-authored-by: alfonso-aft <289616620+alfonso-aft@users.noreply.github.com> (cherry picked from commit 27584ea4496e88dbacc4d889b98e8413f468c9b8) --- crates/aft/src/views/base_builder.rs | 44 +++- crates/aft/src/views/callgraph.rs | 14 +- crates/aft/src/views/first_load.rs | 288 +++++++++++++++++++++++- crates/aft/src/views/materialization.rs | 22 ++ 4 files changed, 356 insertions(+), 12 deletions(-) diff --git a/crates/aft/src/views/base_builder.rs b/crates/aft/src/views/base_builder.rs index 55276b2c29..006311675b 100644 --- a/crates/aft/src/views/base_builder.rs +++ b/crates/aft/src/views/base_builder.rs @@ -349,6 +349,12 @@ impl BuildClaim { 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 } @@ -408,6 +414,12 @@ impl BuildClaim { "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() @@ -558,6 +570,25 @@ pub fn read_stable(path: &Path) -> std::io::Result> { )) } +/// 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> { @@ -618,7 +649,7 @@ pub fn build_base( producers: super::manifest_v2::Producers { trigram: policy.fingerprint_hex(), semantic: semantic_producer.clone(), - callgraph: callgraph_producer, + callgraph: callgraph_producer.clone(), }, head_tree: None, ignore_fingerprint, @@ -640,17 +671,24 @@ pub fn build_base( 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: Some(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(), - super::manifest_v2::EntryV2::regular( + base_entry( + path, crate::blob_store::v2::ContentHash::of(&source), source.len() as u64, planes, diff --git a/crates/aft/src/views/callgraph.rs b/crates/aft/src/views/callgraph.rs index 80d067008d..10b9fd08d2 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( @@ -234,10 +243,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, diff --git a/crates/aft/src/views/first_load.rs b/crates/aft/src/views/first_load.rs index b33b572a4e..fa7e1bf553 100644 --- a/crates/aft/src/views/first_load.rs +++ b/crates/aft/src/views/first_load.rs @@ -1249,6 +1249,25 @@ pub trait CompositePlane: Send + Sync { 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( @@ -1710,8 +1729,8 @@ impl CheckoutDriver { if let Some(plane) = self .planes .iter() + .find(|plane| plane.plane() != FamilyPlane::Trigram && !plane.completes_fresh_base()) .map(|plane| plane.plane()) - .find(|plane| *plane != FamilyPlane::Trigram) { return Err(FreshBootstrapRefusal::NonLexicalPlane(plane)); } @@ -1720,7 +1739,13 @@ impl CheckoutDriver { FamilyPlane::Semantic, )); } - if self.producers.callgraph != super::semantic_runtime::UNREGISTERED_PRODUCER { + // 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, )); @@ -1860,10 +1885,12 @@ impl CheckoutDriver { 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) + .all(|plane| plane.plane() == FamilyPlane::Trigram || plane.completes_fresh_base()) { if let Some(claim) = self .base_claim @@ -1929,8 +1956,8 @@ impl CheckoutDriver { if let super::snapshot::DiskState::Present { content, size } = state { manifest .insert( - path, - super::manifest_v2::EntryV2::regular(content, size, Default::default()), + path.clone(), + super::base_builder::base_entry(&path, content, size, Default::default()), ) .map_err(|e| SiblingLoader::error(e.to_string()))?; } @@ -1991,6 +2018,7 @@ impl CheckoutDriver { } } }; + 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( @@ -2005,6 +2033,68 @@ impl CheckoutDriver { 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, @@ -3094,6 +3184,18 @@ impl CheckoutDriver { &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()))?; @@ -4464,9 +4566,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 } @@ -4522,6 +4761,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)) } @@ -4765,6 +5006,43 @@ impl CompositePlane for CallgraphBridge { 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( 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, From c61c080a9bcffe593f3671987ef3d143e3796dc0 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Tue, 27 Oct 2026 02:46:01 +0100 Subject: [PATCH 062/100] mason: route views-on call-graph reads through the checkout's v3 view With views on (and the call graph enabled, a heavy-work root and an available cache), callgraph_store_for_ops and its no-wait variant acquire a reader from a CallgraphView: a v3-only CheckoutDriver with the lexical and call-graph planes under its own member scope (.callgraph). Each acquisition verified-binds, publishes pending edits as a new worker generation before its single install, refuses uncertain snapshots (pending intent, unhealthy watcher, unpublished changes) and never falls back to the legacy store or a v1 view. The reader is pinned once per pinned request. Status, zoom, inspect's graph-backed categories and the borrowed-graph disclosure follow the route; warming never binds on the transport loop. An explicitly installed CheckoutQueryRuntime keeps precedence. Views off is unchanged. Fix (views-on): view-materialized graphs store the call-graph blob key as a file's content hash, so source-line freshness checks marked every view answer stale (approximate, no call_expression). The adapter now also accepts the current bytes' derived blob key. Parity tests compare callers/impact/call_tree/trace_to output with a cold legacy store over the same files on a plain clone and a linked worktree, across edits, a deletion, an overflow and a restart; a refused acquisition never answers from a resident legacy store. Co-authored-by: alfonso-aft <289616620+alfonso-aft@users.noreply.github.com> (cherry picked from commit 57a9c36ab4d1a796f1146c43c1af6c7c23607ac8) --- crates/aft/src/commands/callgraph_borrowed.rs | 7 +- .../src/commands/callgraph_store_adapter.rs | 22 +- crates/aft/src/commands/configure.rs | 243 +++++++++ crates/aft/src/commands/inspect.rs | 10 +- crates/aft/src/context.rs | 135 +++++ crates/aft/src/feature_status.rs | 8 + crates/aft/src/views/assembly.rs | 2 +- crates/aft/src/views/callgraph.rs | 464 ++++++++++++++++++ 8 files changed, 884 insertions(+), 7 deletions(-) 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 30f713cd15..512bddebe1 100644 --- a/crates/aft/src/commands/configure.rs +++ b/crates/aft/src/commands/configure.rs @@ -15781,6 +15781,249 @@ mod tests { 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, String> { + 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, diff --git a/crates/aft/src/commands/inspect.rs b/crates/aft/src/commands/inspect.rs index 65feb63361..e47892ceb5 100644 --- a/crates/aft/src/commands/inspect.rs +++ b/crates/aft/src/commands/inspect.rs @@ -708,12 +708,16 @@ 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; let checkout_store = if checkout_routed { match routed_store { crate::context::CallgraphStoreAccess::Ready(store) => Some(store), @@ -761,7 +765,7 @@ fn handle_inspect_payload( } else { None }; - let use_checkout_view = needs_ephemeral_view + let use_checkout_view = (needs_ephemeral_view || v3_routed) && snapshot.config.views.enabled && (checkout_routed || checkout_store.is_some()); let checkout_view_unavailability = (needs_ephemeral_view diff --git a/crates/aft/src/context.rs b/crates/aft/src/context.rs index bd0c76c999..b3c68d21f2 100644 --- a/crates/aft/src/context.rs +++ b/crates/aft/src/context.rs @@ -296,6 +296,10 @@ thread_local! { 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, String>)>> = + const { std::cell::RefCell::new(Vec::new()) }; } #[cfg(test)] @@ -388,6 +392,8 @@ impl Drop for ConfigPinGuard { 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() @@ -4555,6 +4561,8 @@ pub struct AppContext { 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 @@ -5406,6 +5414,8 @@ impl AppContext { 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), @@ -9003,6 +9013,7 @@ impl AppContext { 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(); @@ -10541,6 +10552,9 @@ impl AppContext { 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); } @@ -10554,6 +10568,9 @@ impl AppContext { 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(); } @@ -10565,6 +10582,9 @@ impl AppContext { 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); } @@ -10937,6 +10957,23 @@ 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), + Some(Err(reason)) if reason.contains(crate::views::read::CALLGRAPH_DISABLED) => { + CallgraphStoreAccess::Error(CallGraphStoreError::Unavailable(reason)) + } + Some(Err(reason)) => CallgraphStoreAccess::Error(CallGraphStoreError::Unavailable( + format!("callgraph view unavailable: {reason}"), + )), + 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 @@ -12068,6 +12105,104 @@ impl AppContext { 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, String>> { + 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)), + } + } + 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 { diff --git a/crates/aft/src/feature_status.rs b/crates/aft/src/feature_status.rs index 0a2f255dc0..f40b34dab2 100644 --- a/crates/aft/src/feature_status.rs +++ b/crates/aft/src/feature_status.rs @@ -403,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(), @@ -466,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/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/callgraph.rs b/crates/aft/src/views/callgraph.rs index 10b9fd08d2..7ac648bd6b 100644 --- a/crates/aft/src/views/callgraph.rs +++ b/crates/aft/src/views/callgraph.rs @@ -343,6 +343,303 @@ impl PlaneAdapter for CallgraphPlane { } } } +/// 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, String>; + 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, String> { + 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::>(), + )); + } + 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) + } + 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| 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, String> { + 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 { @@ -365,6 +662,173 @@ 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()); + 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(); From ea71f90d1dbd2b1eb2fb2871d4c6478809edaacf Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Tue, 27 Oct 2026 03:53:04 +0100 Subject: [PATCH 063/100] mason: port views-on call-graph tests to the checkout view route With views on, every call-graph read now goes through the checkout's v3 view, so tests that pinned a v1 view and asserted the old route's answers are ported to the same contracts on the new route, or retired when the old route itself was their subject: Ported (contract kept, assertions as strict): - views_keyed_generation_{answers_building,zoom_reports_building, inspect_reports_pending}_while_derived_store_is_busy: a SQLite lock on the view database opening is reported as busy/building/pending, never as an absent graph. The view route now carries a typed busy flag (CallgraphViewError) and inspect observes it from the request's access. - views_pending_head_callers_... -> views_changed_head_callers_answer_the_ current_tree_not_the_stale_legacy_store: never serve stale rows. - views_publication_skips_a_non_utf8_callgraph_source_and_completes: now also passes on the view route because call-graph attachment gives a non-UTF-8 source an empty graph, as checkout assembly does (fix). - publication_build_does_not_delay_same_root_bind_and_read: the read still completes while a publication is held, and answers the current tree. - callgraph_worktree_published_owner_assembles_its_own_changed_source and callgraph_unindexed_path_differs_from_missing_symbol: a worktree answers from its own source at once; a new file is indexed, not 'not_in_generation'. - scoped_inspect_views_worktree_reports_checkout_only_dead_function: an edit after binding is reflected rather than reported unavailable. - views_removed_callgraph_blob_store_and_view_rebuild_on_restart: the v1 checkpoint-contention section is dropped (the view route no longer reads the v1 database); rebuild, parity and edit checks remain. - blob_removal_stop waits (bounded) for v3 session-end/checkpoint threads. Added: views_callgraph_index_off_answers_off_without_binding_a_view. Retired (subject was the v1 route): - views_keyless_generation_answers_disabled_while_derived_store_is_busy and per_checkout_9::callgraph_ops_report_a_keyless_view_generation_as_disabled: a v1 generation published with the call graph off; v3 keyless refusal stays covered by per_checkout_callgraph keyless tests. - generation_sweep_waits_for_the_last_query_handle: v1 QueryPin vs v1 sweep; the v3 equivalent (a held reader keeps its generation) is asserted in views::callgraph::tests. - inspect_view_pending_reply_names_publication_not_changed_sources: wording of the v1 'view pending' reply. - inspect_views_owner_persists_and_reuses_tier2_contributions: the writer-backed reuse pipeline read the v1 published view; v3-routed dead code is projected per request from the pinned view (no contribution reuse). Co-authored-by: alfonso-aft <289616620+alfonso-aft@users.noreply.github.com> (cherry picked from commit 7773c24d17f3c4e26c12a9a038a524a0f9371857) --- crates/aft/src/commands/configure.rs | 123 ++----------- crates/aft/src/commands/inspect.rs | 27 ++- crates/aft/src/context.rs | 169 +++++++++-------- .../src/executor/view_publication_tests.rs | 136 +------------- crates/aft/src/views/callgraph.rs | 66 ++++++- crates/aft/src/views/mod.rs | 5 +- .../integration/callgraph_worktree_test.rs | 67 +------ .../tests/integration/inspect_command_test.rs | 137 ++------------ .../aft/tests/integration/per_checkout_9.rs | 172 ------------------ 9 files changed, 226 insertions(+), 676 deletions(-) diff --git a/crates/aft/src/commands/configure.rs b/crates/aft/src/commands/configure.rs index 512bddebe1..ed71962461 100644 --- a/crates/aft/src/commands/configure.rs +++ b/crates/aft/src/commands/configure.rs @@ -12197,6 +12197,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(), @@ -12829,111 +12839,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(); + // 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!( - 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); - assert_eq!( - answer.unwrap(), + blob_removal_answers(&ctx), ( fixture.callers_before.clone(), fixture.call_tree_before.clone() @@ -15962,7 +15872,10 @@ mod tests { impl crate::views::callgraph::CallgraphReaderSource for Refused { fn acquire( &self, - ) -> Result, String> { + ) -> Result< + Arc, + crate::views::callgraph::CallgraphViewError, + > { self.0.fetch_add(1, Ordering::SeqCst); Err("callgraph source deliberately refused".into()) } diff --git a/crates/aft/src/commands/inspect.rs b/crates/aft/src/commands/inspect.rs index e47892ceb5..5e48e324f6 100644 --- a/crates/aft/src/commands/inspect.rs +++ b/crates/aft/src/commands/inspect.rs @@ -718,6 +718,24 @@ fn handle_inspect_payload( crate::context::CallgraphStoreAccess::Unavailable }; 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), @@ -762,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 }; + // 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 @@ -834,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/context.rs b/crates/aft/src/context.rs index b3c68d21f2..b9b33e696d 100644 --- a/crates/aft/src/context.rs +++ b/crates/aft/src/context.rs @@ -298,7 +298,7 @@ thread_local! { 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, String>)>> = + static CALLGRAPH_QUERY_READERS: std::cell::RefCell, crate::views::callgraph::CallgraphViewError>)>> = const { std::cell::RefCell::new(Vec::new()) }; } @@ -10965,11 +10965,24 @@ impl AppContext { } return match self.callgraph_view_reader() { Some(Ok(store)) => CallgraphStoreAccess::Ready(store), - Some(Err(reason)) if reason.contains(crate::views::read::CALLGRAPH_DISABLED) => { - CallgraphStoreAccess::Error(CallGraphStoreError::Unavailable(reason)) + // 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(reason)) => CallgraphStoreAccess::Error(CallGraphStoreError::Unavailable( - format!("callgraph view unavailable: {reason}"), + Some(Err(error)) => CallgraphStoreAccess::Error(CallGraphStoreError::Unavailable( + format!("callgraph view unavailable: {error}"), )), None => CallgraphStoreAccess::Unavailable, }; @@ -12150,7 +12163,8 @@ impl AppContext { /// acquisition error is final and never answered from another store. pub(crate) fn callgraph_view_reader( &self, - ) -> Option, String>> { + ) -> Option, crate::views::callgraph::CallgraphViewError>> + { if !self.callgraph_views_enabled() { return None; } @@ -12178,7 +12192,7 @@ impl AppContext { { match crate::views::callgraph::CallgraphView::new(root, storage, family, policy) { Ok(view) => *slot = Some(Arc::new(view)), - Err(error) => return Some(Err(error)), + Err(error) => return Some(Err(error.into())), } } slot.as_ref().unwrap().clone() @@ -20434,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", @@ -20454,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 @@ -20529,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(); @@ -20579,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 { @@ -20601,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(); @@ -20747,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 { @@ -20764,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/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/views/callgraph.rs b/crates/aft/src/views/callgraph.rs index 7ac648bd6b..a1d2f59567 100644 --- a/crates/aft/src/views/callgraph.rs +++ b/crates/aft/src/views/callgraph.rs @@ -70,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, ) @@ -291,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() @@ -343,11 +352,45 @@ 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, String>; + fn acquire(&self) -> Result, CallgraphViewError>; fn record_change(&self, path: &Path); fn record_overflow(&self) {} fn record_cookie(&self, _: &Path) {} @@ -380,7 +423,7 @@ fn has_unpublished_changes(snapshot: &Snapshot) -> bool { } impl CallgraphReaderSource for CheckoutCallgraphSource { - fn acquire(&self) -> Result, String> { + 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 @@ -464,12 +507,13 @@ impl CallgraphReaderSource for CheckoutCallgraphSource { .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) + return Err(error.reason.into()) } Err(_) => { // A generation installed from the durable pointer (after a @@ -478,7 +522,10 @@ impl CallgraphReaderSource for CheckoutCallgraphSource { self.bridge .adapter .open_generation(&self.access, snapshot.generation()) - .map_err(|error| error.to_string())?; + .map_err(|error| CallgraphViewError { + busy: error.reason.starts_with(DERIVED_STORE_BUSY), + reason: error.to_string(), + })?; self.bridge .adapter .reader(&self.access, &snapshot) @@ -628,7 +675,7 @@ impl CallgraphView { /// 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, String> { + pub(crate) fn reader(&self) -> Result, CallgraphViewError> { let _query = self .query .lock() @@ -813,6 +860,9 @@ mod tests { 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") diff --git a/crates/aft/src/views/mod.rs b/crates/aft/src/views/mod.rs index 72825f1057..2762d828ff 100644 --- a/crates/aft/src/views/mod.rs +++ b/crates/aft/src/views/mod.rs @@ -17,10 +17,7 @@ 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; 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/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) { From b05a4f76383490b9d8c69c2eb3d6ace9747e777a Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Tue, 27 Oct 2026 04:17:08 +0100 Subject: [PATCH 064/100] mason: port linked-worktree and restart call-graph tests to the checkout view route The on-demand worktree tests asserted the v1 route: answering from the main checkout's published graph, reporting building until a lazy v1 build finished. On the view route a worktree always answers at once from its own v3 view (the shared base's sealed donor avoids rebuilding it). Ported: linked_worktree_{with_its_own_commit_is_never_answered_from_main_graph, stops_answering_from_main_graph_after_main_moves, with_a_graph_from_an_earlier_ process_defers_its_next_publication, defers_and_builds_on_first_query_when_main_ is_not_bound} now assert an immediate answer from the worktree's own view equal to a cold build (lazy_query_builds_own_graph); edit_reports_building_then_ answers_from_its_own_graph -> linked_worktree_edit_answers_from_its_own_view_at_ once; concurrent_first_queries_start_one_call_graph_build asserts both answer and one sealed base build; main_checkout_still_builds_its_call_graph_at_bind and busy_readiness_defers_revalidation_without_a_cold_rebuild keep their v1 publication assertions and read the view route; views_restart_dirty_source_ reports_working_tree_refresh_until_publication -> views_restart_dirty_source_ answers_the_working_tree_at_once. Retired (the old route itself): linked_worktree_answers_from_main_graph_ without_building_its_own and linked_worktree_waits_for_a_locked_main_graph_ instead_of_building, with their unused helpers (lazy_lock_derived, lazy_wait_for_own_graph, inspect current_view_projection_store_for_test). Co-authored-by: alfonso-aft <289616620+alfonso-aft@users.noreply.github.com> (cherry picked from commit dbdd5480bdc6af8a67e95042ee8f88b4ad8cf605) --- crates/aft/src/commands/configure.rs | 271 ++++----------------------- crates/aft/src/inspect/manager.rs | 10 - crates/aft/src/inspect/mod.rs | 4 +- 3 files changed, 41 insertions(+), 244 deletions(-) diff --git a/crates/aft/src/commands/configure.rs b/crates/aft/src/commands/configure.rs index ed71962461..4a841022e1 100644 --- a/crates/aft/src/commands/configure.rs +++ b/crates/aft/src/commands/configure.rs @@ -11460,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!( @@ -11999,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) { @@ -12871,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"); @@ -12937,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( @@ -12965,10 +12868,6 @@ mod tests { "edited_caller" ) .is_ok()); - assert_eq!( - fixture.borrower.lazy_callgraph_builds_scheduled_for_test(), - 1 - ); } #[test] @@ -13054,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] @@ -13063,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(|_| { @@ -13075,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 { @@ -13108,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 @@ -13150,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] @@ -13166,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( @@ -13264,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(); @@ -13433,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(); @@ -13493,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); } 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, From 0c14ef1b9a50b1c7e6b902d79bc8a243faa4cf33 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Tue, 27 Oct 2026 04:42:35 +0100 Subject: [PATCH 065/100] Search-quality descriptor for views step G Co-authored-by: alfonso-aft <289616620+alfonso-aft@users.noreply.github.com> --- .../land-views-g-donors-callgraph.json | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 benchmarks/aft-search/slice-descriptors/land-views-g-donors-callgraph.json 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." +} From 16238c089db11bbf2fb3dc75773e02a56dc3afb4 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Tue, 27 Oct 2026 00:59:41 +0100 Subject: [PATCH 066/100] mason: register tree-keyed base identity and tree proof format Co-authored-by: alfonso-aft <289616620+alfonso-aft@users.noreply.github.com> (cherry picked from commit f3c935edb548a56d523b24ce4263582e584cd53a) (cherry picked from commit 7d2bc52efa5ff3e0beee60fca0e913b60ea6f873) --- docs/design/views-v3-format.md | 112 ++++++++++++++++++++++++++++++++- 1 file changed, 111 insertions(+), 1 deletion(-) diff --git a/docs/design/views-v3-format.md b/docs/design/views-v3-format.md index 0cf7312021..36072d87f9 100644 --- a/docs/design/views-v3-format.md +++ b/docs/design/views-v3-format.md @@ -565,7 +565,9 @@ CREATE TABLE sweep_state (singleton INTEGER PRIMARY KEY CHECK(singleton=1), 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-`. + 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 @@ -653,6 +655,7 @@ 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; 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*. | @@ -716,6 +719,113 @@ 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, From 05104a9d8dd772ff1e8b4fd478a9f8d1c172615b Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Tue, 27 Oct 2026 01:01:03 +0100 Subject: [PATCH 067/100] mason: compute full index trees and Git blob object IDs Co-authored-by: alfonso-aft <289616620+alfonso-aft@users.noreply.github.com> (cherry picked from commit 775d4d79aa7b2c4bd5a82d7a2e111abe475d2990) (cherry picked from commit 9883ddd97801ac60c636add8a52646ad51a5bd39) --- crates/aft/src/git_index_reader.rs | 292 +++++++++++++++++++++++++++++ 1 file changed, 292 insertions(+) diff --git a/crates/aft/src/git_index_reader.rs b/crates/aft/src/git_index_reader.rs index 4de1a275fd..4d96150718 100644 --- a/crates/aft/src/git_index_reader.rs +++ b/crates/aft/src/git_index_reader.rs @@ -21,6 +21,40 @@ pub enum ObjectFormat { } 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, @@ -473,6 +507,110 @@ fn parse_index( }) } +/// 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. @@ -1219,6 +1357,160 @@ mod tests { .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); From 6eb2f04a99416a2ca376f56a518db6fa72f87403 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Tue, 27 Oct 2026 01:09:21 +0100 Subject: [PATCH 068/100] mason: tree-keyed base identity, proof file and receipt fast path (wip, untested) Co-authored-by: alfonso-aft <289616620+alfonso-aft@users.noreply.github.com> (cherry picked from commit 3362271a7a4527309ca2a8b8aa316364a46ca631) (cherry picked from commit 46dfb79978c5202645cf71ec5c77566def3a2c84) --- crates/aft/src/git_index_reader.rs | 18 +- crates/aft/src/views/base_refs.rs | 148 +++++++++- crates/aft/src/views/base_refs_tests.rs | 135 +++++++++ crates/aft/src/views/first_load.rs | 31 +- crates/aft/src/views/mod.rs | 1 + crates/aft/src/views/tree_base.rs | 348 ++++++++++++++++++++++ crates/aft/src/views/tree_base_tests.rs | 373 ++++++++++++++++++++++++ crates/aft/src/views/verification.rs | 320 ++++++++++++++++++++ 8 files changed, 1362 insertions(+), 12 deletions(-) create mode 100644 crates/aft/src/views/tree_base.rs create mode 100644 crates/aft/src/views/tree_base_tests.rs diff --git a/crates/aft/src/git_index_reader.rs b/crates/aft/src/git_index_reader.rs index 4d96150718..f7a17cbed6 100644 --- a/crates/aft/src/git_index_reader.rs +++ b/crates/aft/src/git_index_reader.rs @@ -1434,7 +1434,11 @@ mod tests { )) ); git(dir.path(), &["update-index", "--force-remove", "new"], &[]); - git(dir.path(), &["update-index", "--skip-worktree", "skip"], &[]); + git( + dir.path(), + &["update-index", "--skip-worktree", "skip"], + &[], + ); assert_eq!( tree(&dir), Err(TreeRefusal::Exception( @@ -1442,8 +1446,16 @@ mod tests { EntryException::SkipWorktree )) ); - git(dir.path(), &["update-index", "--no-skip-worktree", "skip"], &[]); - git(dir.path(), &["update-index", "--assume-unchanged", "plain"], &[]); + git( + dir.path(), + &["update-index", "--no-skip-worktree", "skip"], + &[], + ); + git( + dir.path(), + &["update-index", "--assume-unchanged", "plain"], + &[], + ); assert_eq!( tree(&dir), Err(TreeRefusal::Exception( diff --git a/crates/aft/src/views/base_refs.rs b/crates/aft/src/views/base_refs.rs index eb700352f9..244eaf3dae 100644 --- a/crates/aft/src/views/base_refs.rs +++ b/crates/aft/src/views/base_refs.rs @@ -46,9 +46,17 @@ use super::ViewStore; pub const MEMBERSHIP_DIGEST_TAG: &[u8] = b"aft/views-v3/base-membership/1"; /// Domain tag of the effective-identity digest, version 1. pub const IDENTITY_DIGEST_TAG: &[u8] = b"aft/views-v3/base-identity/1"; -/// Membership-keyed base scopes. Slice 4v's tree-keyed scopes will use a -/// different prefix; a scope is never relabelled from one to the other. +/// 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. @@ -148,8 +156,9 @@ impl TaggedHasher { /// entry kind, mode, content hash, size and resolution-input flag, in path /// order. Readiness is excluded, so readiness successors keep the digest. /// -/// Slice 4 bases are membership-keyed: a manifest that claims a HEAD tree or -/// any `git_oid`, or carries composition sections, is not a valid base. +/// 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( @@ -229,6 +238,115 @@ 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`]. @@ -240,7 +358,18 @@ pub struct BaseIdentity { } 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 { @@ -250,6 +379,11 @@ impl BaseIdentity { }) } + /// 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(), @@ -363,9 +497,9 @@ fn expected_dir(storage: &Path, family: &str, scope: &str, stored: &str) -> Resu /// [`RESOLVE_ATTEMPTS`] times when the pointer or row moves; a missing base is /// reported unavailable, never replaced by another generation. pub fn resolve_base(storage: &Path, family: &str, scope: &str) -> Result { - if !scope.starts_with(BASE_SCOPE_PREFIX) { + if base_kind(scope).is_none() { return Err(BaseRefError::Corrupt(format!( - "{scope} is not a membership-keyed base scope" + "{scope} is not a base scope" ))); } for _ in 0..RESOLVE_ATTEMPTS { @@ -540,7 +674,7 @@ pub(crate) fn register_view_in( scope: &str, root: &Path, ) -> Result { - if scope.starts_with(BASE_SCOPE_PREFIX) { + if base_kind(scope).is_some() { return Err(BaseRefError::Corrupt(format!( "{scope} is reserved for bases" ))); diff --git a/crates/aft/src/views/base_refs_tests.rs b/crates/aft/src/views/base_refs_tests.rs index 531cf93845..6111f91b0a 100644 --- a/crates/aft/src/views/base_refs_tests.rs +++ b/crates/aft/src/views/base_refs_tests.rs @@ -227,6 +227,141 @@ fn a_membership_keyed_base_never_claims_tree_identity() { )); } +/// 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") + }; + // This reader resolves it with the 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(); diff --git a/crates/aft/src/views/first_load.rs b/crates/aft/src/views/first_load.rs index fa7e1bf553..f9e4bbf155 100644 --- a/crates/aft/src/views/first_load.rs +++ b/crates/aft/src/views/first_load.rs @@ -1693,6 +1693,8 @@ 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. @@ -1964,11 +1966,24 @@ impl CheckoutDriver { } 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::base_refs::resolve_base(&route.storage, &route.family, &expected.scope) { + match super::tree_base::resolve_preferring(tree_base, &route, &expected.scope) { Ok(base) => base, Err(_) => { - let paths: Vec<_> = manifest.entries().map(|(path, _)| path.clone()).collect(); match super::base_builder::build_base( super::base_builder::BaseBuildInput { storage: &route.storage, @@ -2459,6 +2474,14 @@ impl FirstLoadDriver for CheckoutDriver { ) }) .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( @@ -2642,6 +2665,10 @@ impl FirstLoadDriver for CheckoutDriver { } 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()); diff --git a/crates/aft/src/views/mod.rs b/crates/aft/src/views/mod.rs index 2762d828ff..ea1d3e378f 100644 --- a/crates/aft/src/views/mod.rs +++ b/crates/aft/src/views/mod.rs @@ -61,6 +61,7 @@ pub mod query_wait; pub mod semantic; pub mod semantic_arena; pub mod semantic_runtime; +mod tree_base; pub mod trigram; mod verification; diff --git a/crates/aft/src/views/tree_base.rs b/crates/aft/src/views/tree_base.rs new file mode 100644 index 0000000000..bb4a917f9e --- /dev/null +++ b/crates/aft/src/views/tree_base.rs @@ -0,0 +1,348 @@ +//! 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, +} + +/// Reads and proves the members, or `None` when any check fails. +fn prove(input: &TreeBaseInput<'_>, guard: &BindGuard) -> Option { + if !guard.policy.reusable { + return None; + } + let head_tree = std::str::from_utf8(guard.head_tree.as_deref()?.trim_ascii()).ok()?; + let format = ObjectFormat::from_hex_len(head_tree.len())?; + let index_path = guard.gitdir.as_ref()?.0.join("index"); + let IndexRead::Snapshot(snapshot) = read_index(&index_path, format, Default::default()) else { + return None; + }; + 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], _> = 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).ok()?); + let observed_ns = super::verification::now_ns(); + let before = NativeStat::read(&absolute).ok()?; + if !std::fs::symlink_metadata(&absolute).ok()?.is_file() { + return None; + } + let source = std::fs::read(&absolute).ok()?; + let after = NativeStat::read(&absolute).ok()?; + if before != after || before.size != source.len() as u64 { + return None; + } + let oid = format.blob_oid(&source); + if oid != entry.oid { + return None; + } + #[cfg(unix)] + if (before.mode & 0o111 != 0) != (entry.stat.mode == 0o100755) { + return None; + } + 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, + }, + ) + .ok()?; + 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); + } + (!manifest.is_empty()).then_some(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 { + let guard = BindGuard::capture(input.root, None).ok()?; + let mut proven = prove(&input, &guard)?; + let route = input.route; + let identity = BaseIdentity::of(&route.family, &proven.manifest).ok()?; + if identity.kind() != BaseKind::Tree { + return None; + } + if let Ok(base) = resolve_base(&route.storage, &route.family, &identity.scope) { + return Some(base); + } + let ClaimOutcome::Claimed(claim) = + claim_base(&route.storage, &route.family, &identity.scope, None).ok()? + else { + return None; + }; + match build(route, &identity, &guard, &mut proven, input.policy, &claim) { + Ok(()) => { + let base = resolve_base(&route.storage, &route.family, &identity.scope).ok(); + *claim_slot + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(claim); + base + } + Err(_) => { + let _ = claim.release(); + None + } + } +} + +/// 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))?; + 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..c11f345ab9 --- /dev/null +++ b/crates/aft/src/views/tree_base_tests.rs @@ -0,0 +1,373 @@ +//! 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) + ); + assert_current_bytes(&snapshot, root.path()); +} + +#[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/verification.rs b/crates/aft/src/views/verification.rs index 0f53132c47..f347740fb1 100644 --- a/crates/aft/src/views/verification.rs +++ b/crates/aft/src/views/verification.rs @@ -749,6 +749,326 @@ pub(crate) fn read_evidence( 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(&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); + fs::File::open(&staging)?.sync_all()?; + 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( + 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::*; From 546f1879b60783c3e9e155d99352b3b1b8b6be63 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Tue, 27 Oct 2026 01:18:14 +0100 Subject: [PATCH 069/100] mason: name tree proof refusals and keep published proofs unchanged Co-authored-by: alfonso-aft <289616620+alfonso-aft@users.noreply.github.com> (cherry picked from commit ed0172f1483cc180e4fe0a0fe789b4d25e556ff8) (cherry picked from commit 857d12274cb3e9dcbfd34050d155d7eebc6495f4) --- crates/aft/src/views/tree_base.rs | 72 +++++++++++++++++-------- crates/aft/src/views/tree_base_tests.rs | 44 +++++++++++++++ 2 files changed, 94 insertions(+), 22 deletions(-) diff --git a/crates/aft/src/views/tree_base.rs b/crates/aft/src/views/tree_base.rs index bb4a917f9e..5be32b17a0 100644 --- a/crates/aft/src/views/tree_base.rs +++ b/crates/aft/src/views/tree_base.rs @@ -48,21 +48,31 @@ struct ProvenMembers { format: ObjectFormat, } -/// Reads and proves the members, or `None` when any check fails. -fn prove(input: &TreeBaseInput<'_>, guard: &BindGuard) -> Option { +/// 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 None; + return Err("conversion_policy_not_reusable"); } - let head_tree = std::str::from_utf8(guard.head_tree.as_deref()?.trim_ascii()).ok()?; - let format = ObjectFormat::from_hex_len(head_tree.len())?; - let index_path = guard.gitdir.as_ref()?.0.join("index"); + 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 None; + return Err("index_not_coherent"); }; - if NativeStat::strict_read(&index_path).ok()? != guard.index.as_ref()?.0 - || to_hex(&index_tree(&snapshot).ok()?) != head_tree + if NativeStat::strict_read(&index_path).ok().as_ref() + != guard.index.as_ref().map(|(stat, _)| stat) { - return None; + 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 @@ -89,24 +99,28 @@ fn prove(input: &TreeBaseInput<'_>, guard: &BindGuard) -> Option } let absolute = input .root - .join(super::segment_store::rel_path_to_os(path).ok()?); + .join(super::segment_store::rel_path_to_os(path).map_err(|_| "unrepresentable_path")?); let observed_ns = super::verification::now_ns(); - let before = NativeStat::read(&absolute).ok()?; - if !std::fs::symlink_metadata(&absolute).ok()?.is_file() { - return None; + 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).ok()?; - let after = NativeStat::read(&absolute).ok()?; + 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 None; + return Err("member_changed_during_read"); } let oid = format.blob_oid(&source); if oid != entry.oid { - return None; + return Err("member_differs_from_index_blob"); } #[cfg(unix)] if (before.mode & 0o111 != 0) != (entry.stat.mode == 0o100755) { - return None; + return Err("member_mode_differs_from_index"); } let content = crate::blob_store::v2::ContentHash::of(&source); let planes = EntryPlanes { @@ -131,7 +145,7 @@ fn prove(input: &TreeBaseInput<'_>, guard: &BindGuard) -> Option planes, }, ) - .ok()?; + .map_err(|_| "invalid_manifest_entry")?; receipts.insert( path.clone(), BlobReceipt { @@ -148,7 +162,10 @@ fn prove(input: &TreeBaseInput<'_>, guard: &BindGuard) -> Option ); bytes.insert(path.clone(), source); } - (!manifest.is_empty()).then_some(ProvenMembers { + if manifest.is_empty() { + return Err("no_tracked_members"); + } + Ok(ProvenMembers { manifest, bytes, receipts, @@ -166,7 +183,7 @@ pub(crate) fn bootstrap( claim_slot: &Mutex>, ) -> Option { let guard = BindGuard::capture(input.root, None).ok()?; - let mut proven = prove(&input, &guard)?; + let mut proven = prove(&input, &guard).ok()?; let route = input.route; let identity = BaseIdentity::of(&route.family, &proven.manifest).ok()?; if identity.kind() != BaseKind::Tree { @@ -237,6 +254,17 @@ fn build( 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. + if super::ViewStore::existing_dir(dir.clone()) + .map(|store| store.current_generation_read_only()) + .transpose() + .map_err(|e| error(&e))? + .flatten() + .is_some() + { + return Ok(()); + } super::verification::write_tree_proof( storage, family, diff --git a/crates/aft/src/views/tree_base_tests.rs b/crates/aft/src/views/tree_base_tests.rs index c11f345ab9..127f423338 100644 --- a/crates/aft/src/views/tree_base_tests.rs +++ b/crates/aft/src/views/tree_base_tests.rs @@ -325,6 +325,50 @@ fn a_file_whose_bytes_no_longer_match_its_receipt_takes_the_full_check() { assert_current_bytes(&snapshot, root.path()); } +#[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())); + 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(); From 1889d040787bb448777a5bc85686e0040e61adda Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Tue, 27 Oct 2026 01:26:00 +0100 Subject: [PATCH 070/100] mason: report why a tree-keyed bootstrap is unavailable Co-authored-by: alfonso-aft <289616620+alfonso-aft@users.noreply.github.com> (cherry picked from commit 1fd271284572f785def2ecd7ba687ded8706018d) (cherry picked from commit 799e1a0649843f94351222f48acbc31336bca8f5) --- crates/aft/src/views/tree_base.rs | 31 +++++++++++++++---------- crates/aft/src/views/tree_base_tests.rs | 19 +++++++++++++++ 2 files changed, 38 insertions(+), 12 deletions(-) diff --git a/crates/aft/src/views/tree_base.rs b/crates/aft/src/views/tree_base.rs index 5be32b17a0..43e4df15df 100644 --- a/crates/aft/src/views/tree_base.rs +++ b/crates/aft/src/views/tree_base.rs @@ -182,32 +182,39 @@ pub(crate) fn bootstrap( input: TreeBaseInput<'_>, claim_slot: &Mutex>, ) -> Option { - let guard = BindGuard::capture(input.root, None).ok()?; - let mut proven = prove(&input, &guard).ok()?; + 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).ok()?; - if identity.kind() != BaseKind::Tree { - return None; - } + 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 Some(base); + return Ok(base); } let ClaimOutcome::Claimed(claim) = - claim_base(&route.storage, &route.family, &identity.scope, None).ok()? + claim_base(&route.storage, &route.family, &identity.scope, None) + .map_err(|e| e.to_string())? else { - return None; + 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).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(_) => { + Err(error) => { let _ = claim.release(); - None + Err(error) } } } diff --git a/crates/aft/src/views/tree_base_tests.rs b/crates/aft/src/views/tree_base_tests.rs index 127f423338..512cdaaabb 100644 --- a/crates/aft/src/views/tree_base_tests.rs +++ b/crates/aft/src/views/tree_base_tests.rs @@ -354,6 +354,25 @@ fn the_tree_proof_names_the_first_check_a_checkout_fails() { 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"], From f7c36c7ab3e0f35ebe7443d5971a67d1eec9d3e9 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Tue, 27 Oct 2026 01:29:25 +0100 Subject: [PATCH 071/100] mason: create the tree base member directory before writing its proof Co-authored-by: alfonso-aft <289616620+alfonso-aft@users.noreply.github.com> (cherry picked from commit 21536e8dceaf1fd798d813853f2216b81cbc33b1) (cherry picked from commit 7fd67ef4b2ec7d11bcac6fad520e196f01ffcbcb) --- crates/aft/src/views/tree_base.rs | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/crates/aft/src/views/tree_base.rs b/crates/aft/src/views/tree_base.rs index 43e4df15df..06005f026f 100644 --- a/crates/aft/src/views/tree_base.rs +++ b/crates/aft/src/views/tree_base.rs @@ -262,12 +262,11 @@ fn build( }) .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. - if super::ViewStore::existing_dir(dir.clone()) - .map(|store| store.current_generation_read_only()) - .transpose() + // 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))? - .flatten() .is_some() { return Ok(()); From 35dbea29dcc14efd62db7d1fb1dce1d32fc4a5c4 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Tue, 27 Oct 2026 01:32:04 +0100 Subject: [PATCH 072/100] mason: clarify which resolver the tree-base test exercises Co-authored-by: alfonso-aft <289616620+alfonso-aft@users.noreply.github.com> (cherry picked from commit 9ee68f4a022572214d60cbc3df9dffb7632244d3) (cherry picked from commit 7a5d796253743fa8159a67f559ace19e81cc3489) --- crates/aft/src/views/base_refs_tests.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/aft/src/views/base_refs_tests.rs b/crates/aft/src/views/base_refs_tests.rs index 6111f91b0a..d2aae788b4 100644 --- a/crates/aft/src/views/base_refs_tests.rs +++ b/crates/aft/src/views/base_refs_tests.rs @@ -331,7 +331,7 @@ fn an_older_membership_only_reader_refuses_a_published_tree_keyed_base() { else { panic!("first tree base publication lost") }; - // This reader resolves it with the tree identity. + // 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); From 60233df9af0c92cd336b9bdb909e08c413932e37 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Tue, 27 Oct 2026 01:38:44 +0100 Subject: [PATCH 073/100] mason: check only the unchanged member's readiness after an edit Co-authored-by: alfonso-aft <289616620+alfonso-aft@users.noreply.github.com> (cherry picked from commit eb65b7e2034f962d24ccc2e173ef7ab670d1994b) (cherry picked from commit 0d9c4bc08a0409d7ab7a2d475af6024182660353) --- crates/aft/src/views/tree_base_tests.rs | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/crates/aft/src/views/tree_base_tests.rs b/crates/aft/src/views/tree_base_tests.rs index 512cdaaabb..6e8898f971 100644 --- a/crates/aft/src/views/tree_base_tests.rs +++ b/crates/aft/src/views/tree_base_tests.rs @@ -322,7 +322,16 @@ fn a_file_whose_bytes_no_longer_match_its_receipt_takes_the_full_check() { snapshot.disk_state(&rel(FILES[0].0)), DiskState::of_bytes(&edited) ); - assert_current_bytes(&snapshot, root.path()); + // 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] From 7bf4075f877db8425728f7ec30f2754cdca455d1 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Tue, 27 Oct 2026 01:59:32 +0100 Subject: [PATCH 074/100] Search-quality descriptor for the tree-proven views bases Co-authored-by: alfonso-aft <289616620+alfonso-aft@users.noreply.github.com> (cherry picked from commit 12c67f89cb7addbcf0fb2105401602ac4774c6fc) --- .../slice-descriptors/land-views-tree-proven-bases.json | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 benchmarks/aft-search/slice-descriptors/land-views-tree-proven-bases.json 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" + ] +} From 00298b50eb6a5675fbdce515676bbf9baedad0a6 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Tue, 27 Oct 2026 07:59:31 +0100 Subject: [PATCH 075/100] Search-quality descriptor for views steps C to G Co-authored-by: alfonso-aft <289616620+alfonso-aft@users.noreply.github.com> --- .../land-views-steps-c-to-g.json | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 benchmarks/aft-search/slice-descriptors/land-views-steps-c-to-g.json 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." +} From 03c02ea91d952c9c6170c3d820f19b37fb46bc95 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Tue, 27 Oct 2026 08:11:10 +0100 Subject: [PATCH 076/100] Format the view-token bridge files and sort bridge exports (Biome) Co-authored-by: alfonso-aft <289616620+alfonso-aft@users.noreply.github.com> --- packages/aft-bridge/src/__tests__/view-token.test.ts | 4 +++- packages/aft-bridge/src/index.ts | 3 +-- packages/aft-bridge/src/view-token.ts | 4 +++- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/aft-bridge/src/__tests__/view-token.test.ts b/packages/aft-bridge/src/__tests__/view-token.test.ts index 10e22dc51b..c067978af4 100644 --- a/packages/aft-bridge/src/__tests__/view-token.test.ts +++ b/packages/aft-bridge/src/__tests__/view-token.test.ts @@ -11,7 +11,9 @@ test("view token uses a management request, not an agent tool", async () => { }, } as unknown as AftProjectTransport; expect(await mintViewToken(transport, "/future/root", "abc")).toEqual({ - token: "opaque", expires_ms: 100, advisory: true, + token: "opaque", + expires_ms: 100, + advisory: true, }); expect(calls).toEqual([["view_token", { root: "/future/root", expected_tree: "abc" }]]); }); diff --git a/packages/aft-bridge/src/index.ts b/packages/aft-bridge/src/index.ts index 3303a5a032..8881db863f 100644 --- a/packages/aft-bridge/src/index.ts +++ b/packages/aft-bridge/src/index.ts @@ -100,8 +100,6 @@ export { readBinaryIdentity, writeStampedFileDigest, } from "./binary-identity.js"; -export { mintViewToken, type ViewToken } from "./view-token.js"; - export type { BashCompletedPayload, BashLongRunningPayload, @@ -470,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 index ac040a42a4..50e3950657 100644 --- a/packages/aft-bridge/src/view-token.ts +++ b/packages/aft-bridge/src/view-token.ts @@ -17,7 +17,9 @@ export async function mintViewToken( expected_tree: expectedTree, }); if (!response.success) { - throw new Error(typeof response.message === "string" ? response.message : "View token mint failed"); + throw new Error( + typeof response.message === "string" ? response.message : "View token mint failed", + ); } const data = response; if (typeof data !== "object" || data === null) { From 1cd4c17500fe1b8d1e2a5ba3f4291ca0ae43ecdf Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Tue, 27 Oct 2026 08:46:10 +0100 Subject: [PATCH 077/100] mason: sync view staging files through a write-capable handle on Windows write_evidence, write_tree_proof and the base builder's derived .ref flushed their staging file with File::open(..).sync_all(). Windows' FlushFileBuffers refuses a read-only handle (os error 5), so every v3 worker publication carrying stat evidence, every tree proof and every base derived ref failed there. Use views::sync_file, which already opens the file for writing on Windows. Co-authored-by: alfonso-aft <289616620+alfonso-aft@users.noreply.github.com> --- crates/aft/src/views/base_builder.rs | 4 +++- crates/aft/src/views/verification.rs | 8 ++++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/crates/aft/src/views/base_builder.rs b/crates/aft/src/views/base_builder.rs index 006311675b..7b195bd488 100644 --- a/crates/aft/src/views/base_builder.rs +++ b/crates/aft/src/views/base_builder.rs @@ -425,7 +425,9 @@ impl BuildClaim { .view_dir() .join(format!(".derived-{generation}.ref.tmp")); std::fs::write(&temporary, owner.as_bytes())?; - std::fs::File::open(&temporary)?.sync_all()?; + // 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(()) diff --git a/crates/aft/src/views/verification.rs b/crates/aft/src/views/verification.rs index f347740fb1..624e6d6110 100644 --- a/crates/aft/src/views/verification.rs +++ b/crates/aft/src/views/verification.rs @@ -681,7 +681,9 @@ pub(crate) fn write_evidence( } tx.commit()?; drop(db); - fs::File::open(&staging)?.sync_all()?; + // 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 { @@ -862,7 +864,9 @@ pub(crate) fn write_tree_proof( } tx.commit()?; drop(db); - fs::File::open(&staging)?.sync_all()?; + // 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(()) From 3892a58c40af6ef0183f8990a6db0f7f15b9d8c5 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Tue, 27 Oct 2026 08:46:11 +0100 Subject: [PATCH 078/100] mason: move each git stat setting off its current value in the policy digest test Git for Windows' git init already records core.ignorecase=true and core.fileMode=false, so setting those values changed nothing and the digest rightly stayed the same. The test now picks, per key, a candidate value that differs from the key's current value, keeping its claim that every listed setting is covered by the digest. Co-authored-by: alfonso-aft <289616620+alfonso-aft@users.noreply.github.com> --- crates/aft/src/views/verification.rs | 30 ++++++++++++++++++++-------- 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/crates/aft/src/views/verification.rs b/crates/aft/src/views/verification.rs index 624e6d6110..4eeb0d71ca 100644 --- a/crates/aft/src/views/verification.rs +++ b/crates/aft/src/views/verification.rs @@ -1188,15 +1188,29 @@ mod tests { fn final_policy_digest_covers_git_stat_settings_and_include_targets() { let root = tempfile::tempdir().unwrap(); git(root.path(), &["init", "-q"]); - for (key, value) in [ - ("core.autocrlf", "true"), - ("core.eol", "crlf"), - ("core.symlinks", "false"), - ("core.ignorecase", "true"), - ("core.checkStat", "minimal"), - ("core.trustctime", "false"), - ("core.fileMode", "false"), + // `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}"); From 48f47926aae8be2ede54d47e129853e3fb07d86f Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Tue, 27 Oct 2026 08:46:22 +0100 Subject: [PATCH 079/100] mason: spell long view-store SQLite paths in \\?\ form on Windows Views-on derived call-graph databases and stat-evidence staging files live under blobs/v3//views/base-m1-<64 hex>/ with generation names of 100+ characters, so their paths (and SQLite's -journal/-wal sidecars) pass Windows' MAX_PATH and SQLite fails with 'unable to open database file'. IdentityConnection::open/open_with_flags and the verification sidecar opens now pass the path through db::lifecycle::sqlite_open_path, as TrackedConnection already does. Only paths within 12 characters of MAX_PATH change spelling; the identity registry canonicalizes keys, so both spellings file under one key. Co-authored-by: alfonso-aft <289616620+alfonso-aft@users.noreply.github.com> --- crates/aft/src/db/file_identity.rs | 21 ++++++++++++++++----- crates/aft/src/views/verification.rs | 9 +++++---- 2 files changed, 21 insertions(+), 9 deletions(-) diff --git a/crates/aft/src/db/file_identity.rs b/crates/aft/src/db/file_identity.rs index e526a9e034..8367010eb4 100644 --- a/crates/aft/src/db/file_identity.rs +++ b/crates/aft/src/db/file_identity.rs @@ -403,14 +403,17 @@ impl IdentityConnection { 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)) @@ -427,11 +430,19 @@ impl IdentityConnection { } 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; every plain path gets the long-path spelling. + 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/views/verification.rs b/crates/aft/src/views/verification.rs index 4eeb0d71ca..dab8142332 100644 --- a/crates/aft/src/views/verification.rs +++ b/crates/aft/src/views/verification.rs @@ -662,7 +662,8 @@ pub(crate) fn write_evidence( super::base_refs::fresh_nonce() )); let result = (|| -> Result<(), Box> { - let mut db = rusqlite::Connection::open(&staging)?; + // 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")?; @@ -705,7 +706,7 @@ pub(crate) fn read_evidence( guard: &BindGuard, ) -> Option> { let db = rusqlite::Connection::open_with_flags( - evidence_path(dir, generation), + crate::db::lifecycle::sqlite_open_path(&evidence_path(dir, generation)), rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY, ) .ok()?; @@ -836,7 +837,7 @@ pub(crate) fn write_tree_proof( super::base_refs::fresh_nonce() )); let result = (|| -> Result<(), Box> { - let mut db = rusqlite::Connection::open(&staging)?; + 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")?; @@ -882,7 +883,7 @@ pub(crate) fn write_tree_proof( /// 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( - dir.join(TREE_PROOF_FILE), + crate::db::lifecycle::sqlite_open_path(&dir.join(TREE_PROOF_FILE)), rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY, ) .ok()?; From ec6961485f108d7c86bb4b24872fdd849ad16169 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Tue, 27 Oct 2026 08:59:51 +0100 Subject: [PATCH 080/100] mason: sync sealed and cloned donors through a write-capable handle on Windows Sealing a base's derived graph and cloning a sealed donor flushed the file through File::open(..).sync_all(). Windows' FlushFileBuffers needs a writable handle, so every base donor seal failed with 'donor fsync: Access is denied (os error 5)' and no views-on call graph published. Use views::sync_file, which opens for writing on Windows and retries a briefly held sharing violation. Co-authored-by: alfonso-aft <289616620+alfonso-aft@users.noreply.github.com> --- crates/aft/src/views/donor.rs | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/crates/aft/src/views/donor.rs b/crates/aft/src/views/donor.rs index bc3b803d2a..f1258406cb 100644 --- a/crates/aft/src/views/donor.rs +++ b/crates/aft/src/views/donor.rs @@ -208,9 +208,9 @@ pub(crate) fn seal(database: &Path) -> Result { if crate::db::file_identity::open_connections(database) != 0 { return Err(error("seal", "the database is still open")); } - std::fs::File::open(database) - .and_then(|file| file.sync_all()) - .map_err(|e| error("fsync", e))?; + // 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)?; @@ -293,9 +293,7 @@ pub(crate) fn clone_sealed(donor: &Path, destination: &Path) -> Result Date: Tue, 27 Oct 2026 09:15:17 +0100 Subject: [PATCH 081/100] mason: join lexical-view member paths with native separators on Windows RelPath stores '/', and rel_path_to_os keeps it on Windows, so the lexical view produced paths like C:\...\project\src/a.rs: unlike the legacy index's spelling in grep/glob results, and unreadable under a \\?\ checkout root, where Windows takes '/' literally. The trigram view now joins member paths component by component. rel_path_to_os's return form is unchanged. Co-authored-by: alfonso-aft <289616620+alfonso-aft@users.noreply.github.com> --- crates/aft/src/views/trigram.rs | 66 ++++++++++++++++++++++++++------- 1 file changed, 52 insertions(+), 14 deletions(-) diff --git a/crates/aft/src/views/trigram.rs b/crates/aft/src/views/trigram.rs index 5765c01f3a..fbd3640619 100644 --- a/crates/aft/src/views/trigram.rs +++ b/crates/aft/src/views/trigram.rs @@ -27,6 +27,24 @@ 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. @@ -233,9 +251,8 @@ impl LexicalView { let DiskState::Present { content, size } = disk else { continue; }; - let absolute = self - .root - .join(rel_path_to_os(&path).map_err(|error| error.to_string())?); + 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, @@ -637,8 +654,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; @@ -925,7 +942,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 @@ -941,15 +958,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); } } } @@ -1025,8 +1042,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")); } @@ -1150,9 +1168,9 @@ pub fn materialize_v3_observed( let payload = match attachment { Some(attachment) => attachment.payload.clone(), None => { - let relative = rel_path_to_os(path).map_err(|e| plane_error(e.to_string()))?; - let bytes = - fs::read(root.join(relative)).map_err(|e| plane_error(e.to_string()))?; + 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")); } @@ -1557,6 +1575,26 @@ mod tests { .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) + ); + } + } + #[test] fn lexical_views_projection_matches_legacy_masks_ranks_and_membership() { let temp = tempfile::tempdir().unwrap(); From 70e61d4290892cb31815da7a915a088529ad94bf Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Tue, 27 Oct 2026 09:16:36 +0100 Subject: [PATCH 082/100] mason: clarify which IdentityConnection paths get the long-path spelling Co-authored-by: alfonso-aft <289616620+alfonso-aft@users.noreply.github.com> --- crates/aft/src/db/file_identity.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/aft/src/db/file_identity.rs b/crates/aft/src/db/file_identity.rs index 8367010eb4..22d06c9913 100644 --- a/crates/aft/src/db/file_identity.rs +++ b/crates/aft/src/db/file_identity.rs @@ -431,7 +431,8 @@ impl IdentityConnection { 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; every plain path gets the long-path spelling. + // 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:") { From 751f1d08f4ece43c7afe5ba6085a861998c044f0 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Tue, 27 Oct 2026 02:02:47 +0100 Subject: [PATCH 083/100] Stage the non-UTF-8 index path through git's index input so the test runs on macOS and Windows Co-authored-by: alfonso-aft <289616620+alfonso-aft@users.noreply.github.com> --- crates/aft/src/git_index_reader.rs | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/crates/aft/src/git_index_reader.rs b/crates/aft/src/git_index_reader.rs index f7a17cbed6..0135a0ec6a 100644 --- a/crates/aft/src/git_index_reader.rs +++ b/crates/aft/src/git_index_reader.rs @@ -1341,15 +1341,20 @@ mod tests { assert!(!stat.matches_stat(&observed)); } - #[cfg(unix)] #[test] fn real_git_preserves_non_utf8_paths() { - use std::ffi::OsStr; - use std::os::unix::ffi::OsStrExt; let dir = fixture(ObjectFormat::Sha1); let name = b"non-utf8-\xff"; - fs::write(dir.path().join(OsStr::from_bytes(name)), b"raw\n").unwrap(); - git(dir.path(), &["add", "."], &[]); + // 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 From 1d90f0984c1f465162e7ce64f0ce22a9bf73f745 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Tue, 27 Oct 2026 02:15:54 +0100 Subject: [PATCH 084/100] Format the non-UTF-8 index test Co-authored-by: alfonso-aft <289616620+alfonso-aft@users.noreply.github.com> --- crates/aft/src/git_index_reader.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/aft/src/git_index_reader.rs b/crates/aft/src/git_index_reader.rs index 0135a0ec6a..4cf199de00 100644 --- a/crates/aft/src/git_index_reader.rs +++ b/crates/aft/src/git_index_reader.rs @@ -1354,7 +1354,11 @@ mod tests { 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", "--add", "--index-info"], + &line, + ); git(dir.path(), &["update-index", "--index-version", "4"], &[]); assert!(snapshot(dir.path(), ObjectFormat::Sha1) .entries From 03b92ca08590e62e68b5b8c5d4e2dbe017687685 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Tue, 27 Oct 2026 10:16:43 +0100 Subject: [PATCH 085/100] mason: serve strict lexical binds without a steady watcher subscription A v3 CheckoutDriver whose bind had no steady watcher subscription (no watcher, an unproved or incomplete one, an unsupported filesystem, and every macOS checkout because FSEvents verified bind is off) marked each installed snapshot overflowed as soon as `installed_snapshot` was read. The lexical source read the snapshot that way right after its own strict bind, so every views-on grep, glob and aft_search answered internal_error "lexical view has unverified writes". The source now reads the cut it just installed, as the call-graph source already does. The overflow mark still sends the next bind down the full strict walk. A write that lands between install and read is re-bound up to three times before refusing. Test fixtures that still asserted the legacy search index with views on are updated to the views-on equivalent. Step F stopped the legacy loader and writer for views-on roots, so these assertions could no longer pass on any platform: - per_checkout_semantic_runtime: wait for a grep served from the lexical view instead of ctx.search_index().ready. - configure_normal_project_keeps_root_work_enabled: the trigram lane is the lexical view (lexical_views_enabled), not a legacy loader rx. - configure_cache_unavailable_walks...: the recovered trigram cache is the lexical view's blobs/v3//trigram.sqlite. No cache.bin is written. - legacy_semantic_import_runs_once...: enable the call graph so the views runtime that runs the import loads (F loads it only for a semantic or call-graph index). - state_commands_test: a views-on family is built under blobs/v3 by the first lexical query, so the wait sends a grep. - lexical_views_projection_matches_legacy...: open the view on the canonical root, as production does (macOS /var vs /private/var). Co-authored-by: alfonso-aft <289616620+alfonso-aft@users.noreply.github.com> --- crates/aft/src/commands/configure.rs | 36 +++++--- crates/aft/src/views/trigram.rs | 85 +++++++++++++++++-- .../per_checkout_semantic_runtime.rs | 23 +++-- .../tests/integration/state_commands_test.rs | 36 ++++++-- 4 files changed, 148 insertions(+), 32 deletions(-) diff --git a/crates/aft/src/commands/configure.rs b/crates/aft/src/commands/configure.rs index 4a841022e1..e7d60fed08 100644 --- a/crates/aft/src/commands/configure.rs +++ b/crates/aft/src/commands/configure.rs @@ -14266,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 } }))] }); @@ -14996,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 @@ -24579,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/views/trigram.rs b/crates/aft/src/views/trigram.rs index fbd3640619..46df429586 100644 --- a/crates/aft/src/views/trigram.rs +++ b/crates/aft/src/views/trigram.rs @@ -107,7 +107,14 @@ impl LexicalSnapshotSource for CheckoutLexicalSource { self.driver .install(&self.access, &snapshot, revision) .map_err(|error| error.to_string())?; - Ok(self.driver.installed_snapshot()) + // 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) { @@ -234,17 +241,31 @@ impl LexicalView { 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.acquire()?; - if snapshot.pending_intent().next().is_some() || snapshot.watcher() != WatcherState::Healthy - { - return Err("lexical view has unverified writes".into()); - } + let snapshot = self.verified_snapshot()?; let mut source = None; let mut rows = Vec::new(); for (path, disk) in snapshot.membership() { @@ -1595,10 +1616,60 @@ mod tests { } } + /// 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(); - let root = temp.path().join("project"); + // 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(); 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()); } From f6ecda350ec71b6282eede084fe6f9872340992b Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Tue, 27 Oct 2026 06:49:31 +0100 Subject: [PATCH 086/100] mason: stop v1 view publication on views-routed roots, route symbol prewarm and parent readers through v3 views A views-routed root (views on, heavy work allowed, cache available, no explicitly installed checkout query runtime) now runs none of the legacy views-on writers: configure no longer opens, imports into or publishes the content-addressed v1 view, view_publication::schedule_with_reason returns without scheduling, prepare_view_paths refuses (a backstop for direct callers), and watcher batches skip the legacy call-graph store refresh. Watcher changes, overflows and cookies already reach the v3 checkout views; an ignore-rule change now also marks them for a full reconcile. Symbol prewarm on a routed root follows the files its lexical v3 view verified and reads and writes the family's v3 symbol plane by content (lookup, then parse and put only misses). The legacy symbols.bin is neither read nor written. AppContext::symbol_view_route_count counts files served from or stored into the plane. Parent folders read children's v3 publications: the call graph from the child's .callgraph member (answers carry as_of: the child's PublishedFrontier), and grep/glob from the child's v3 lexical member when it has one (a views-off child still publishes only cache.bin, which keeps its old path). A foreign reader applies no file changes on top of a view publication; only the child's own session claims freshness. ParentSession::view_route_counts counts routed answers. Tests: views_routed_root_publishes_no_v1_view_and_puts_no_legacy_blobs (red with the stops removed: blobs/ and views/ written), views_routed_symbol_prewarm_stores_then_reuses_v3_symbol_rows, parent_grep_and_glob_read_views_on_children_from_their_v3_lexical_view. Ported: parent_callgraph_reads_child_views_and_protects_served_generation -> parent_callgraph_reads_child_v3_views_and_answers_as_of_their_publication (v1 journal-file and v1 generation-sweep parts dropped: the parent no longer reads the v1 view); parent_folder_survives_a_real_process_restart now waits for the child's v3 lexical view instead of a cache.bin a views-on child no longer writes. Retired: parent_names_a_child_view_without_callgraph_as_disabled (its subject was a keyless v1 generation; a v3 child with the call graph off publishes no call-graph member, reported as not published). Co-authored-by: alfonso-aft <289616620+alfonso-aft@users.noreply.github.com> --- crates/aft/src/commands/configure.rs | 364 +++++++++++++++++- crates/aft/src/context.rs | 44 ++- crates/aft/src/executor/view_publication.rs | 6 + crates/aft/src/runtime_drain.rs | 16 +- crates/aft/src/search_index.rs | 24 ++ crates/aft/src/views/parent.rs | 94 ++++- crates/aft/src/views/parent/engine.rs | 4 + crates/aft/src/views/parent/query.rs | 23 +- crates/aft/src/views/parent/worker.rs | 265 ++++++------- crates/aft/src/views/read.rs | 5 - crates/aft/src/views/trigram.rs | 75 ++++ .../aft/tests/integration/per_checkout_9.rs | 338 ++++++++-------- 12 files changed, 906 insertions(+), 352 deletions(-) diff --git a/crates/aft/src/commands/configure.rs b/crates/aft/src/commands/configure.rs index e7d60fed08..df20090999 100644 --- a/crates/aft/src/commands/configure.rs +++ b/crates/aft/src/commands/configure.rs @@ -3079,6 +3079,167 @@ fn delay_symbol_prewarm_for_debug() { thread::sleep(Duration::from_millis(delay_ms)); } +/// What one views-routed symbol prewarm did with the files it was given. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +struct SymbolViewPrewarm { + /// Already in the symbol cache for the file's current modification time. + cached: u64, + /// Served from rows the family's v3 symbol plane already held. + reused: u64, + /// Parsed here and stored into the v3 symbol plane. + stored: u64, + /// Left for an ordinary on-demand parse: unreadable, changed since the + /// lexical view verified it, or not parseable. + skipped: u64, +} + +/// Pre-warms the symbol cache of a views-routed root from the files its +/// lexical v3 view verified, on a background thread. Symbols come from the +/// family's v3 symbol plane, keyed by content, so a file another checkout of +/// the family already parsed is not parsed again; only misses are parsed and +/// then stored for every checkout. The legacy symbol cache file is neither +/// read nor written. +fn spawn_symbol_view_prewarm(ctx: &AppContext, snapshot: &crate::search_index::SearchIndexSnapshot) { + let files = snapshot.file_entries().to_vec(); + let symbol_cache = ctx.symbol_cache(); + let generation = symbol_cache + .read() + .map(|cache| cache.generation()) + .unwrap_or_default(); + let storage = ctx.storage_paths().artifacts; + let root = ctx.canonical_cache_root(); + let family = ctx.memoized_artifact_cache_key(&root); + let routes = ctx.symbol_view_route_counter(); + let session_id = log_ctx::current_session(); + let spawned = thread::Builder::new() + .name("aft-symbol-view-prewarm".to_owned()) + .spawn(move || { + log_ctx::with_session(session_id, || { + #[cfg(debug_assertions)] + delay_symbol_prewarm_for_debug(); + let report = prewarm_symbol_cache_from_view( + &storage, + &family, + &symbol_cache, + generation, + &files, + ); + routes.fetch_add(report.reused + report.stored, Ordering::Relaxed); + slog_info!( + "pre-warmed symbol cache from the v3 symbol plane: {} reused, {} stored, {} cached, {} skipped root={}", + report.reused, + report.stored, + report.cached, + report.skipped, + root.display() + ); + }); + }); + if let Err(error) = spawned { + slog_warn!("symbol view prewarm thread failed to start: {}", error); + } +} + +fn prewarm_symbol_cache_from_view( + storage: &Path, + family: &str, + symbol_cache: &SharedSymbolCache, + generation: u64, + files: &[crate::search_index::FileEntry], +) -> SymbolViewPrewarm { + use crate::views::symbol_store; + let mut report = SymbolViewPrewarm::default(); + let producer = symbol_store::SymbolProducer::current(); + let mut rows = Vec::new(); + // False once a newer configure reset the cache; that configure runs its + // own prewarm. + let insert = |file: &crate::search_index::FileEntry, symbols: Vec| { + let Ok(mut cache) = symbol_cache.write() else { + return false; + }; + if cache.generation() != generation { + return false; + } + cache.insert_for_generation( + generation, + file.path.clone(), + file.modified, + file.size, + file.content_hash, + symbols, + ); + true + }; + for file in files { + if symbol_cache + .read() + .is_ok_and(|cache| cache.contains_path_with_mtime(&file.path, file.modified)) + { + report.cached += 1; + continue; + } + let Some(lang) = crate::parser::detect_language(&file.path) else { + continue; + }; + let content = crate::blob_store::v2::ContentHash::from_bytes(*file.content_hash.as_bytes()); + match symbol_store::lookup(storage, family, content, &file.path) { + Ok(Some(symbols)) => { + if !insert(file, symbols.as_ref().clone()) { + break; + } + report.reused += 1; + continue; + } + Ok(None) => {} + Err(error) => { + slog_debug!( + "v3 symbol lookup failed path={}: {}", + file.path.display(), + error + ); + } + } + // Only the bytes the lexical view verified may be stored under its + // content hash; a file that moved on is left for an on-demand parse. + let source = match crate::parser::read_source(&file.path) { + Ok(source) if blake3::hash(source.as_bytes()) == file.content_hash => source, + _ => { + report.skipped += 1; + continue; + } + }; + let symbols = crate::parser::parse_source_with_cached_parser(&file.path, &source, lang) + .and_then(|tree| crate::parser::extract_symbols_from_tree(&source, &tree, lang)); + let Ok(symbols) = symbols else { + report.skipped += 1; + continue; + }; + let language = symbol_store::language_id(lang); + let key = symbol_store::SymbolKey { + content, + language: language.clone(), + producer, + } + .family_key(); + rows.push(( + key, + symbol_store::encode_payload(&producer, &language, &symbols), + )); + if !insert(file, symbols) { + break; + } + report.stored += 1; + } + if !rows.is_empty() { + if let Err(error) = symbol_store::put(storage, family, &rows) { + slog_warn!("storing prewarmed symbols in the v3 symbol plane failed: {}", error); + report.skipped += report.stored; + report.stored = 0; + } + } + report +} + fn walk_semantic_project_files_bounded( root: &Path, max_files: usize, @@ -8604,7 +8765,12 @@ fn run_configure_maintenance_unit_inner( && ctx.config().views.enabled && !job.root_too_broad { - if ctx.config().indexes.semantic || ctx.config().indexes.callgraph { + if ctx.views_route_root() { + // Every read of this root goes through its v3 checkout + // views; the v1 view is neither opened, imported into + // nor published. + ctx.drop_v1_view_runtime(); + } else if ctx.config().indexes.semantic || ctx.config().indexes.callgraph { finish_configure_view_load( ctx, prepare_configure_view(&ConfigureViewInput::capture(ctx, job), job), @@ -8612,8 +8778,15 @@ fn run_configure_maintenance_unit_inner( } else { ctx.clear_view_runtime(); } - if let Some(Err(error)) = ctx.lexical_view_snapshot("bind") { - slog_warn!("lexical checkout view unavailable: {}", error); + match ctx.lexical_view_snapshot("bind") { + Some(Err(error)) => { + slog_warn!("lexical checkout view unavailable: {}", error); + } + // The symbol prewarm follows the files the view verified. + Some(Ok(snapshot)) if ctx.views_route_root() => { + spawn_symbol_view_prewarm(ctx, &snapshot); + } + _ => {} } } else { ctx.clear_view_runtime(); @@ -15758,6 +15931,191 @@ mod tests { ); } + /// What a root's storage holds outside the v3 namespace: content-addressed + /// v1 views and v1/v2 blob stores, plus the legacy symbol cache file. + fn legacy_views_on_writes(storage: &Path) -> Vec { + let mut found = Vec::new(); + for (dir, keep) in [("blobs", Some("v3")), ("views", None), ("symbols", None)] { + if let Ok(entries) = std::fs::read_dir(storage.join(dir)) { + for entry in entries.flatten() { + let name = entry.file_name().to_string_lossy().into_owned(); + if keep != Some(name.as_str()) { + found.push(format!("{dir}/{name}")); + } + } + } + } + found.sort(); + found + } + + #[test] + fn views_routed_root_publishes_no_v1_view_and_puts_no_legacy_blobs() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("project"); + init_git_fixture(&root); + for (name, text) in [ + ("a.ts", "export function target() { return 1; }\n"), + ( + "b.ts", + "import { target } from './a';\nexport function caller() { return target(); }\n", + ), + ] { + std::fs::write(root.join(name), text).unwrap(); + } + git_commit_all(&root, "graph"); + let root = std::fs::canonicalize(root).unwrap(); + let storage = temp.path().join("storage"); + let ctx = test_context(); + assert!( + handle_configure_for_test(&configure_callgraph_views(&root, &storage, true), &ctx) + .success + ); + assert!(ctx.views_route_root()); + // The view load stage runs here; a routed root opens no v1 view. + super::drain_deferred_configure_maintenance(&ctx); + let callers = |ctx: &AppContext| { + crate::commands::callers::handle_callers( + &RawRequest { + id: "routed-callers".into(), + command: "callers".into(), + lsp_hints: None, + session_id: None, + params: json!({"file": root.join("a.ts"), "symbol": "target", "depth": 1}), + }, + ctx, + ) + }; + let answer = callers(&ctx); + assert!(answer.success, "{:?}", answer.data); + assert!(ctx.callgraph_view_route_count() > 0); + + // An edit, a watcher overflow and every publication trigger (a queued + // path publication and a full one) leave the v1 view and the legacy + // blob stores untouched. + 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")); + ctx.record_checkout_watcher_overflow(); + let _ = crate::executor::view_publication::schedule( + &ctx, + BTreeSet::from([b"b.ts".to_vec()]), + true, + ); + let _ = crate::executor::view_publication::schedule(&ctx, BTreeSet::new(), true); + let answer = callers(&ctx); + assert!(answer.success, "{:?}", answer.data); + assert_eq!(legacy_views_on_writes(&storage), Vec::::new()); + assert_eq!(ctx.view_publication_attempts_for_test(), 0); + assert!(ctx.view_runtime_snapshot().is_none()); + let direct = ctx.publish_view_paths(BTreeSet::new(), true); + assert!( + direct.as_ref().is_err_and(|error| error + .to_string() + .contains(crate::context::VIEW_PUBLICATION_STOPPED_ON_ROUTED_ROOT)), + "{direct:?}" + ); + assert_eq!(legacy_views_on_writes(&storage), Vec::::new()); + assert!( + crate::blob_store::v3::family_dir(&storage, &ctx.memoized_artifact_cache_key(&root)) + .unwrap() + .is_dir() + ); + + // Views off is not a routed root. + let off = test_context(); + assert!( + handle_configure_for_test( + &configure_callgraph_views(&root, &temp.path().join("off-storage"), false), + &off + ) + .success + ); + assert!(!off.views_route_root()); + } + + #[test] + fn views_routed_symbol_prewarm_stores_then_reuses_v3_symbol_rows() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("project"); + init_git_fixture(&root); + std::fs::write( + root.join("lib.rs"), + "pub fn alpha() -> u32 { 1 }\npub struct Beta;\n", + ) + .unwrap(); + std::fs::write(root.join("notes.md"), "# notes\n").unwrap(); + let root = std::fs::canonicalize(root).unwrap(); + let storage = temp.path().join("storage"); + let ctx = test_context(); + assert!( + handle_configure_for_test(&configure_callgraph_views(&root, &storage, true), &ctx) + .success + ); + super::drain_deferred_configure_maintenance(&ctx); + let deadline = Instant::now() + Duration::from_secs(60); + while ctx.symbol_view_route_count() == 0 { + assert!( + Instant::now() < deadline, + "the routed symbol prewarm never ran" + ); + std::thread::sleep(Duration::from_millis(20)); + } + let family = ctx.memoized_artifact_cache_key(&root); + let lib = root.join("lib.rs"); + let bytes = std::fs::read(&lib).unwrap(); + let stored = crate::views::symbol_store::lookup( + &storage, + &family, + crate::blob_store::v2::ContentHash::of(&bytes), + &lib, + ) + .unwrap() + .expect("the prewarm stored lib.rs's symbols in the v3 symbol plane"); + let names: Vec<_> = stored.iter().map(|symbol| symbol.name.clone()).collect(); + assert!(names.contains(&"alpha".to_owned()), "{names:?}"); + let modified = std::fs::metadata(&lib).unwrap().modified().unwrap(); + assert!(ctx + .symbol_cache() + .read() + .unwrap() + .contains_path_with_mtime(&lib, modified)); + assert_eq!(legacy_views_on_writes(&storage), Vec::::new()); + + // A fresh cache over the same files reuses the stored rows: nothing + // is parsed or stored again. + let snapshot = ctx.lexical_view_snapshot("bind").unwrap().unwrap(); + let fresh: crate::parser::SharedSymbolCache = + Arc::new(std::sync::RwLock::new(crate::parser::SymbolCache::new())); + let report = super::prewarm_symbol_cache_from_view( + &storage, + &family, + &fresh, + 0, + snapshot.file_entries(), + ); + let parsed_files = snapshot + .file_entries() + .iter() + .filter(|file| crate::parser::detect_language(&file.path).is_some()) + .count() as u64; + assert!(parsed_files >= 1); + assert_eq!( + report, + super::SymbolViewPrewarm { + reused: parsed_files, + ..Default::default() + } + ); + assert!(fresh + .read() + .unwrap() + .contains_path_with_mtime(&lib, modified)); + } + fn configure_search_with_max_file_size( root: &std::path::Path, storage: &std::path::Path, diff --git a/crates/aft/src/context.rs b/crates/aft/src/context.rs index b9b33e696d..5ae302b57b 100644 --- a/crates/aft/src/context.rs +++ b/crates/aft/src/context.rs @@ -273,6 +273,10 @@ const VIEW_DISK_LIMITS_OFF: u64 = u64::MAX; /// checkout's semantic index has finished loading. pub(crate) const VIEW_PUBLICATION_WAITING_FOR_SEMANTIC_INDEX: &str = "semantic view publication is waiting for the semantic index"; +/// Refusal of a content-addressed v1 view publication on a root whose views +/// route every read through its v3 checkout views. +pub(crate) const VIEW_PUBLICATION_STOPPED_ON_ROUTED_ROOT: &str = + "v1 view publication is stopped: this root's views read its v3 checkout views"; /// The publication phase that takes the query pin for a newly assembled /// generation. Before it, publication walks source paths, assembles the /// generation, and opens the view to load its manifest. @@ -4563,6 +4567,9 @@ pub struct AppContext { lexical_routes: [AtomicU64; 3], callgraph_view: parking_lot::Mutex>>, callgraph_view_routes: AtomicU64, + /// Files whose symbols a views-routed root's prewarm reused from, or + /// stored into, the family's v3 symbol plane. + symbol_view_routes: Arc, /// 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 @@ -5416,6 +5423,7 @@ impl AppContext { lexical_routes: Default::default(), callgraph_view: parking_lot::Mutex::new(None), callgraph_view_routes: AtomicU64::new(0), + symbol_view_routes: Arc::default(), checkout_semantic: Arc::default(), view_disk_limits_next_ms, checkout_query_runtime: RwLock::new(None), @@ -9660,6 +9668,18 @@ impl AppContext { drop(retired); } + /// Drops this root's content-addressed v1 view runtime and nothing else: + /// the checkout drivers and the semantic lane keep running. + pub(crate) fn drop_v1_view_runtime(&self) { + self.release_view_publication_cache(); + let retired = self + .view_runtime + .write() + .unwrap_or_else(|error| error.into_inner()) + .take(); + drop(retired); + } + fn release_view_publication_cache(&self) { let view_dir = self .view_runtime @@ -9899,6 +9919,9 @@ impl AppContext { } let content_generation = self.configure_content_generation(); phase("manifest").map_err(|error| error.to_string())?; + if self.views_route_root() { + return Err(VIEW_PUBLICATION_STOPPED_ON_ROUTED_ROOT.to_owned()); + } if self.retire_deleted_view_root() { return Err("view publication root was deleted".to_owned()); } @@ -12122,9 +12145,18 @@ impl AppContext { /// 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 { + self.views_route_root() && self.config().indexes.callgraph + } + + /// True when views serve this root through its own v3 checkout views + /// (lexical, call graph and semantic) rather than the content-addressed + /// v1 view or the legacy stores. Such a root runs none of the legacy + /// views-on writers: no v1 view publication or import, no v1 blob puts + /// and no legacy semantic import. An explicitly installed checkout query + /// runtime keeps the older route. + pub(crate) fn views_route_root(&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() @@ -12217,6 +12249,16 @@ impl AppContext { self.callgraph_view_routes.load(Ordering::Relaxed) } + /// Files a views-routed symbol prewarm served from or stored into the + /// v3 symbol plane. + pub fn symbol_view_route_count(&self) -> u64 { + self.symbol_view_routes.load(Ordering::Relaxed) + } + + pub(crate) fn symbol_view_route_counter(&self) -> Arc { + Arc::clone(&self.symbol_view_routes) + } + pub(crate) fn search_exact_memo( &self, ) -> Arc { diff --git a/crates/aft/src/executor/view_publication.rs b/crates/aft/src/executor/view_publication.rs index 26dea2937a..a0ad328c5b 100644 --- a/crates/aft/src/executor/view_publication.rs +++ b/crates/aft/src/executor/view_publication.rs @@ -691,6 +691,12 @@ pub(crate) fn schedule_with_reason( if ctx.retire_deleted_view_root() { return Ok(()); } + // A root whose reads all go through its v3 checkout views has no reader + // of the content-addressed v1 view, so nothing is assembled, put into + // the v1 blob store or published for it. + if ctx.views_route_root() { + return Ok(()); + } let target = CURRENT.with(|slot| slot.borrow().clone()); let Some(target) = target.filter(|target| std::ptr::eq(ctx, target.ctx.as_ref())) else { if ctx diff --git a/crates/aft/src/runtime_drain.rs b/crates/aft/src/runtime_drain.rs index 60b61cf15e..808813de4e 100644 --- a/crates/aft/src/runtime_drain.rs +++ b/crates/aft/src/runtime_drain.rs @@ -2728,10 +2728,13 @@ fn watcher_path_is_generated_for_callgraph(ctx: &AppContext, path: &Path) -> boo } fn published_view_owns_callgraph_writes(ctx: &AppContext) -> bool { - ctx.config().views.enabled - && ctx - .view_runtime_snapshot() - .is_some_and(|view| view.generation.is_some() && view.manifest.is_some()) + // A views-routed root reads its call graph from its v3 view only, so no + // legacy store refresh runs for it. + ctx.views_route_root() + || ctx.config().views.enabled + && ctx + .view_runtime_snapshot() + .is_some_and(|view| view.generation.is_some() && view.manifest.is_some()) } fn enqueue_legacy_callgraph_refresh_for_watcher( @@ -4073,6 +4076,11 @@ pub fn drain_watcher_events_bounded(ctx: &AppContext, max_paths: usize) -> Drain if ctx.config().views.enabled { // Changed ignore rules change which files the checkout has. ctx.note_checkout_changed_for_callgraph(); + if ctx.views_route_root() { + // The v3 checkout views reconcile their membership + // in full at their next read. + ctx.record_checkout_watcher_overflow(); + } ctx.reset_view_publication_retry(); state.view_publication_paths.clear(); state.view_publication_full = true; diff --git a/crates/aft/src/search_index.rs b/crates/aft/src/search_index.rs index d4d9d2aff1..385b7512b5 100644 --- a/crates/aft/src/search_index.rs +++ b/crates/aft/src/search_index.rs @@ -883,6 +883,12 @@ impl SearchIndexSnapshot { .and_then(|id| self.files.get(*id as usize)) .map(|file| file.modified) } + + /// The files this snapshot answers for, with the size, modification time + /// and content hash it verified for each. + pub(crate) fn file_entries(&self) -> &[FileEntry] { + &self.files + } /// Number of unique trigrams in the combined base index and delta postings. pub fn trigram_count(&self) -> usize { let base_count = self.base.as_ref().map_or(0, |base| base.lookup.len()); @@ -1906,6 +1912,24 @@ impl SearchIndex { } } + /// A read-only index over a snapshot projected from a published view. + /// It is never written or persisted; its holder replaces it with the + /// next publication instead of applying file changes to it. + pub(crate) fn from_view_snapshot(snapshot: SearchIndexSnapshot) -> Self { + Self { + base: snapshot.base, + delta: snapshot.delta, + files: snapshot.files, + path_to_id: snapshot.path_to_id, + ready: snapshot.ready, + project_root: snapshot.project_root, + max_file_size: snapshot.max_file_size, + file_trigram_count: snapshot.file_trigram_count, + unindexed_files: snapshot.unindexed_files, + ..Self::new() + } + } + pub fn build_with_limit(root: &Path, max_file_size: u64) -> Self { let started = Instant::now(); let cache_dir = transient_search_cache_dir(root); diff --git a/crates/aft/src/views/parent.rs b/crates/aft/src/views/parent.rs index a904effe62..39e4690604 100644 --- a/crates/aft/src/views/parent.rs +++ b/crates/aft/src/views/parent.rs @@ -5,16 +5,18 @@ //! serves grep, glob, `aft_search`, the call graph tools and inspect from the //! children's own published indexes, read-only: //! -//! - **trigram:** the child's persisted `cache.bin`, opened through the -//! borrowed read-only loader and reconciled with the child's files in RAM. -//! With views on, a child's trigram plane is still this legacy artifact -//! (the content-addressed view's own trigram file is empty), so the parent -//! reads the artifact the child actually publishes. +//! - **trigram:** the child's published v3 lexical view when its sessions run +//! with views on, answered as of that publication (the child's own session +//! is the only one that claims freshness, so the parent applies no file +//! changes on top). A child whose sessions run with views off publishes +//! only its persisted `cache.bin`, which the parent opens through the +//! borrowed read-only loader and keeps current in RAM from its watcher. //! - **semantic:** the child's per-checkout (v2) view, opened through a //! registry *reader* registration (a reader marker, never a view) and a read //! marker on the generation it serves. -//! - **callgraph:** the child's content-addressed view generation, protected -//! by a query pin (a read marker in the child's view directory). +//! - **callgraph:** the child's published v3 call-graph view, protected by a +//! read marker on the generation it serves. Answers name the publication +//! they are as of (`as_of`). //! //! Everything slow happens on the session's background worker: finding the //! children, loading each child plane once, following the children's later @@ -465,6 +467,9 @@ pub fn activate( ) -> bool { let root = std::fs::canonicalize(root).unwrap_or_else(|_| root.to_path_buf()); let mut registry = lock(registry()); + let trigram_policy = crate::blob_store::v2::TrigramPolicy { + max_file_size: ctx.config().search_index_max_file_size, + }; let Some(planes) = registry.prepared.remove(&root) else { if let Some(session) = registry.sessions.get(&root) { session.release(context_id(ctx)); @@ -474,7 +479,11 @@ pub fn activate( if let Some(existing) = registry.sessions.get(&root) { // A rebind with the same planes and storage joins the warm session // instead of loading every child again. - if !existing.stopped() && existing.planes == planes && existing.storage == storage { + if !existing.stopped() + && existing.planes == planes + && existing.storage == storage + && existing.trigram_policy == trigram_policy + { existing.hold(Holder::of(ctx)); return true; } @@ -484,6 +493,7 @@ pub fn activate( planes, storage.to_path_buf(), semantic.clone(), + trigram_policy, Holder::of(ctx), ); if let Some(previous) = registry.sessions.insert(root, session) { @@ -706,7 +716,48 @@ impl Plane { pub(crate) struct TrigramChild { pub index: Arc, - artifact: BorrowedArtifactGeneration, + source: TrigramSource, +} + +/// Where a child's trigram snapshot came from. +#[derive(Clone)] +pub(crate) enum TrigramSource { + /// The persisted `cache.bin` of a child whose sessions run with views + /// off. The parent keeps it current from its own watcher and backstop. + Legacy(BorrowedArtifactGeneration), + /// The child's published v3 lexical view. Answers are as of the child's + /// publication ([`PublishedFrontier`]); only the child's own session + /// claims freshness, so the parent applies no file changes on top. + View(PublishedChild), +} + +/// A v3 generation a child's own session published, as a foreign reader +/// sees it: its name and the frontier its answers are as of. +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct PublishedChild { + pub generation: String, + pub frontier: Option, +} + +impl PublishedChild { + fn of(generation: &super::snapshot::ComposedGeneration) -> Self { + Self { + generation: generation.name().to_owned(), + frontier: generation.published().cloned(), + } + } + + /// The `as_of` object a parent answer carries for this child. + pub(crate) fn as_of(&self, child: &Child) -> serde_json::Value { + let frontier = self.frontier.clone().unwrap_or_default(); + serde_json::json!({ + "child": child.display(), + "generation": self.generation, + "published_at_ms": frontier.published_at_ms, + "delta_cut_version": frontier.delta_cut_version, + "watcher_epoch": frontier.watcher_epoch, + }) + } } pub(crate) struct SemanticChild { @@ -717,7 +768,7 @@ pub(crate) struct SemanticChild { pub(crate) struct CallgraphChild { pub store: Arc, - generation: String, + pub published: PublishedChild, } /// One child repository of a parent folder. @@ -747,8 +798,6 @@ pub struct Child { /// Size and mtime of the child's semantic view pointer database and its /// WAL when the worker last read the pointer; see `worker::refresh_semantic`. pub(crate) semantic_pointer_stat: Mutex>>, - /// HEAD fingerprint, cached against the `.git/HEAD` bytes it came from. - head: Mutex, String)>>, loads: AtomicUsize, applied: AtomicUsize, } @@ -774,7 +823,6 @@ impl Child { ignore_files: Mutex::new(worker::IgnoreCache::default()), reader: Mutex::new(None), semantic_pointer_stat: Mutex::new(None), - head: Mutex::new(None), loads: AtomicUsize::new(0), applied: AtomicUsize::new(0), } @@ -828,7 +876,7 @@ impl Child { pub fn callgraph_generation(&self) -> Option { read(&self.callgraph) .ready() - .map(|callgraph| callgraph.generation.clone()) + .map(|callgraph| callgraph.published.generation.clone()) } /// The semantic generation this session holds a read marker on. @@ -877,6 +925,11 @@ pub struct ParentSession { pub(crate) planes: RequestedPlanes, storage: PathBuf, semantic_config: crate::config::SemanticBackendConfig, + /// The trigram policy of the children's v3 views: the producer their + /// lexical and call-graph members are published under. + trigram_policy: crate::blob_store::v2::TrigramPolicy, + /// Parent call-graph and lexical answers served from children's v3 views. + view_routes: [AtomicU64; 2], /// `None` until the worker's first full discovery walk finishes. discovery: RwLock>>, children: RwLock>>, @@ -899,6 +952,7 @@ impl ParentSession { planes: RequestedPlanes, storage: PathBuf, semantic_config: crate::config::SemanticBackendConfig, + trigram_policy: crate::blob_store::v2::TrigramPolicy, holder: Holder, ) -> Arc { let root_for_backstop = root.clone(); @@ -907,6 +961,8 @@ impl ParentSession { planes, storage, semantic_config, + trigram_policy, + view_routes: Default::default(), discovery: RwLock::new(None), children: RwLock::new(Vec::new()), semantic: RwLock::new(if planes.semantic { @@ -951,6 +1007,16 @@ impl ParentSession { read(&self.discovery).clone() } + /// Parent answers served from the children's v3 views, ordered as call + /// graph, then grep and glob. + pub fn view_route_counts(&self) -> [u64; 2] { + std::array::from_fn(|index| self.view_routes[index].load(Ordering::Relaxed)) + } + + pub(crate) fn note_view_route(&self, index: usize) { + self.view_routes[index].fetch_add(1, Ordering::Relaxed); + } + /// Completed worker rounds; the first one is the initial load. pub fn rounds(&self) -> usize { self.rounds.load(Ordering::SeqCst) diff --git a/crates/aft/src/views/parent/engine.rs b/crates/aft/src/views/parent/engine.rs index 74a6ae5d7a..545d4d9599 100644 --- a/crates/aft/src/views/parent/engine.rs +++ b/crates/aft/src/views/parent/engine.rs @@ -553,6 +553,10 @@ pub(super) mod tests { callgraph: false, }, semantic_config: Default::default(), + trigram_policy: crate::blob_store::v2::TrigramPolicy { + max_file_size: crate::config::Config::default().search_index_max_file_size, + }, + view_routes: Default::default(), discovery: RwLock::new(None), children: RwLock::new(Vec::new()), semantic: RwLock::new(Plane::Loading), diff --git a/crates/aft/src/views/parent/query.rs b/crates/aft/src/views/parent/query.rs index e5adc43301..5e24f8075b 100644 --- a/crates/aft/src/views/parent/query.rs +++ b/crates/aft/src/views/parent/query.rs @@ -15,7 +15,12 @@ use crate::pattern_compile::CompiledPattern; use crate::protocol::{RawRequest, Response}; use crate::search_index::{GrepResult, IndexStatus, PathFilters}; -use super::{display_relative, read, Child, ParentSession, Plane}; +use super::{display_relative, read, Child, ParentSession, Plane, TrigramSource}; + +/// Index of [`ParentSession::view_route_counts`] for call-graph answers. +const CALLGRAPH_ROUTE: usize = 0; +/// Index of [`ParentSession::view_route_counts`] for grep and glob answers. +const LEXICAL_ROUTE: usize = 1; /// Commands a parent session answers itself from its children. const ROUTED_COMMANDS: &[&str] = &[ @@ -177,6 +182,7 @@ pub(crate) fn grep_fan_out( let mut gaps = Gaps::default(); let mut phases = GrepExecutionPhaseTimings::default(); let mut scope_has_files = false; + let mut from_views = false; for root in &scope.roots { let search_root = &root.search_root; if search_root.is_file() || !search_root.starts_with(session.root()) { @@ -209,6 +215,7 @@ pub(crate) fn grep_fan_out( }; let snapshot = trigram.index.snapshot(); if snapshot.has_file_in_scope(search_root) { + from_views |= matches!(trigram.source, TrigramSource::View(_)); snapshots.push(snapshot); } } @@ -252,6 +259,9 @@ pub(crate) fn grep_fan_out( gaps.scope(&session, search_root); } phases.indexed_scope_has_files = Some(scope_has_files || !gaps.is_empty()); + if from_views { + session.note_view_route(LEXICAL_ROUTE); + } let result = if results.is_empty() { GrepResult { index_sources_unavailable: false, @@ -301,6 +311,7 @@ pub(crate) fn glob_fan_out( gaps: Vec::new(), }; let mut gaps = Gaps::default(); + let mut from_views = false; for search_root in search_roots { let search_root = std::fs::canonicalize(search_root).unwrap_or_else(|_| search_root.clone()); @@ -325,12 +336,16 @@ pub(crate) fn glob_fan_out( .index .snapshot() .glob_profiled(pattern, &search_root, false); + from_views |= matches!(trigram.source, TrigramSource::View(_)); answer.scope_has_files |= scope_has_files; answer.entries_visited += visited; answer.files.extend(files); } gaps.scope(&session, &search_root); } + if from_views { + session.note_view_route(LEXICAL_ROUTE); + } answer.files.sort(); answer.files.dedup(); answer.scope_has_files |= !gaps.is_empty(); @@ -572,6 +587,12 @@ fn callgraph( match value { Ok(Ok(mut value)) => { prefix_paths(&mut value, &child.relative); + // The child's own session is the only one that claims its graph + // is current; the parent answers as of the child's publication. + if let Some(object) = value.as_object_mut() { + object.insert("as_of".into(), callgraph.published.as_of(&child)); + } + session.note_view_route(CALLGRAPH_ROUTE); Response::success(&req.id, value) } Ok(Err(response)) => response, diff --git a/crates/aft/src/views/parent/worker.rs b/crates/aft/src/views/parent/worker.rs index d175961b2c..c3dc99ead0 100644 --- a/crates/aft/src/views/parent/worker.rs +++ b/crates/aft/src/views/parent/worker.rs @@ -15,7 +15,7 @@ use super::super::semantic::SemanticProducer; use super::super::snapshot::LiveDelta; use super::{ lock, read, write, CallgraphChild, Child, Discovery, ParentSemantic, ParentSession, Plane, - SemanticChild, TrigramChild, DEFAULT_MAX_CHILD_REPOS, + PublishedChild, SemanticChild, TrigramChild, TrigramSource, DEFAULT_MAX_CHILD_REPOS, }; /// Longest the worker sleeps between two checks of its stop flag, holders @@ -227,6 +227,19 @@ fn refresh_trigram(session: &ParentSession, child: &Child) { return; } }; + // A child whose sessions run with views on publishes its lexical index + // only as its v3 lexical view; the parent reads that publication. + match open_child_lexical_view(session, child, family) { + Ok(None) => {} + Ok(Some(generation)) => { + install_view_trigram(session, child, family, &generation); + return; + } + Err(error) => { + *write(&child.trigram) = Plane::Gap(format!("lexical view unavailable: {error}")); + return; + } + } let artifact = crate::readonly_artifacts::search_index_artifact_generation_with_key( family, Some(&session.storage), @@ -238,7 +251,7 @@ fn refresh_trigram(session: &ParentSession, child: &Child) { }; if read(&child.trigram) .ready() - .is_some_and(|current| current.artifact == artifact) + .is_some_and(|current| matches!(¤t.source, TrigramSource::Legacy(current) if *current == artifact)) { // Same artifact: changes since it was read arrive as watcher paths // and through the backstop, never by reading the artifact again. @@ -284,11 +297,69 @@ fn refresh_trigram(session: &ParentSession, child: &Child) { child, TrigramChild { index: Arc::new(index), - artifact, + source: TrigramSource::Legacy(artifact), }, ); } +/// The child's published v3 lexical view, or `None` when its sessions never +/// published one (a child bound with views off, or never bound). +fn open_child_lexical_view( + session: &ParentSession, + child: &Child, + family: &str, +) -> Result>, String> { + let producers = Producers { + trigram: session.trigram_policy.fingerprint_hex(), + semantic: None, + callgraph: super::super::semantic_runtime::UNREGISTERED_PRODUCER.into(), + }; + super::super::base_refs::open_foreign_v3(&session.storage, family, &child.scope, &producers) + .map_err(|error| error.to_string()) +} + +/// Installs the trigram snapshot of a child's published v3 lexical view +/// unless the parent already serves that generation. +fn install_view_trigram( + session: &ParentSession, + child: &Child, + family: &str, + generation: &Arc, +) { + let published = PublishedChild::of(generation); + if read(&child.trigram) + .ready() + .is_some_and(|current| matches!(¤t.source, TrigramSource::View(current) if *current == published)) + { + return; + } + child.loads.fetch_add(1, Ordering::SeqCst); + match super::super::trigram::project_published( + &child.root, + &session.storage, + family, + session.trigram_policy, + generation, + ) { + Ok(snapshot) => { + // Changes made since the child published arrive with the child's + // next publication; the parent never applies them itself. + lock(&child.pending).clear(); + install_trigram( + session, + child, + TrigramChild { + index: Arc::new(crate::search_index::SearchIndex::from_view_snapshot(snapshot)), + source: TrigramSource::View(published), + }, + ); + } + Err(error) => { + *write(&child.trigram) = Plane::Gap(format!("lexical view unreadable: {error}")); + } + } +} + /// Applies the watcher paths queued for `child`. fn apply_pending(child: &Arc) { let paths = std::mem::take(&mut *lock(&child.pending)); @@ -313,6 +384,9 @@ fn apply_paths(child: &Arc, paths: BTreeSet) { // Not loaded yet: the first load reconciles the whole child anyway. return; }; + if matches!(current.source, TrigramSource::View(_)) { + return; + } let mut index = (*current.index).clone(); let mut applied = 0usize; for path in &paths { @@ -332,10 +406,9 @@ fn apply_paths(child: &Arc, paths: BTreeSet) { return; } child.applied.fetch_add(applied, Ordering::SeqCst); - let artifact = current.artifact.clone(); let session_less = TrigramChild { index: Arc::new(index), - artifact, + source: current.source.clone(), }; *write(&child.trigram) = Plane::Ready(Arc::new(session_less)); super::engine::sync_trigram(child); @@ -348,6 +421,9 @@ fn backstop(child: &Arc) { let Some(current) = read(&child.trigram).ready().cloned() else { return; }; + if matches!(current.source, TrigramSource::View(_)) { + return; + } let walked = crate::search_index::walk_project_files( &child.root, &crate::search_index::PathFilters::default(), @@ -392,7 +468,7 @@ fn apply_walked_paths(child: &Arc, paths: BTreeSet) { child.applied.fetch_add(paths.len(), Ordering::SeqCst); *write(&child.trigram) = Plane::Ready(Arc::new(TrigramChild { index: Arc::new(index), - artifact: current.artifact.clone(), + source: current.source.clone(), })); super::engine::sync_trigram(child); } @@ -476,74 +552,29 @@ fn belongs_to_child(child: &Child, path: &Path) -> bool { // Call graph, inspect and semantic publications // --------------------------------------------------------------------------- -/// Bytes that change whenever the checkout's HEAD commit can have changed: the -/// HEAD file plus the ref file it names (and `packed-refs`), with their mtimes. -fn head_probe(root: &Path) -> Vec { - let git = root.join(".git"); - let git_dir = if git.is_file() { - std::fs::read_to_string(&git) - .ok() - .and_then(|text| { - text.trim() - .strip_prefix("gitdir:") - .map(|dir| root.join(dir.trim())) - }) - .unwrap_or(git) - } else { - git - }; - let mut probe = Vec::new(); - let mut add = |path: &Path| { - if let Ok(bytes) = std::fs::read(path) { - probe.extend_from_slice(&bytes); - } - if let Ok(modified) = std::fs::metadata(path).and_then(|meta| meta.modified()) { - probe.extend_from_slice(format!("{modified:?}").as_bytes()); +/// Follows the child's published v3 call-graph view. The parent answers as of +/// that publication and holds its read marker; a child whose sessions never +/// published one is a named gap. +fn refresh_callgraph(session: &ParentSession, child: &Child) { + let family = match child.family(&session.storage) { + Ok(family) => family, + Err(error) => { + *write(&child.callgraph) = Plane::Gap(error); + return; } - probe.push(0); }; - let head = git_dir.join("HEAD"); - add(&head); - let head_text = std::fs::read_to_string(&head).unwrap_or_default(); - if let Some(reference) = head_text.trim().strip_prefix("ref:") { - let reference = reference.trim(); - add(&git_dir.join(reference)); - // A linked worktree keeps branch refs in the common directory. - if let Ok(common) = std::fs::read_to_string(git_dir.join("commondir")) { - let common = git_dir.join(common.trim()); - add(&common.join(reference)); - add(&common.join("packed-refs")); - } - } - add(&git_dir.join("packed-refs")); - probe -} - -/// The fingerprint that names the child's view generations for its current -/// HEAD (a hash of HEAD's tracked tree). `git ls-tree` runs only when HEAD or -/// the ref it names changed since the last call. -fn head_fingerprint(child: &Child) -> Result { - let probe = head_probe(&child.root); - if let Some((cached_probe, fingerprint)) = lock(&child.head).as_ref() { - if *cached_probe == probe { - return Ok(fingerprint.clone()); - } - } - let entries = - crate::alias::head_tree_entries(&child.root).map_err(|error| error.to_string())?; - let fingerprint = super::super::assembly::head_tree_fingerprint(&entries); - *lock(&child.head) = Some((probe, fingerprint.clone())); - Ok(fingerprint) -} - -fn refresh_callgraph(session: &ParentSession, child: &Child) { - let view_dir = session.storage.join("views").join(&child.scope); - let Some(store) = super::super::ViewStore::existing_dir(view_dir.clone()) else { - *write(&child.callgraph) = - Plane::Gap("no call graph view has been published for this repository yet".into()); - return; + let producers = Producers { + trigram: session.trigram_policy.fingerprint_hex(), + semantic: None, + callgraph: super::super::callgraph::PRODUCER.into(), }; - let generation = match store.current_generation_read_only() { + let scope = super::super::callgraph::view_scope(&child.root); + let generation = match super::super::base_refs::open_foreign_v3( + &session.storage, + family, + &scope, + &producers, + ) { Ok(Some(generation)) => generation, Ok(None) => { *write(&child.callgraph) = @@ -551,100 +582,34 @@ fn refresh_callgraph(session: &ParentSession, child: &Child) { return; } Err(error) => { - *write(&child.callgraph) = Plane::Gap(format!("call graph view unreadable: {error}")); + *write(&child.callgraph) = Plane::Gap(format!("call graph view unavailable: {error}")); return; } }; - let head = match head_fingerprint(child) { - Ok(head) => head, - Err(error) => { - *write(&child.callgraph) = Plane::Gap(format!("HEAD unreadable: {error}")); - return; - } - }; - if !super::super::generation_matches_head(&generation, &head) { - // The child's sessions have not yet published a view for its current - // HEAD. Serving the older graph would answer for another commit. - *write(&child.callgraph) = - Plane::Gap("the call graph view for the current HEAD is still pending".into()); - return; - } + let published = PublishedChild::of(&generation); if read(&child.callgraph) .ready() - .is_some_and(|current| current.generation == generation) + .is_some_and(|current| current.published == published) { return; } - // Protect before touching: the read marker on this generation exists - // before its derived database is opened, and the view's current-generation - // pointer is re-read afterwards, so a generation the child replaced in - // between is never adopted. - let pin = match crate::pins::QueryPin::acquire(&view_dir, &generation) { - Ok(pin) => Arc::new(pin), - Err(error) => { - *write(&child.callgraph) = Plane::Gap(format!("call graph view not pinned: {error}")); - return; - } - }; - if store - .current_generation_read_only() - .ok() - .flatten() - .as_deref() - != Some(generation.as_str()) - { + let Some(route) = generation.v3_route().cloned() else { + *write(&child.callgraph) = Plane::Gap("call graph view has no v3 route".into()); return; - } - // A linked worktree builds its own graph only when one of its own queries - // needs it. Until then the main checkout's graph answers for it while the - // published files match; otherwise the child's call-graph plane reports - // that the graph has not been built yet. - let family = match child.family(&session.storage) { - Ok(family) => family, - Err(error) => { - *write(&child.callgraph) = Plane::Gap(error); - return; - } }; - if !super::super::read::derived_built(&view_dir, &generation) { - let main = store.load_manifest(&generation).ok().map(|manifest| { - super::super::read::main_checkout_callgraph( - &session.storage, - &child.root, - family, - &generation, - &manifest, - ) - }); - *write(&child.callgraph) = match main { - Some(super::super::read::MainCheckoutGraph::Answer(opened, _)) => { - child.loads.fetch_add(1, Ordering::SeqCst); - Plane::Ready(Arc::new(CallgraphChild { - store: Arc::new(opened), - generation, - })) - } - Some(super::super::read::MainCheckoutGraph::Busy) => Plane::Gap( - "the main checkout's call graph is locked by a publication; retrying".into(), - ), - Some(super::super::read::MainCheckoutGraph::Unavailable) | None => { - Plane::Gap(super::super::read::CALLGRAPH_NOT_BUILT.into()) - } - }; - return; - } - match super::super::read::open_published_callgraph( + match crate::callgraph_store::ReadonlyCallGraphStore::open_pinned_derived( child.root.clone(), family.to_owned(), - view_dir, - &generation, - Some(pin), + route.member_dir(), + generation.name(), ) { - Ok(opened) => { + Ok(store) => { child.loads.fetch_add(1, Ordering::SeqCst); + // The store keeps the generation, and with it the generation's + // read marker, for as long as the parent serves it. *write(&child.callgraph) = Plane::Ready(Arc::new(CallgraphChild { - store: Arc::new(opened), - generation, + store: Arc::new(store.retain_generation(generation)), + published, })); } Err(error) => { diff --git a/crates/aft/src/views/read.rs b/crates/aft/src/views/read.rs index ce019fcd58..a5ce8e57e1 100644 --- a/crates/aft/src/views/read.rs +++ b/crates/aft/src/views/read.rs @@ -238,11 +238,6 @@ pub(crate) fn open_published_callgraph( ReadonlyCallGraphStore::open_manifest_view(project_root, family, view_dir, generation, pin) } -/// Why a linked worktree's own generation cannot answer call-graph queries: -/// it was published without a derived database, which is built on first use. -pub(crate) const CALLGRAPH_NOT_BUILT: &str = - "this checkout's call graph has not been built yet; the first query that needs it starts the build"; - /// True when `generation` has a call-graph database to read: its own derived /// file, or the checkout-wide `derived.sqlite` that older publications used, /// when that holds a ready graph. A linked worktree publishes generations diff --git a/crates/aft/src/views/trigram.rs b/crates/aft/src/views/trigram.rs index 46df429586..f8218addb1 100644 --- a/crates/aft/src/views/trigram.rs +++ b/crates/aft/src/views/trigram.rs @@ -347,6 +347,81 @@ impl LexicalView { } } +/// The query snapshot of a lexical view generation another session +/// published, for a foreign reader such as a parent folder. It answers as of +/// that publication: entries come from the generation alone, and a file whose +/// trigram payload the generation does not name as ready is refused rather +/// than read from the disk. +pub(crate) fn project_published( + root: &Path, + storage: &Path, + family: &str, + policy: TrigramPolicy, + generation: &super::snapshot::ComposedGeneration, +) -> Result { + use super::segment_store::PayloadSource; + let mut source = None; + let mut rows = Vec::new(); + for (path, entry) in generation.entries() { + let super::manifest_v2::EntryV2::Regular { content, size, .. } = entry else { + continue; + }; + let (content, size) = (*content, *size); + let absolute = root.join(rel_path_to_os(path).map_err(|error| error.to_string())?); + let key = crate::blob_store::v2::TrigramKey { content, policy }.family_key(); + if generation.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( + storage, + 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()))?; + let payload = TrigramPayload::decode(&bytes).map_err(|error| error.to_string())?; + // The file's own modification time when it still exists; a foreign + // reader does not verify the bytes, so a moved-on file still answers + // with the published content's postings. + let modified = fs::metadata(&absolute) + .and_then(|metadata| metadata.modified()) + .unwrap_or(std::time::SystemTime::UNIX_EPOCH); + rows.push(( + crate::search_index::FileEntry { + path: absolute, + size, + modified, + 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( + root, + policy.max_file_size, + rows, + ), + ) +} + #[cfg(test)] pub(crate) struct BaseBuilderTestSource { root: PathBuf, diff --git a/crates/aft/tests/integration/per_checkout_9.rs b/crates/aft/tests/integration/per_checkout_9.rs index 412fb84f12..52b4d13012 100644 --- a/crates/aft/tests/integration/per_checkout_9.rs +++ b/crates/aft/tests/integration/per_checkout_9.rs @@ -420,33 +420,51 @@ fn wait_trigram(ctx: &AppContext, root: &Path, storage: &Path) { } } -fn callgraph_view(root: &Path, storage: &Path) -> aft::views::ViewStore { - aft::views::ViewStore::open(storage, &aft::path_identity::project_scope_key(root)).unwrap() -} - -/// Waits until the child's content-addressed view publishes a generation -/// other than `previous`. -fn wait_callgraph_view( - ctx: &AppContext, - root: &Path, - storage: &Path, - previous: Option<&str>, -) -> String { +/// Asks the child session for its call graph until it answers. The first +/// answer binds the child's v3 call-graph view and publishes it; a later one +/// publishes the edits made since. +fn child_callgraph_answer(ctx: &AppContext, root: &Path) -> Value { let deadline = Instant::now() + DEADLINE; loop { drain(ctx); aft::runtime_drain::drain_deferred_configure_maintenance(ctx); - if let Some(generation) = callgraph_view(root, storage).current_generation().unwrap() { + let answer = aft::commands::callers::handle_callers( + &request(json!({ + "id": "child-callers", + "command": "callers", + "file": root.join("src/lib.rs"), + "symbol": "target", + })), + ctx, + ); + if answer.success { + return data(answer); + } + assert!( + Instant::now() < deadline, + "child call graph view never answered: {}: {:?}", + root.display(), + answer.data + ); + thread::sleep(Duration::from_millis(50)); + } +} + +/// Waits until the parent serves a call-graph generation of `child` other +/// than `previous`, and returns it. +fn wait_parent_callgraph(child: &aft::views::parent::Child, previous: Option<&str>) -> String { + let deadline = Instant::now() + DEADLINE; + loop { + if let Some(generation) = child.callgraph_generation() { if Some(generation.as_str()) != previous { return generation; } } assert!( Instant::now() < deadline, - "child call graph view never published: {}", - root.display() + "the parent never served the child's call graph view" ); - thread::sleep(Duration::from_millis(50)); + thread::sleep(Duration::from_millis(20)); } } @@ -819,6 +837,68 @@ fn parent_backstop_applies_child_edits_and_never_writes_child_artifacts() { assert_eq!(session.children()[0].applied_paths(), 0); } +/// A child whose sessions run with views on publishes its lexical index only +/// as its v3 lexical view. The parent reads that publication: grep and glob +/// answer from it, and an edit the child has not published is not claimed by +/// the parent, which applies no file changes of its own on top. +#[test] +fn parent_grep_and_glob_read_views_on_children_from_their_v3_lexical_view() { + fast_refresh(); + let folder = folder(2); + std::fs::remove_file(folder.root.join("README.md")).unwrap(); + let mut expected_grep = BTreeSet::new(); + let mut children = Vec::new(); + for child in &folder.children { + let ctx = configure(child, &folder.storage, config_doc(true, TRIGRAM_ONLY, None)); + let prefix = child.strip_prefix(&folder.root).unwrap().to_path_buf(); + expected_grep.extend(grep_rows(&grep(&ctx, COMMON), child, &prefix)); + assert!(!trigram_artifact(child, &folder.storage).exists()); + children.push(ctx); + } + assert_eq!(expected_grep.len(), 4, "every child contributes two lines"); + + let parent = configure( + &folder.root, + &folder.storage, + config_doc(true, TRIGRAM_ONLY, None), + ); + let session = parent_session(&parent); + assert!(session.children().iter().all(|child| child.trigram_ready())); + let answer = grep(&parent, COMMON); + assert_eq!( + grep_rows(&answer, &folder.root, Path::new("")), + expected_grep, + "{answer:#}" + ); + assert_eq!(answer["complete"], true, "{answer:#}"); + assert!(session.view_route_counts()[1] >= 1); + let globbed = glob(&parent, "**/*.rs"); + assert_eq!(globbed["files"].as_array().map(Vec::len), Some(4), "{globbed:#}"); + let routed = session.view_route_counts()[1]; + assert!(routed >= 2, "{routed}"); + + // An edit no child session has published: the parent answers as of the + // child's publication rather than reading the file itself. + write( + &folder.children[1], + "src/fresh.rs", + "pub fn fresh() {}\n// UNPUBLISHED_PARENT_EDIT_NEEDLE\n", + ); + let rounds = session.rounds(); + assert!(session.wait_rounds(rounds + 2, DEADLINE)); + let unpublished = grep(&parent, "UNPUBLISHED_PARENT_EDIT_NEEDLE"); + assert_eq!( + grep_rows(&unpublished, &folder.root, Path::new("")), + BTreeSet::new(), + "{unpublished:#}" + ); + for child in session.children() { + assert_eq!(child.loads(), 1, "an unchanged publication is not reloaded"); + assert_eq!(child.applied_paths(), 0, "the parent applied an edit itself"); + } + drop(children); +} + /// The parent's own file watcher carries a child's edit to the parent within /// seconds, while the backstop stays at its multi-minute default: changes are /// event-driven, routed to the child that owns the path. @@ -901,12 +981,12 @@ fn parent_watcher_routes_child_edits_to_the_owning_child() { assert!(aft.shutdown().success()); } -/// The call graph is served from a child's view while the child's SQLite -/// journal files exist, paths carry the child prefix, a child without a view -/// and a file outside every child are named gaps, and the generation the -/// parent serves survives the child's generation sweep. +/// The call graph is served from a child's published v3 view: answers carry +/// the child prefix and say which publication they are as of, a child without +/// a view and a file outside every child are named gaps, and the generation +/// the parent serves stays readable after the child publishes a newer one. #[test] -fn parent_callgraph_reads_child_views_and_protects_served_generation() { +fn parent_callgraph_reads_child_v3_views_and_answers_as_of_their_publication() { fast_refresh(); let temp = tempfile::tempdir().unwrap(); let base = std::fs::canonicalize(temp.path()).unwrap(); @@ -929,41 +1009,9 @@ fn parent_callgraph_reads_child_views_and_protects_served_generation() { callgraph: true, semantic: false, }; - // The child session stays alive, so its view databases keep their - // `-wal`/`-shm` journal files while the parent reads them. - let child = configure(&graph, &storage, config_doc(true, with_graph, None)); - let first = wait_callgraph_view(&child, &graph, &storage, None); // `plain` is never bound to a session, so it has no view at all. - let view_dir = callgraph_view(&graph, &storage).view_dir().to_path_buf(); - // Hold the derived database open in WAL mode, as a live child process - // does: SQLite then keeps its `-wal` and `-shm` journal files beside it. - let derived = std::fs::read_dir(&view_dir) - .unwrap() - .flatten() - .map(|entry| entry.path()) - .find(|path| { - path.file_name() - .and_then(|name| name.to_str()) - .is_some_and(|name| name.starts_with("derived") && name.ends_with(".sqlite")) - }) - .expect("published view has a derived database"); - let holder = rusqlite::Connection::open(&derived).unwrap(); - let mode: String = holder - .query_row("PRAGMA journal_mode=WAL", [], |row| row.get(0)) - .unwrap(); - assert_eq!(mode, "wal"); - let _: i64 = holder - .query_row("SELECT count(*) FROM sqlite_master", [], |row| row.get(0)) - .unwrap(); - let journals = std::fs::read_dir(&view_dir) - .unwrap() - .flatten() - .filter(|entry| { - let name = entry.file_name().to_string_lossy().into_owned(); - name.ends_with("-wal") || name.ends_with("-shm") - }) - .count(); - assert!(journals > 0, "fixture must leave SQLite journal files"); + let child = configure(&graph, &storage, config_doc(true, with_graph, None)); + child_callgraph_answer(&child, &graph); let parent = configure(&root, &storage, config_doc(true, with_graph, None)); let session = parent_session(&parent); @@ -973,14 +1021,8 @@ fn parent_callgraph_reads_child_views_and_protects_served_generation() { .find(|child| child.root == graph) .unwrap() .clone(); - assert!( - graph_child.callgraph_ready(), - "journal files must not lock the parent out" - ); - assert_eq!( - graph_child.callgraph_generation().as_deref(), - Some(first.as_str()) - ); + let first = wait_parent_callgraph(&graph_child, None); + assert_eq!(session.view_route_counts()[0], 0); let callers = data( aft::views::parent::route( @@ -997,6 +1039,12 @@ fn parent_callgraph_reads_child_views_and_protects_served_generation() { let text = callers.to_string(); assert!(text.contains("graph/src/lib.rs"), "{callers:#}"); assert!(text.contains("caller"), "{callers:#}"); + // A foreign reader names the publication it answers as of; it never + // claims the child's graph is current. + assert_eq!(callers["as_of"]["child"], "graph", "{callers:#}"); + assert_eq!(callers["as_of"]["generation"], first.as_str(), "{callers:#}"); + assert!(callers["as_of"]["published_at_ms"].is_u64(), "{callers:#}"); + assert_eq!(session.view_route_counts()[0], 1); let missing = aft::views::parent::route( &request(json!({ @@ -1087,45 +1135,55 @@ fn parent_callgraph_reads_child_views_and_protects_served_generation() { assert_eq!(off.data["summary"]["todos"]["off"], true); assert_eq!(parent.inspect_manager().reuse_start_count_for_test(), 0); - // Hold the served generation, let the child publish a new one, and sweep - // the child's view: the parent's read marker keeps the old generation. + // Hold the served generation while the child publishes an edit: the + // parent keeps answering from the generation it holds a marker on, as + // of that publication, and follows the child once released. session.pause_refresh(true); - drop(holder); - drop(child); - write( - &graph, - "src/lib.rs", - "pub fn target() -> u32 {\n 2\n}\n\npub fn caller() -> u32 {\n target()\n}\n", - ); - commit_all(&graph, "second"); - let child = configure(&graph, &storage, config_doc(true, with_graph, None)); - let second = wait_callgraph_view(&child, &graph, &storage, Some(&first)); - let manifest = view_dir.join(format!("manifest-{first}.json")); - // The child's own maintenance sweeps its view after publishing; the - // generation the parent serves must survive that sweep and an explicit one. - assert!( - manifest.is_file(), - "the child's post-publication sweep removed a generation the parent serves" + // An AFT write through the child's session, which its view records. + let written = aft::commands::write::handle_write( + &request(json!({ + "id": "child-write", + "command": "write", + "file": graph.join("src/lib.rs"), + "content": "pub fn target() -> u32 {\n 2\n}\n\npub fn caller() -> u32 {\n target()\n}\n\npub fn second() -> u32 {\n target()\n}\n", + })), + &child, ); - callgraph_view(&graph, &storage) - .sweep_generations() - .unwrap(); - assert!( - manifest.is_file(), - "a generation the parent serves must survive the child's sweep" + assert!(written.success, "{:?}", written.data); + let edited = child_callgraph_answer(&child, &graph); + assert!(edited.to_string().contains("second"), "{edited:#}"); + let held = data( + aft::views::parent::route( + &request(json!({ + "id": "parent-callers-held", + "command": "callers", + "file": "graph/src/lib.rs", + "symbol": "target", + })), + &parent, + ) + .unwrap(), ); - // Control: once the parent follows the child, the old generation is no - // longer protected and the same sweep collects it. + assert_eq!(held["as_of"]["generation"], first.as_str(), "{held:#}"); + assert!(!held.to_string().contains("second"), "{held:#}"); session.pause_refresh(false); - let deadline = Instant::now() + DEADLINE; - while graph_child.callgraph_generation().as_deref() != Some(second.as_str()) { - assert!(Instant::now() < deadline, "parent never followed the child"); - thread::sleep(Duration::from_millis(20)); - } - callgraph_view(&graph, &storage) - .sweep_generations() - .unwrap(); - assert!(!manifest.exists(), "an unprotected old generation is swept"); + let second = wait_parent_callgraph(&graph_child, Some(&first)); + let followed = data( + aft::views::parent::route( + &request(json!({ + "id": "parent-callers-followed", + "command": "callers", + "file": "graph/src/lib.rs", + "symbol": "target", + })), + &parent, + ) + .unwrap(), + ); + assert_eq!(followed["as_of"]["generation"], second.as_str(), "{followed:#}"); + assert!(followed.to_string().contains("second"), "{followed:#}"); + // The parent built and wrote no call graph of its own. + assert!(parent.callgraph_store().read().unwrap().is_none()); drop(child); } @@ -1806,71 +1864,6 @@ fn standalone_semantic_grace_and_load_never_block_requests() { assert!(aft.shutdown().success()); } -/// 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) { - let head = aft::alias::head_tree_entries(root).unwrap(); - let report = aft::views::assembly::publish_checkout(&aft::views::assembly::AssemblyRequest { - storage: storage.to_path_buf(), - project_root: root.to_path_buf(), - family: aft::search_index::artifact_cache_key(root), - scope: aft::path_identity::project_scope_key(root), - desired_head: aft::views::assembly::head_tree_fingerprint(&head), - changed_paths: Default::default(), - semantic_keys: Default::default(), - require_semantic: false, - allow_blob_put: true, - callgraph: false, - }) - .unwrap(); - assert!(report.published); - assert!(aft::views::assembly::manifest_lacks_callgraph( - report.manifest.as_ref().unwrap() - )); -} - -/// A parent folder names a child whose view was published with the call -/// graph off as unavailable for the call graph, never as having no callers. -#[test] -fn parent_names_a_child_view_without_callgraph_as_disabled() { - fast_refresh(); - let temp = tempfile::tempdir().unwrap(); - let base = std::fs::canonicalize(temp.path()).unwrap(); - let root = base.join("projects"); - let storage = base.join("storage"); - let child = root.join("child"); - init_repo(&child, &[("src/lib.rs", "pub fn alone() {}\n".to_string())]); - publish_without_callgraph(&child, &storage); - let with_graph = Planes { - trigram: true, - callgraph: true, - semantic: false, - }; - let parent = configure(&root, &storage, config_doc(true, with_graph, None)); - parent_session(&parent); - let answer = aft::views::parent::route( - &request(json!({ - "id": "parent-callers", - "command": "callers", - "file": "child/src/lib.rs", - "symbol": "alone", - })), - &parent, - ) - .unwrap(); - assert!(!answer.success, "{:#}", answer.data); - assert_eq!(answer.data["complete"], false); - assert_eq!(answer.data["gaps"][0]["path"], "child"); - assert!( - answer.data["gaps"][0]["reason"] - .as_str() - .unwrap() - .contains("call graph is disabled (indexes.callgraph=false)"), - "{:#}", - answer.data - ); -} - /// Views stay off by default; with views off a parent folder keeps today's /// behaviour and no parent session starts. #[test] @@ -1891,9 +1884,9 @@ fn views_off_folder_is_not_a_parent_session() { } /// A real process restart: a new `aft` process bound to the parent folder -/// serves the child from the index the child's own session persisted, -/// without the child ever being bound in the new process, and without -/// writing the child's artifact. +/// serves the child from the v3 lexical view the child's own session +/// published, without the child ever being bound in the new process. Neither +/// process writes a legacy trigram artifact for the child. #[test] fn parent_folder_survives_a_real_process_restart() { let temp = tempfile::tempdir().unwrap(); @@ -1948,12 +1941,10 @@ fn parent_folder_survives_a_real_process_restart() { let configured = first.send(&configure_request(&child)); assert_eq!(configured["success"], true, "{configured:#}"); let artifact = trigram_artifact(&child, &storage); - let deadline = Instant::now() + DEADLINE; - while !artifact.is_file() { - assert!(Instant::now() < deadline, "child index never persisted"); - first.send(&grep_request); - thread::sleep(Duration::from_millis(100)); - } + // The child's own grep answers from its lexical view, which the child's + // bind published. + let child_answer = wait_for_match(&mut first); + assert_eq!(child_answer["complete"], true, "{child_answer:#}"); let configured = first.send(&configure_request(&root)); assert_eq!(configured["success"], true, "{configured:#}"); let answer = wait_for_match(&mut first); @@ -1964,7 +1955,6 @@ fn parent_folder_survives_a_real_process_restart() { .replace('\\', "/"); assert!(file.ends_with("src/lib.rs"), "{answer:#}"); assert!(first.shutdown().success()); - let before = std::fs::read(&artifact).unwrap(); let mut second = crate::helpers::AftProcess::spawn(); let configured = second.send(&configure_request(&root)); @@ -1972,5 +1962,5 @@ fn parent_folder_survives_a_real_process_restart() { let answer = wait_for_match(&mut second); assert_eq!(answer["complete"], true, "{answer:#}"); assert!(second.shutdown().success()); - assert_eq!(std::fs::read(&artifact).unwrap(), before); + assert!(!artifact.exists()); } From eadd4e4f2da2ac161caecf3ebbe28b261ffc2ab9 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Tue, 27 Oct 2026 07:56:32 +0100 Subject: [PATCH 087/100] mason: port views-on tests to the routed v3 contract and retire v1-publication tests On a views-routed root no v1 view is opened, imported into or published any more, and production reaches that machinery on no views-on root: a root that is not routed (heavy work disallowed, cache unavailable) never installs a v1 view runtime, and an explicit CheckoutQueryRuntime is installed only by tests. Tests whose subject was the v1 route itself are therefore retired, and the v1 code they covered is left for legacy removal. Ported (contract kept on the new route, assertions as strict): - views_publication_skips_a_non_utf8_callgraph_source_and_completes -> views_callgraph_view_skips_a_non_utf8_callgraph_source_and_answers: the non-UTF-8 source never fails the call-graph view, other files keep their graph, and once it decodes it joins. The v1 path-status skipped-path health report has no v3 counterpart yet. - runtime_drain views_published_watcher_batch_skips_legacy_refresh_and_still_ schedules_publication -> views_routed_watcher_batch_skips_legacy_refresh_ and_publishes_no_v1_view (red with the routed clause of published_view_owns_callgraph_writes removed: legacy refresh ran once). - callgraph_worktree_publishes_unique_source_without_an_owner_writer_or_ overlay: the worktree's own content is a row in the family's v3 call-graph plane, and no v1 blob store is opened (was: a v1 blob put). Retired (subject was v1 view publication, its payload check, its v1 blob store or the executor that schedules it): - executor::view_publication tests: callgraph_producer_change_republishes_ an_existing_generation, context_keeps_a_typed_conflict_and_the_winning_ generation_for_retry, deleted_root_pending_publication_retires_once_without_ rescheduling, inspect_view_pending_publication_{reschedules_dead_code_ without_another_inspect,without_deferred_job_requests_no_refresh}, invalidated_publication_cancels_before_derived_and_removes_generation_files, views_production_{receiver_index_preserves_payload_database_and_query_bytes, publication_reuses_rust_receiver_source_index}, publication_burst_5k_ measurement (ignored) and every publication_* test of that module that configured a views-on root (41). - subc: view_publication_{quiet_root_wakes_without_other_maintenance, burst_coalesces_and_returns_idle,wakes_while_the_language_server_manager_ is_busy,wakes_when_the_first_drain_runs_out_of_budget}, route_binds_are_answered_while_the_view_payload_check_is_held. - runtime_drain: delayed_view_publication_retains_{ignore_reason_after_edit, deferred_rescan_reason}. - configure: callgraph_view_publishes_while_semantic_view_fill_is_held, linked_worktree_with_a_graph_from_an_earlier_process_defers_its_next_ publication, s4_upgrade_same_head_configure_recovers_old_view_identities, views_clean_thousand_file_configure_hashes_zero_source_files, views_{corrupt,emptied}_callgraph_blob_store_*, views_first_publication_ leaves_no_path_pending_for_non_member_head_paths, views_gate_on_opens_ family_stores_after_ack_and_schedules_fresh_head, views_missing_payload_*, views_owner_rebuilds_stale_callgraph_with_multiple_sessions_and_linked_ worktrees, views_payload_check_names_missing_paths_and_reports_an_ exhausted_budget, views_removed_callgraph_blob_store_*. - context: views_busy_dead_code_refresh_retries_after_lock_clears_without_ edit, views_first_publication_retires_legacy_after_migration_window, views_legacy_refresh_ignores_busy_process_worker, views_published_ navigation_uses_cached_head_without_git_spawns, worktree_view_publishes_ {callgraph_without_importing_legacy_semantics,dirty_source_after_a_ watcher_event}. Test-only helpers and seams left unused by the retirements are removed (blob-removal fixtures, the publication Fixture and Gate, and the cfg(test) accessors they read in assembly, manifest_incremental, gc, file_identity, dispatch, alias and semantic_runtime). Co-authored-by: alfonso-aft <289616620+alfonso-aft@users.noreply.github.com> --- crates/aft/src/alias/mod.rs | 5 - crates/aft/src/callgraph_store/dispatch.rs | 14 - crates/aft/src/commands/configure.rs | 1323 +------- crates/aft/src/context.rs | 345 -- crates/aft/src/db/file_identity.rs | 20 - crates/aft/src/executor/view_publication.rs | 9 - .../src/executor/view_publication_tests.rs | 2775 ----------------- crates/aft/src/gc/mod.rs | 10 - crates/aft/src/runtime_drain.rs | 249 +- crates/aft/src/subc/mod.rs | 490 --- crates/aft/src/views/assembly.rs | 51 - crates/aft/src/views/manifest_incremental.rs | 5 - crates/aft/src/views/mod.rs | 2 +- crates/aft/src/views/parent/worker.rs | 18 +- .../integration/callgraph_worktree_test.rs | 22 +- .../aft/tests/integration/per_checkout_9.rs | 33 +- 16 files changed, 100 insertions(+), 5271 deletions(-) diff --git a/crates/aft/src/alias/mod.rs b/crates/aft/src/alias/mod.rs index 814dc3520c..91325d5daf 100644 --- a/crates/aft/src/alias/mod.rs +++ b/crates/aft/src/alias/mod.rs @@ -30,11 +30,6 @@ thread_local! { static HEAD_TREE_ENTRY_CALLS: std::cell::Cell = const { std::cell::Cell::new(0) }; } -#[cfg(test)] -pub(crate) fn reset_head_tree_entry_calls_for_test() { - HEAD_TREE_ENTRY_CALLS.with(|calls| calls.set(0)); -} - #[cfg(test)] pub(crate) fn head_tree_entry_calls_for_test() -> usize { HEAD_TREE_ENTRY_CALLS.with(std::cell::Cell::get) diff --git a/crates/aft/src/callgraph_store/dispatch.rs b/crates/aft/src/callgraph_store/dispatch.rs index 479840e64e..2ee92876e9 100644 --- a/crates/aft/src/callgraph_store/dispatch.rs +++ b/crates/aft/src/callgraph_store/dispatch.rs @@ -871,20 +871,6 @@ thread_local! { static UNINDEXED_RECEIVERS: std::cell::Cell = const { std::cell::Cell::new(false) }; } -/// Keep the former lookup algorithm as an independent byte/parity oracle and -/// work-count control. The switch exists only in test builds and is thread-local. -#[cfg(test)] -pub(crate) fn with_unindexed_receiver_lookup(run: impl FnOnce() -> T) -> T { - struct Restore(bool); - impl Drop for Restore { - fn drop(&mut self) { - UNINDEXED_RECEIVERS.with(|mode| mode.set(self.0)); - } - } - let _restore = Restore(UNINDEXED_RECEIVERS.with(|mode| mode.replace(true))); - run() -} - fn unindexed_constructor_present( function: Node<'_>, source: &str, diff --git a/crates/aft/src/commands/configure.rs b/crates/aft/src/commands/configure.rs index df20090999..0b7dd05497 100644 --- a/crates/aft/src/commands/configure.rs +++ b/crates/aft/src/commands/configure.rs @@ -3099,7 +3099,10 @@ struct SymbolViewPrewarm { /// the family already parsed is not parsed again; only misses are parsed and /// then stored for every checkout. The legacy symbol cache file is neither /// read nor written. -fn spawn_symbol_view_prewarm(ctx: &AppContext, snapshot: &crate::search_index::SearchIndexSnapshot) { +fn spawn_symbol_view_prewarm( + ctx: &AppContext, + snapshot: &crate::search_index::SearchIndexSnapshot, +) { let files = snapshot.file_entries().to_vec(); let symbol_cache = ctx.symbol_cache(); let generation = symbol_cache @@ -3232,7 +3235,10 @@ fn prewarm_symbol_cache_from_view( } if !rows.is_empty() { if let Err(error) = symbol_store::put(storage, family, &rows) { - slog_warn!("storing prewarmed symbols in the v3 symbol plane failed: {}", error); + slog_warn!( + "storing prewarmed symbols in the v3 symbol plane failed: {}", + error + ); report.skipped += report.stored; report.stored = 0; } @@ -11675,255 +11681,6 @@ mod tests { )); } - #[test] - fn views_owner_rebuilds_stale_callgraph_with_multiple_sessions_and_linked_worktrees() { - 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 temp = tempfile::tempdir().unwrap(); - let main = temp.path().join("main"); - let storage = temp.path().join("storage"); - init_git_fixture(&main); - let main = main.canonicalize().unwrap(); - for name in ["linked-one", "linked-two"] { - assert!(git_command(&main) - .args(["worktree", "add", "--detach", "--quiet"]) - .arg(temp.path().join(name)) - .arg("HEAD") - .status() - .unwrap() - .success()); - } - let family = crate::search_index::artifact_cache_key(&main); - let scope = crate::path_identity::project_scope_key(&main); - let head = crate::views::assembly::head_tree_fingerprint( - &crate::alias::head_tree_entries(&main).unwrap(), - ); - let seeded = - crate::views::assembly::publish_checkout(&crate::views::assembly::AssemblyRequest { - storage: storage.clone(), - project_root: main.clone(), - family: family.clone(), - scope: scope.clone(), - desired_head: head, - changed_paths: BTreeSet::new(), - semantic_keys: Default::default(), - require_semantic: false, - allow_blob_put: true, - callgraph: true, - }) - .unwrap(); - let generation = seeded.generation.unwrap(); - let view = crate::views::ViewStore::open(&storage, &scope).unwrap(); - let derived = view.derived_path(&generation).unwrap(); - crate::views::wait_for_derived_checkpoint_for_test(view.view_dir()); - // An older binary published the same HEAD with callgraph keys, but its - // extraction producer and derived build output are no longer readable. - let source = fs::read_to_string(main.join("tracked.rs")).unwrap(); - let old_producer = "ruled-callgraph-v2"; - let old_key = - crate::blob_store::CallgraphKey::from_bytes(source.as_bytes(), "rust", old_producer) - .full_key(); - let old_blob = - crate::callgraph_store::join::CallgraphBlob::extract(&source, "rust", old_producer) - .unwrap(); - let mut blobs = crate::blob_store::BlobStore::open( - &storage, - &family, - crate::blob_store::BlobPlane::Callgraph, - ) - .unwrap(); - blobs.put(&old_key, &old_blob.to_bytes().unwrap()).unwrap(); - // Leave only old-producer payloads: the owner must extract current - // blobs before a read-only worktree can assemble its own graph. - let connection = rusqlite::Connection::open(blobs.path()).unwrap(); - connection - .execute( - "DELETE FROM blob_payloads WHERE full_key != ?1", - [old_key.as_bytes().as_slice()], - ) - .unwrap(); - drop(connection); - let old_manifest = - crate::views::Manifest::new(view.load_manifest(&generation).unwrap().entries().map( - |(path, entry)| { - let mut entry = entry.clone(); - if let crate::views::ManifestEntry::Regular { planes, .. } = &mut entry { - planes.callgraph = Some(old_key.to_hex()); - } - (path.clone(), entry) - }, - )) - .unwrap(); - fs::write( - view.manifest_path(&generation).unwrap(), - old_manifest.to_json_bytes().unwrap(), - ) - .unwrap(); - let connection = rusqlite::Connection::open(&derived).unwrap(); - connection.execute("UPDATE meta SET v = '9e904a6dac7809c1250e41cc0eb9c1811afc33f707da25d3938c095b6e3bca2c' WHERE k = 'fingerprint'", []).unwrap(); - drop(connection); - - let params = |root: &Path| { - json!({ - "project_root": root, "storage_dir": storage, "harness": "opencode", - "config": [user_tier(json!({ "views": { "enabled": true }, - "indexes": { "trigram": false, "semantic": false, "callgraph": true } }))] - }) - }; - let owner = test_context(); - for session in ["owner-one", "owner-two", "owner-three"] { - let response = handle_configure_for_test( - &configure_request_with_session(params(&main), session), - &owner, - ); - assert!(response.success, "{}", response.data); - assert_eq!(response.data["artifact_owner"]["mode"], "owner"); - } - assert!(owner.callgraph_writer()); - super::drain_deferred_configure_maintenance(&owner); - crate::views::wait_for_derived_checkpoint_for_test(view.view_dir()); - match owner.callgraph_store_for_ops() { - CallgraphStoreAccess::Ready(store) => { - assert_eq!(store.reader_kind(), "view"); - assert!(crate::callgraph_store::CallGraphRead::node_for( - &store, - Path::new("tracked.rs"), - "tracked" - ) - .is_ok()); - } - CallgraphStoreAccess::Error(error) => { - panic!("bound owner did not rebuild its stale view: {error}") - } - _ => panic!("bound owner did not rebuild its stale view"), - } - assert_ne!( - view.current_generation().unwrap().as_deref(), - Some(generation.as_str()) - ); - // Rebinding a ready owner must not manufacture redundant generations. - let ready_generation = view.current_generation().unwrap(); - assert!( - handle_configure_for_test( - &configure_request_with_session(params(&main), "owner-four"), - &owner - ) - .success - ); - super::drain_deferred_configure_maintenance(&owner); - assert_eq!(view.current_generation().unwrap(), ready_generation); - for name in ["linked-one", "linked-two"] { - let linked = temp.path().join(name); - let borrower = test_context(); - let response = handle_configure_for_test( - &configure_request_with_session(params(&linked), name), - &borrower, - ); - assert!(response.success, "{}", response.data); - assert_eq!(response.data["artifact_owner"]["mode"], "read_only"); - assert!(!borrower.callgraph_writer()); - super::drain_deferred_configure_maintenance(&borrower); - crate::views::wait_for_derived_checkpoint_for_test(&lazy_view_dir(&storage, &linked)); - match borrower.callgraph_store_for_ops() { - CallgraphStoreAccess::Ready(store) => { - assert_eq!(store.reader_kind(), "view"); - assert!(crate::callgraph_store::CallGraphRead::node_for( - &store, - Path::new("tracked.rs"), - "tracked" - ) - .is_ok()); - } - CallgraphStoreAccess::Error(error) => { - panic!("linked worktree could not read the rebuilt graph: {error}") - } - _ => panic!("linked worktree could not read the rebuilt graph"), - } - let request = RawRequest { - id: "linked-callers".into(), - command: "callers".into(), - session_id: Some(name.into()), - lsp_hints: None, - params: json!({ "file": linked.join("tracked.rs"), "symbol": "tracked" }), - }; - let answer = crate::commands::callers::handle_callers(&request, &borrower); - assert!(answer.success, "{}", answer.data); - } - } - - #[test] - fn s4_upgrade_same_head_configure_recovers_old_view_identities() { - 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 temp = tempfile::tempdir().unwrap(); - let main = temp.path().join("main"); - let storage = temp.path().join("storage"); - init_git_fixture(&main); - fs::write(main.join("tracked.rs"), "pub struct C; impl C { pub fn unique(&self) {} }\npub fn tracked(x: &C) { x.unique(); }\n").unwrap(); - let main = main.canonicalize().unwrap(); - let family = crate::search_index::artifact_cache_key(&main); - let scope = crate::path_identity::project_scope_key(&main); - let head = crate::views::assembly::head_tree_fingerprint( - &crate::alias::head_tree_entries(&main).unwrap(), - ); - let request = crate::views::assembly::AssemblyRequest { - storage: storage.clone(), - project_root: main.clone(), - family: family.clone(), - scope: scope.clone(), - desired_head: head.clone(), - changed_paths: BTreeSet::new(), - semantic_keys: Default::default(), - require_semantic: false, - allow_blob_put: true, - callgraph: true, - }; - let seeded = crate::callgraph_store::position_identity_tests::with_pre_s4_output(|| { - crate::views::assembly::publish_checkout(&request) - }) - .unwrap(); - let view = crate::views::ViewStore::open(&storage, &scope).unwrap(); - let old = view - .derived_path(seeded.generation.as_deref().unwrap()) - .unwrap(); - crate::views::wait_for_derived_checkpoint_for_test(view.view_dir()); - { - let conn = rusqlite::Connection::open(&old).unwrap(); - crate::callgraph_store::position_identity_tests::seed_old_dispatch_hint(&conn); - crate::callgraph_store::position_identity_tests::stamp_pre_s4(&conn, true); - } - let owner = test_context(); - let params = json!({"project_root":main,"storage_dir":storage,"harness":"opencode","config":[user_tier(json!({"views":{"enabled":true},"indexes":{"trigram":false,"semantic":false,"callgraph":true}}))]}); - let response = - handle_configure_for_test(&configure_request_with_session(params, "s4-owner"), &owner); - assert!(response.success, "{}", response.data); - super::drain_deferred_configure_maintenance(&owner); - crate::views::wait_for_derived_checkpoint_for_test(view.view_dir()); - match owner.callgraph_store_for_ops() { - CallgraphStoreAccess::Ready(store) => assert!( - !crate::callgraph_store::CallGraphRead::edge_snapshot(&store) - .unwrap() - .is_empty() - ), - _ => panic!("same-HEAD configure must recover a ready view"), - } - assert_eq!( - head, - crate::views::assembly::head_tree_fingerprint( - &crate::alias::head_tree_entries(&main).unwrap() - ) - ); - let current = view.current_generation().unwrap().unwrap(); - assert_ne!(Some(current.as_str()), seeded.generation.as_deref()); - let database = view.derived_path(¤t).unwrap(); - crate::views::wait_for_derived_checkpoint_for_test(view.view_dir()); - crate::callgraph_store::position_identity_tests::assert_current_graph( - &rusqlite::Connection::open(&database).unwrap(), - ); - } - /// `lib.rs` of the on-demand call-graph fixtures: one caller, one callee /// and one function nothing calls, so callers, impact and dead code all /// have something to report. @@ -12189,51 +11946,6 @@ mod tests { assert_eq!(outcome.processed, 1); } - /// A views-on checkout whose call graph an operator removes while the - /// daemon is stopped: `blobs//callgraph.sqlite` and its `-wal` and - /// `-shm` files are deleted, while `oid-alias.sqlite` and - /// `semantic.sqlite` beside them are kept. - struct BlobRemovalFixture { - _temp: tempfile::TempDir, - root: PathBuf, - storage: PathBuf, - family: String, - /// `callers` of `target` and `call_tree` of `caller`, Debug-formatted, - /// as the daemon answered before the store was removed. - callers_before: String, - call_tree_before: String, - generation_before: String, - } - - const BLOB_REMOVAL_EDITED_LIB: &str = - "pub fn caller() { target(); }\npub fn second() { target(); }\npub fn target() {}\nfn unused() {}\n"; - - fn blob_removal_callgraph_db(storage: &Path, family: &str) -> PathBuf { - storage.join("blobs").join(family).join("callgraph.sqlite") - } - - fn blob_removal_payload_rows(storage: &Path, family: &str) -> u64 { - let connection = - rusqlite::Connection::open(blob_removal_callgraph_db(storage, family)).unwrap(); - connection - .query_row("SELECT COUNT(*) FROM blob_payloads", [], |row| row.get(0)) - .unwrap() - } - - fn blob_removal_answers(ctx: &AppContext) -> (String, String) { - let view = ctx.view_runtime_snapshot().expect("published fixture view"); - // Wait for the detached checkpoint to finish before reading: it can - // still lock the database after publication. A live query can instead - // return the retryable callgraph_building response. - crate::views::wait_for_derived_checkpoint_for_test(&view.view_dir); - let store = lazy_ready(ctx.callgraph_store_for_ops()); - let lib = Path::new("lib.rs"); - ( - format!("{:?}", store.callers_of(lib, "target", 3).unwrap()), - format!("{:?}", store.call_tree(lib, "caller", 3).unwrap()), - ) - } - fn blob_removal_generation(storage: &Path, root: &Path) -> Option { crate::views::ViewStore::open(storage, &crate::path_identity::project_scope_key(root)) .unwrap() @@ -12327,301 +12039,6 @@ mod tests { .contains("caller")); } - /// Models a stopped daemon before the store is removed: once this returns, - /// no tracked database connection under `storage` is open in this process. - /// Deleting a file that a connection still holds would leave that - /// connection reading the unlinked file on Unix, which would hide the effect - /// being tested, and fails on Windows. - /// - /// Dropping the last context closes the connections it owns. The view - /// publication, however, handed its keeper connection on the new - /// `derived-*.sqlite` to the detached `aft-view-checkpoint` worker (see - /// `views::generation::schedule_derived_checkpoint`). The context does not - /// own that worker; it waits at least 250 ms, attempts a checkpoint unless - /// a later publication superseded it, then closes the connection either - /// way. Wait for it here. A real daemon stop ends the process, which closes - /// that handle as well. - fn blob_removal_stop(ctx: Arc, storage: &Path, root: &Path) { - let Ok(ctx) = Arc::try_unwrap(ctx) else { - panic!("another owner kept the context alive past the stop"); - }; - 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(), - "connections outlived the stopped context: {held:#?}" - ); - } - - /// Removes the family's call-graph blob store and asserts that the alias and - /// semantic stores beside it are left in place. - fn blob_removal_delete_store(storage: &Path, family: &str) { - let database = blob_removal_callgraph_db(storage, family); - assert!(database.is_file()); - for suffix in ["", "-wal", "-shm"] { - let path = PathBuf::from(format!("{}{suffix}", database.display())); - match fs::remove_file(&path) { - Ok(()) => {} - Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} - Err(error) => panic!("removing {}: {error}", path.display()), - } - } - let family_dir = storage.join("blobs").join(family); - assert!(!database.exists()); - assert!(family_dir.join("oid-alias.sqlite").is_file()); - assert!(family_dir.join("semantic.sqlite").is_file()); - } - - /// Binds a committed checkout with views and the call graph on, records the - /// answers the daemon gives, stops it and removes the call-graph blob store. - fn blob_removal_fixture() -> BlobRemovalFixture { - let temp = tempfile::tempdir().unwrap(); - let root = temp.path().join("main"); - init_git_fixture(&root); - lazy_commit(&root, LAZY_LIB, "lib"); - let root = root.canonicalize().unwrap(); - let storage = temp.path().join("storage"); - let family = crate::search_index::artifact_cache_key(&root); - - let ctx = lazy_bind(&root, &storage, "before-removal"); - let (callers_before, call_tree_before) = blob_removal_answers(&ctx); - assert!(callers_before.contains("caller"), "{callers_before}"); - assert!(call_tree_before.contains("target"), "{call_tree_before}"); - let generation_before = blob_removal_generation(&storage, &root).unwrap(); - assert!(crate::views::read::derived_built( - &lazy_view_dir(&storage, &root), - &generation_before - )); - // lib.rs and tracked.rs each have one extracted payload. - assert_eq!(blob_removal_payload_rows(&storage, &family), 2); - - blob_removal_stop(ctx, &storage, &root); - blob_removal_delete_store(&storage, &family); - BlobRemovalFixture { - _temp: temp, - root, - storage, - family, - callers_before, - call_tree_before, - generation_before, - } - } - - /// Removing only the blob store used to freeze the view: nothing extracted - /// the deleted payloads again, the first edit's closure check found the - /// unchanged file's payload missing, and every retry failed the same way. - /// A missing payload is now extracted again from the working tree, so the - /// restarted daemon keeps answering and the next edit publishes. - #[test] - fn views_removed_callgraph_blob_store_heals_and_the_next_edit_publishes() { - 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 = blob_removal_fixture(); - let (root, storage, family) = (&fixture.root, &fixture.storage, &fixture.family); - - let ctx = lazy_bind(root, storage, "after-removal"); - assert_eq!( - blob_removal_answers(&ctx), - ( - fixture.callers_before.clone(), - fixture.call_tree_before.clone() - ), - "the restarted daemon answers from the surviving derived database" - ); - - fs::write(root.join("lib.rs"), BLOB_REMOVAL_EDITED_LIB).unwrap(); - let report = ctx - .publish_view_paths(BTreeSet::from([b"lib.rs".to_vec()]), false) - .expect("the edit publishes although the blob store was removed"); - assert!(report.published, "{report:?}"); - assert!(report.pending_paths.is_empty(), "{report:?}"); - assert_ne!( - blob_removal_generation(storage, root).as_deref(), - Some(fixture.generation_before.as_str()), - "the edit's generation is current" - ); - let (callers_after_edit, _) = blob_removal_answers(&ctx); - assert!( - callers_after_edit.contains("second"), - "the edit's new caller is served: {callers_after_edit}" - ); - // The unchanged file's payload was extracted again, beside the edit's. - assert!(blob_removal_has_payload( - storage, - family, - &blob_removal_key(&root.join("tracked.rs")) - )); - assert!(blob_removal_has_payload( - storage, - family, - &blob_removal_key(&root.join("lib.rs")) - )); - } - - /// The callgraph key of a file's current bytes, as assembly computes it. - fn blob_removal_key(path: &Path) -> String { - crate::blob_store::CallgraphKey::from_bytes( - &fs::read(path).unwrap(), - "rust", - crate::views::callgraph::PRODUCER, - ) - .full_key() - .to_hex() - } - - fn blob_removal_has_payload(storage: &Path, family: &str, key: &str) -> bool { - let connection = - rusqlite::Connection::open(blob_removal_callgraph_db(storage, family)).unwrap(); - let key = crate::blob_store::v2::parse_hex32(key).unwrap(); - connection - .query_row( - "SELECT COUNT(*) FROM blob_payloads WHERE full_key = ?1", - [key.as_slice()], - |row| row.get::<_, i64>(0), - ) - .unwrap() - == 1 - } - - /// Deletes every payload row of the family's call-graph store, keeping the - /// file, as a lost or truncated store would leave it. - fn blob_removal_clear_rows(storage: &Path, family: &str) { - let connection = - rusqlite::Connection::open(blob_removal_callgraph_db(storage, family)).unwrap(); - connection - .execute_batch("DELETE FROM blob_payloads") - .unwrap(); - drop(connection); - assert_eq!(blob_removal_payload_rows(storage, family), 0); - } - - fn blob_removal_manifest_key(storage: &Path, root: &Path, path: &str) -> Option { - let view = - crate::views::ViewStore::open(storage, &crate::path_identity::project_scope_key(root)) - .unwrap(); - let generation = view.current_generation().unwrap().unwrap(); - let manifest = view.load_manifest(&generation).unwrap(); - match manifest.get(&crate::views::RelPath::new(path.as_bytes().to_vec()).unwrap())? { - crate::views::ManifestEntry::Regular { planes, .. } => planes.callgraph.clone(), - _ => None, - } - } - - /// A bound checkout whose call-graph store loses its rows while the daemon - /// runs. Returns the live context. - fn blob_heal_running_fixture() -> (tempfile::TempDir, PathBuf, PathBuf, String, Arc) - { - let temp = tempfile::tempdir().unwrap(); - let root = temp.path().join("main"); - init_git_fixture(&root); - lazy_commit(&root, LAZY_LIB, "lib"); - let root = root.canonicalize().unwrap(); - let storage = temp.path().join("storage"); - let family = crate::search_index::artifact_cache_key(&root); - let ctx = lazy_bind(&root, &storage, "running-heal"); - assert_eq!(blob_removal_payload_rows(&storage, &family), 2); - blob_removal_clear_rows(&storage, &family); - (temp, root, storage, family, ctx) - } - - /// A store SQLite cannot read is moved aside and recreated empty when it - /// is opened. The view keeps naming the old payloads; they are extracted - /// again, and the next edit publishes. - #[test] - fn views_corrupt_callgraph_blob_store_is_moved_aside_and_heals() { - 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 = blob_removal_fixture(); - let (root, storage, family) = (&fixture.root, &fixture.storage, &fixture.family); - let database = blob_removal_callgraph_db(storage, family); - fs::write(&database, b"this is not a SQLite database\n".repeat(256)).unwrap(); - - let ctx = lazy_bind(root, storage, "after-corruption"); - let family_dir = storage.join("blobs").join(family); - let moved_aside = fs::read_dir(&family_dir) - .unwrap() - .filter_map(|entry| entry.ok()?.file_name().into_string().ok()) - .filter(|name| name.starts_with("callgraph.sqlite.corrupt-")) - .count(); - assert_eq!(moved_aside, 1, "the corrupt store was moved aside"); - assert_eq!( - blob_removal_answers(&ctx), - ( - fixture.callers_before.clone(), - fixture.call_tree_before.clone() - ), - ); - - fs::write(root.join("lib.rs"), BLOB_REMOVAL_EDITED_LIB).unwrap(); - let report = ctx - .publish_view_paths(BTreeSet::from([b"lib.rs".to_vec()]), false) - .expect("the edit publishes after the corrupt store was replaced"); - assert!(report.published, "{report:?}"); - let (callers_after_edit, _) = blob_removal_answers(&ctx); - assert!( - callers_after_edit.contains("second"), - "{callers_after_edit}" - ); - assert!(blob_removal_has_payload( - storage, - family, - &blob_removal_key(&root.join("tracked.rs")) - )); - } - - /// Bind finds the current generation's missing payloads before any edit - /// and schedules the publication that extracts them again. - #[test] - fn views_emptied_callgraph_blob_store_is_healed_at_bind() { - 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 = blob_removal_fixture(); - let (root, storage, family) = (&fixture.root, &fixture.storage, &fixture.family); - - let ctx = lazy_bind(root, storage, "after-emptying"); - assert_eq!( - blob_removal_payload_rows(storage, family), - 2, - "bind extracted both payloads again without an edit" - ); - assert_eq!( - blob_removal_generation(storage, root).as_deref(), - Some(fixture.generation_before.as_str()), - "the repair changed no content, so the generation is unchanged" - ); - assert!(ctx - .view_runtime_snapshot() - .is_some_and(|view| view.pending_paths.is_empty())); - assert_eq!( - blob_removal_answers(&ctx), - ( - fixture.callers_before.clone(), - fixture.call_tree_before.clone() - ), - ); - let (_cold_storage, cold) = lazy_cold_store(root); - let store = lazy_ready(ctx.callgraph_store_for_ops()); - assert_eq!( - store.edge_snapshot().unwrap(), - cold.edge_snapshot().unwrap() - ); - } - /// Measures, on a checkout of about AFT's size with both payload planes /// populated, what the payload checks cost: the bind-time check of a /// healthy store, and a one-file edit's publication split into the @@ -12818,208 +12235,6 @@ mod tests { } } - /// A missing payload whose file changed since the view was published is - /// extracted from the file's current content, under its new key. - #[test] - fn views_missing_payload_of_a_changed_file_is_extracted_under_its_new_key() { - 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 (_temp, root, storage, family, ctx) = blob_heal_running_fixture(); - let old_tracked_key = blob_removal_key(&root.join("tracked.rs")); - assert_eq!( - blob_removal_manifest_key(&storage, &root, "tracked.rs").as_deref(), - Some(old_tracked_key.as_str()) - ); - // Nothing reports this change: only lib.rs is published below. - fs::write( - root.join("tracked.rs"), - "fn tracked() { crate::target(); }\n", - ) - .unwrap(); - let new_tracked_key = blob_removal_key(&root.join("tracked.rs")); - - fs::write(root.join("lib.rs"), BLOB_REMOVAL_EDITED_LIB).unwrap(); - let report = ctx - .publish_view_paths(BTreeSet::from([b"lib.rs".to_vec()]), false) - .expect("the edit publishes"); - assert!(report.published, "{report:?}"); - assert_eq!( - blob_removal_manifest_key(&storage, &root, "tracked.rs").as_deref(), - Some(new_tracked_key.as_str()), - "the changed file is published under its current content's key" - ); - assert!(blob_removal_has_payload( - &storage, - &family, - &new_tracked_key - )); - assert!(!blob_removal_has_payload( - &storage, - &family, - &old_tracked_key - )); - let (callers_after_edit, _) = blob_removal_answers(&ctx); - assert!( - callers_after_edit.contains("second"), - "{callers_after_edit}" - ); - } - - /// The bind check reads keys, not payloads, and an exhausted budget is - /// reported as incomplete rather than as a store with nothing missing. - #[test] - fn views_payload_check_names_missing_paths_and_reports_an_exhausted_budget() { - 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 (_temp, root, storage, family, _ctx) = blob_heal_running_fixture(); - let view = crate::views::ViewStore::open( - &storage, - &crate::path_identity::project_scope_key(&root), - ) - .unwrap(); - let manifest = view - .load_manifest(&view.current_generation().unwrap().unwrap()) - .unwrap(); - let callgraph = crate::blob_store::BlobStore::open( - &storage, - family.clone(), - crate::blob_store::BlobPlane::Callgraph, - ) - .unwrap(); - let semantic = crate::blob_store::BlobStore::open( - &storage, - family.clone(), - crate::blob_store::BlobPlane::Semantic, - ) - .unwrap(); - - let check = super::view_payload_check( - &manifest, - &callgraph, - &semantic, - Instant::now() + Duration::from_secs(60), - ) - .unwrap(); - assert!(check.complete()); - assert_eq!( - check.missing.keys().cloned().collect::>(), - vec![b"lib.rs".to_vec(), b"tracked.rs".to_vec()] - ); - assert!(check - .missing - .values() - .all(|input| input.plane == "callgraph")); - - let exhausted = super::view_payload_check( - &manifest, - &callgraph, - &semantic, - Instant::now() - .checked_sub(Duration::from_millis(1)) - .unwrap_or_else(Instant::now), - ) - .unwrap(); - assert!(!exhausted.complete()); - assert!(exhausted.missing.is_empty()); - } - - /// A missing payload whose file cannot be read leaves that path out of the - /// generation with a failed status; the rest of the publication lands. - #[test] - fn views_missing_payload_of_an_unreadable_file_fails_only_that_path() { - 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 (_temp, root, storage, _family, ctx) = blob_heal_running_fixture(); - // A directory where the tracked file was: the path still exists, but - // reading it as a file fails. - fs::remove_file(root.join("tracked.rs")).unwrap(); - fs::create_dir(root.join("tracked.rs")).unwrap(); - - fs::write(root.join("lib.rs"), BLOB_REMOVAL_EDITED_LIB).unwrap(); - let report = ctx - .publish_view_paths(BTreeSet::from([b"lib.rs".to_vec()]), false) - .expect("one unreadable path does not fail the publication"); - assert!(report.published, "{report:?}"); - assert!( - blob_removal_manifest_key(&storage, &root, "tracked.rs").is_none(), - "the unreadable path is not published with a missing payload" - ); - let status = crate::path_status::PathStatusStore::open(&lazy_view_dir(&storage, &root)) - .unwrap() - .status_for(b"tracked.rs") - .unwrap() - .expect("the unreadable path has a status"); - assert_eq!(status.state, crate::path_status::PathState::Failed); - assert_eq!( - status.reason, - crate::views::assembly::MISSING_PAYLOAD_SOURCE_UNREADABLE - ); - let (callers_after_edit, _) = blob_removal_answers(&ctx); - assert!( - callers_after_edit.contains("second"), - "{callers_after_edit}" - ); - } - - /// Removing the checkout's view directory together with the blob store - /// makes the next bind publish from scratch: every file is extracted - /// again, and the rebuilt graph answers as before and takes later edits. - #[test] - fn views_removed_callgraph_blob_store_and_view_rebuild_on_restart() { - 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 = blob_removal_fixture(); - let (root, storage, family) = (&fixture.root, &fixture.storage, &fixture.family); - let view_dir = lazy_view_dir(storage, root); - fs::remove_dir_all(&view_dir).unwrap(); - - let ctx = lazy_bind(root, storage, "after-removal"); - let generation = blob_removal_generation(storage, root).expect("republished at bind"); - assert!(crate::views::read::derived_built(&view_dir, &generation)); - assert!(ctx - .view_runtime_snapshot() - .is_some_and(|view| view.pending_paths.is_empty())); - assert_eq!( - blob_removal_payload_rows(storage, family), - 2, - "both files were extracted again" - ); - - // 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!( - blob_removal_answers(&ctx), - ( - fixture.callers_before.clone(), - fixture.call_tree_before.clone() - ), - ); - let (_cold_storage, cold) = lazy_cold_store(root); - let store = lazy_ready(ctx.callgraph_store_for_ops()); - assert_eq!( - store.edge_snapshot().unwrap(), - cold.edge_snapshot().unwrap() - ); - drop(store); - - fs::write(root.join("lib.rs"), BLOB_REMOVAL_EDITED_LIB).unwrap(); - let report = ctx - .publish_view_paths(BTreeSet::from([b"lib.rs".to_vec()]), false) - .unwrap(); - assert!(report.published, "{report:?}"); - assert!(report.pending_paths.is_empty(), "{report:?}"); - let (callers_after_edit, _) = blob_removal_answers(&ctx); - assert!( - callers_after_edit.contains("second"), - "{callers_after_edit}" - ); - } - #[test] fn linked_worktree_edit_answers_from_its_own_view_at_once() { let _artifact_guard = artifact_owner_test_lock(); @@ -13264,43 +12479,6 @@ mod tests { (generation, built) } - #[test] - fn linked_worktree_with_a_graph_from_an_earlier_process_defers_its_next_publication() { - 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"); - // An earlier daemon built this worktree's graph (the eager behaviour - // before graphs were built on first use, or a process that queried it). - // Then the worker committed, so this bind has a publication to make. - let mut earlier = None; - let fixture = lazy_worktree_with(true, true, |linked, storage| { - lazy_publish(linked, storage); - earlier = Some(lazy_current_generation(storage, linked)); - lazy_commit( - linked, - "pub fn caller() { target(); }\npub fn branch_caller() { target(); }\npub fn target() {}\nfn unused() {}\n", - "branch caller", - ); - }); - let (earlier, earlier_built) = earlier.unwrap(); - assert!(earlier_built, "the earlier process built a derived graph"); - let (current, built) = lazy_current_generation(&fixture.storage, &fixture.linked); - assert_ne!(current, earlier, "the bind published the new commit"); - assert!( - !built, - "no query needed the graph in this process, so the publication must not patch or rebuild it: {:?}", - lazy_derived_files(&lazy_view_dir(&fixture.storage, &fixture.linked)) - ); - assert_eq!( - fixture.borrower.lazy_callgraph_builds_scheduled_for_test(), - 0 - ); - // The first query builds it, and the result equals a cold build. - let store = lazy_query_builds_own_graph(&fixture); - let (_cold_storage, cold) = lazy_cold_store(&fixture.linked); - lazy_assert_same_graph(&store, &cold); - } - #[test] fn linked_worktree_defers_and_builds_on_first_query_when_main_is_not_bound() { let _artifact_guard = artifact_owner_test_lock(); @@ -13491,74 +12669,6 @@ mod tests { assert_eq!(response.data["total_callers"], 1); } - #[test] - fn views_clean_thousand_file_configure_hashes_zero_source_files() { - 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(); - let storage = tempfile::tempdir().unwrap(); - init_git_fixture(project.path()); - for index in 0..999 { - std::fs::write( - project.path().join(format!("file_{index:04}.rs")), - "pub fn clean() {}\n", - ) - .unwrap(); - } - assert!(git_command(project.path()) - .args(["add", "."]) - .status() - .unwrap() - .success()); - assert!(git_command(project.path()) - .args([ - "-c", - "user.name=AFT Tests", - "-c", - "user.email=aft-tests@example.com", - "commit", - "--quiet", - "-m", - "thousand files" - ]) - .status() - .unwrap() - .success()); - let root = std::fs::canonicalize(project.path()).unwrap(); - let ctx = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default()); - let configure = configure_request_with_params(json!({ - "project_root": root, "storage_dir": storage.path(), "harness": "opencode", - "config": [user_tier(json!({ - "views": {"enabled": true}, - "indexes": {"trigram": false, "semantic": false, "callgraph": true} - }))] - })); - assert!(handle_configure_for_test(&configure, &ctx).success); - super::drain_deferred_configure_maintenance(&ctx); - assert_eq!( - ctx.view_runtime_snapshot() - .unwrap() - .manifest - .unwrap() - .entries() - .count(), - 1000 - ); - drop(ctx); - - let restarted = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default()); - assert!(handle_configure_for_test(&configure, &restarted).success); - let mut state = super::ConfigureMaintenanceState::default(); - state.absorb_enqueued(&restarted); - let job = &state.jobs.front().unwrap().job; - let input = super::ConfigureViewInput::capture(&restarted, job); - super::VIEW_WORKING_TREE_HASHES.with(|count| count.set(0)); - let prepared = super::open_view_runtime_for_configure(&input, job).unwrap(); - assert!(prepared.snapshot.pending_paths.is_empty()); - super::VIEW_WORKING_TREE_HASHES - .with(|count| assert_eq!(count.get(), 0, "clean configure must not hash source files")); - } - #[test] fn view_working_tree_verification_is_bounded_and_hashes_source_bytes() { let _git_env = crate::test_env::hermetic_git_env_guard(); @@ -13721,230 +12831,12 @@ mod tests { ); } - #[test] - fn views_gate_on_opens_family_stores_after_ack_and_schedules_fresh_head() { - 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(); - let storage = tempfile::tempdir().unwrap(); - init_git_fixture(project.path()); - let canonical_root = std::fs::canonicalize(project.path()).unwrap(); - let family = crate::search_index::artifact_cache_key(&canonical_root); - let scope = crate::path_identity::project_scope_key(&canonical_root); - let ctx = AppContext::new( - Box::new(TreeSitterProvider::new()), - Config { - storage_dir: Some(storage.path().to_path_buf()), - ..Config::default() - }, - ); - let response = handle_configure_for_test( - &configure_request_with_params(json!({ - "project_root": canonical_root, - "storage_dir": storage.path(), - "harness": "opencode", - "config": [user_tier(json!({ - "views": { "enabled": true }, - "indexes": { "trigram": false, "semantic": false, "callgraph": true } - }))] - })), - &ctx, - ); - assert!(response.success, "{}", response.data); - assert!(!storage.path().join("views").exists()); - - super::drain_deferred_configure_maintenance(&ctx); - - let view = ctx.view_runtime_snapshot().expect("view runtime"); - assert_eq!(view.family, family); - assert_eq!(view.scope, scope); - assert!(view - .generation - .as_deref() - .is_some_and(|generation| generation.starts_with("1-"))); - assert!(view.pending_paths.is_empty()); - assert!(view.manifest.is_some()); - assert!(storage - .path() - .join("blobs") - .join(&family) - .join("semantic.sqlite") - .is_file()); - assert!(storage - .path() - .join("blobs") - .join(&family) - .join("callgraph.sqlite") - .is_file()); - assert!(storage.path().join("views").join(scope).is_dir()); - let health = ctx.view_health_snapshot().expect("view health"); - assert_eq!(health.generation, 1); - assert!(health.pinned); - let digest_request: RawRequest = serde_json::from_value(json!({ - "id": "digest-1", - "command": "health.digest" - })) - .unwrap(); - let digest = crate::commands::health_digest::handle_health_digest(&digest_request, &ctx); - let digest = serde_json::to_value(digest).unwrap(); - assert_eq!(digest["views"]["ticket"]["generation"], json!(1)); - - match ctx.callgraph_store_for_ops() { - CallgraphStoreAccess::Ready(store) => { - assert_eq!(store.reader_kind(), "view"); - assert!(crate::callgraph_store::CallGraphRead::node_for( - &store, - Path::new("tracked.rs"), - "tracked", - ) - .is_ok()); - } - _ => panic!("expected view callgraph reader"), - } - - fs::write(project.path().join("next.rs"), "pub fn next() {}\n").unwrap(); - assert!(git_command(project.path()) - .args(["add", "next.rs"]) - .status() - .unwrap() - .success()); - // The hermetic git env has no identity of its own; a CI runner has none - // either, so the commit must carry it the way the fixture's first one does. - assert!(git_command(project.path()) - .args([ - "-c", - "user.name=AFT Tests", - "-c", - "user.email=aft-tests@example.com", - "commit", - "--quiet", - "-m", - "next", - ]) - .status() - .unwrap() - .success()); - ctx.publish_view_paths(BTreeSet::from([b"next.rs".to_vec()]), true) - .unwrap(); - let digest_request: RawRequest = serde_json::from_value(json!({ - "id": "digest-2", - "command": "health.digest" - })) - .unwrap(); - let digest = crate::commands::health_digest::handle_health_digest(&digest_request, &ctx); - let digest = serde_json::to_value(digest).unwrap(); - assert_eq!(digest["views"]["ticket"]["generation"], json!(2)); - } - - /// Every HEAD path a first views-on configure schedules must leave the - /// pending ledger once the publication finishes, including paths the - /// publication does not make view members: tracked files deleted from the - /// checkout (binary assets here, as a copy of a tree can leave them out), - /// tracked files the project content rules exclude, and tracked symlinks - /// deleted from the checkout. A path left pending here is never revisited, - /// so status would report it pending for the life of the view. - #[test] - fn views_first_publication_leaves_no_path_pending_for_non_member_head_paths() { - 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(); - let storage = tempfile::tempdir().unwrap(); - let root = project.path(); - // Binary content present in the checkout: an ordinary member. - std::fs::create_dir_all(root.join("assets")).unwrap(); - std::fs::write(root.join("assets/present.png"), [0x89, b'P', 0xff, 0xfe, 0]).unwrap(); - // Binary content committed and then removed from the checkout. - std::fs::write(root.join("assets/banner.jpeg"), [0xff, 0xd8, 0xff, 0xe0, 0]).unwrap(); - std::fs::create_dir_all(root.join("fixtures")).unwrap(); - std::fs::write(root.join("fixtures/binary.bin"), [0, 0xff, 0xfe, 1]).unwrap(); - // A tracked database file, which project content rules always exclude. - std::fs::write(root.join("fixtures/model.db"), b"SQLite format 3\0").unwrap(); - #[cfg(unix)] - { - // A dangling symlink present in the checkout is still a member; - // one deleted from the checkout is not. - std::os::unix::fs::symlink("missing.rs", root.join("dangling.rs")).unwrap(); - std::os::unix::fs::symlink("tracked.rs", root.join("removed_link.rs")).unwrap(); - } - init_git_fixture(root); - std::fs::remove_file(root.join("assets/banner.jpeg")).unwrap(); - std::fs::remove_file(root.join("fixtures/binary.bin")).unwrap(); - #[cfg(unix)] - std::fs::remove_file(root.join("removed_link.rs")).unwrap(); - let canonical_root = std::fs::canonicalize(root).unwrap(); - let ctx = AppContext::new( - Box::new(TreeSitterProvider::new()), - Config { - storage_dir: Some(storage.path().to_path_buf()), - ..Config::default() - }, - ); - let response = handle_configure_for_test( - &configure_request_with_params(json!({ - "project_root": canonical_root, - "storage_dir": storage.path(), - "harness": "opencode", - "config": [user_tier(json!({ - "views": { "enabled": true }, - "indexes": { "trigram": false, "semantic": false, "callgraph": true } - }))] - })), - &ctx, - ); - assert!(response.success, "{}", response.data); - - super::drain_deferred_configure_maintenance(&ctx); - - let view = ctx.view_runtime_snapshot().expect("view runtime"); - let manifest = view.manifest.as_ref().expect("published manifest"); - let members = manifest - .entries() - .map(|(path, _)| String::from_utf8_lossy(path.as_bytes()).into_owned()) - .collect::>(); - assert!(members.contains("assets/present.png"), "{members:?}"); - assert!(!members.contains("assets/banner.jpeg"), "{members:?}"); - assert!(!members.contains("fixtures/binary.bin"), "{members:?}"); - assert!(!members.contains("fixtures/model.db"), "{members:?}"); - #[cfg(unix)] - { - assert!(members.contains("dangling.rs"), "{members:?}"); - assert!(!members.contains("removed_link.rs"), "{members:?}"); - } - - let ledger = crate::path_status::PathStatusStore::open(&view.view_dir) - .unwrap() - .summary() - .unwrap(); - let annotated = ledger - .paths - .iter() - .map(|status| { - format!( - "{} {} ({})", - String::from_utf8_lossy(&status.rel_path), - status.state.as_str(), - status.reason - ) - }) - .collect::>(); - assert_eq!( - (ledger.pending_count, ledger.failed_count), - (0, 0), - "paths left annotated after a complete publication: {annotated:?}" - ); - let health = ctx.view_health_snapshot().expect("view health"); - assert_eq!(health.generation, 1); - assert!(health.pinned); - assert_eq!((health.pending_paths, health.failed_paths), (0, 0)); - } - /// A tracked source in a call-graph language that is not valid UTF-8 can - /// never be parsed. It must not fail the publication (which would leave - /// every HEAD path pending forever): it joins the generation with an - /// empty call graph and is reported as skipped, apart from pending and - /// failed, while every other file keeps its call graph. + /// never be parsed. It must not fail the root's call-graph view: it joins + /// with an empty call graph while every other file keeps its call graph, + /// and once it decodes, its symbols join too. #[test] - fn views_publication_skips_a_non_utf8_callgraph_source_and_completes() { + fn views_callgraph_view_skips_a_non_utf8_callgraph_source_and_answers() { 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(); @@ -13977,56 +12869,29 @@ mod tests { assert!(response.success, "{}", response.data); super::drain_deferred_configure_maintenance(&ctx); - - let health = ctx.view_health_snapshot().expect("view health"); - assert_eq!(health.generation, 1, "{health:?}"); - assert!(health.pinned, "{health:?}"); - assert_eq!((health.pending_paths, health.failed_paths), (0, 0)); - assert_eq!( - health.skipped_paths, - std::collections::BTreeMap::from([("non_utf8_source".to_string(), 1)]) - ); - let status = serde_json::to_value(&health).unwrap(); - assert_eq!(status["skipped_paths"]["non_utf8_source"], json!(1)); - assert_eq!( - status["skipped_path_sample"], - json!([{ "path": "latin1.py", "reason": "non_utf8_source" }]) - ); - let view = ctx.view_runtime_snapshot().expect("view runtime"); - let manifest = view.manifest.as_ref().expect("published manifest"); - assert!(manifest - .entries() - .any(|(path, _)| path.as_bytes() == b"latin1.py")); - match ctx.callgraph_store_for_ops() { + assert!(ctx.views_route_root()); + let graph_has = |file: &str, symbol: &str| match ctx.callgraph_store_for_ops() { CallgraphStoreAccess::Ready(store) => { - assert_eq!(store.reader_kind(), "view"); - for (file, symbol) in [("tracked.rs", "tracked"), ("util.py", "helper")] { - assert!( - crate::callgraph_store::CallGraphRead::node_for( - &store, - Path::new(file), - symbol, - ) - .is_ok(), - "{file}:{symbol} left the call graph" - ); - } + crate::callgraph_store::CallGraphRead::node_for(&store, Path::new(file), symbol) + .is_ok() } - _ => panic!("expected view callgraph reader"), + CallgraphStoreAccess::Error(error) => panic!("call-graph view refused: {error}"), + _ => panic!("expected the call-graph view reader"), + }; + for (file, symbol) in [("tracked.rs", "tracked"), ("util.py", "helper")] { + assert!( + graph_has(file, symbol), + "{file}:{symbol} left the call graph" + ); } + assert!(!graph_has("latin1.py", "caf\u{e9}")); - // Once the file decodes, its skip is cleared by the next publication. + // Once the file decodes, the next read of the view includes it. std::fs::write(root.join("latin1.py"), "def cafe():\n pass\n").unwrap(); - ctx.publish_view_paths(BTreeSet::from([b"latin1.py".to_vec()]), true) - .unwrap(); - // Read the ledger directly: status caches its summary between reads. - let ledger = crate::path_status::PathStatusStore::open(&view.view_dir) - .unwrap() - .summary() - .unwrap(); - assert!(ledger.skipped_by_reason.is_empty(), "{ledger:?}"); - assert!(ledger.skipped_sample.is_empty(), "{ledger:?}"); - assert_eq!((ledger.pending_count, ledger.failed_count), (0, 0)); + ctx.record_checkout_watcher_change(&canonical_root.join("latin1.py")); + assert!(graph_has("latin1.py", "cafe")); + assert!(graph_has("util.py", "helper")); + assert!(ctx.view_runtime_snapshot().is_none()); } /// While a views-on root is unbound past the grace window its semantic @@ -14291,123 +13156,6 @@ mod tests { ); } - /// Callgraph view publication never waits on semantic embedding. With - /// views enabled, semantic vectors belong to the root's per-checkout - /// semantic view, so the older view publishes callgraph data with no - /// semantic plane at all, even while the semantic view's first fill is - /// stuck at the embedding server. - #[test] - fn callgraph_view_publishes_while_semantic_view_fill_is_held() { - 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 _quiet_window = EnvVarGuard::set("AFT_SEMANTIC_QUIET_WINDOW_MS", "50"); - let server = CountingEmbeddingServer::start(); - let project = tempfile::tempdir().unwrap(); - let storage = tempfile::tempdir().unwrap(); - init_git_fixture(project.path()); - let canonical_root = project.path().canonicalize().unwrap(); - let source = canonical_root.join("tracked.rs"); - let ctx = AppContext::new( - Box::new(TreeSitterProvider::new()), - Config { - storage_dir: Some(storage.path().to_path_buf()), - ..Config::default() - }, - ); - let request = configure_semantic_views_with_timeout( - &canonical_root, - storage.path(), - &server.base_url, - 30_000, - ); - assert!(handle_configure_for_test(&request, &ctx).success); - super::drain_deferred_configure_maintenance(&ctx); - // The non-probe request proves the fill reached the server's response - // gate. Draining maintenance only releases the worker: model startup, - // view loading and cold-build admission can still be queued. Allow a - // 60-second hang guard rather than imposing a startup latency budget. - assert!( - server.wait_for_non_probe_request_count(1, Duration::from_secs(60)), - "the semantic view's first fill did not reach the embedding server: {}", - ctx.checkout_semantic().describe() - ); - assert!( - ctx.semantic_index() - .read() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .is_none(), - "a views-on root must not build the legacy semantic index" - ); - - let second_source = - "pub fn newly_visible() {}\npub fn invokes_new() { newly_visible(); }\n"; - fs::write(&source, second_source).unwrap(); - assert!(git_command(project.path()) - .args(["add", "tracked.rs"]) - .status() - .unwrap() - .success()); - assert!(git_command(project.path()) - .args([ - "-c", - "user.name=AFT Tests", - "-c", - "user.email=aft-tests@example.com", - "commit", - "--quiet", - "-m", - "callgraph change while semantic is held", - ]) - .status() - .unwrap() - .success()); - ctx.update_config(|config| config.indexes.callgraph = true); - let callgraph_report = ctx - .publish_view_paths(BTreeSet::from([b"tracked.rs".to_vec()]), true) - .unwrap(); - assert!(callgraph_report.published); - assert!( - callgraph_report.pending_paths.is_empty(), - "the older view must not wait for semantic vectors it no longer carries" - ); - match ctx.callgraph_store_for_ops() { - CallgraphStoreAccess::Ready(store) => { - assert_eq!(store.reader_kind(), "view"); - assert!(crate::callgraph_store::CallGraphRead::node_for( - &store, - Path::new("tracked.rs"), - "newly_visible", - ) - .is_ok()); - } - _ => panic!("callgraph plane was not readable while embedding was held"), - } - - server.release_responses(); - // A fill event describes one worker round, not query readiness. Wait for - // the semantic view to cover the edited source after releasing the server. - ctx.checkout_semantic().wake(); - let deadline = Instant::now() + Duration::from_secs(30); - loop { - let complete = ctx.checkout_semantic_runtime().map(|runtime| { - runtime - .search(&[1.0, 0.0, 0.0], 10, &|_| true) - .map(|answer| answer.complete()) - }); - if matches!(complete, Some(Ok(true))) { - break; - } - assert!( - Instant::now() < deadline, - "semantic view did not become complete after response release; \ - lane={}; search={complete:?}", - ctx.checkout_semantic().describe() - ); - std::thread::sleep(Duration::from_millis(10)); - } - } - #[test] fn legacy_semantic_import_runs_once_and_records_its_outcome() { let _git_env = crate::test_env::hermetic_git_env_guard(); @@ -16019,11 +14767,12 @@ mod tests { "{direct:?}" ); assert_eq!(legacy_views_on_writes(&storage), Vec::::new()); - assert!( - crate::blob_store::v3::family_dir(&storage, &ctx.memoized_artifact_cache_key(&root)) - .unwrap() - .is_dir() - ); + assert!(crate::blob_store::v3::family_dir( + &storage, + &ctx.memoized_artifact_cache_key(&root) + ) + .unwrap() + .is_dir()); // Views off is not a routed root. let off = test_context(); diff --git a/crates/aft/src/context.rs b/crates/aft/src/context.rs index 5ae302b57b..1217973148 100644 --- a/crates/aft/src/context.rs +++ b/crates/aft/src/context.rs @@ -9637,17 +9637,6 @@ impl AppContext { self.view_publication_attempts.load(Ordering::Acquire) } - #[cfg(test)] - pub(crate) fn view_publication_attempt_started_for_test( - &self, - attempt: usize, - ) -> Option { - self.view_publication_attempt_starts - .lock() - .get(attempt) - .copied() - } - pub(crate) fn clear_view_runtime(&self) { self.release_view_publication_cache(); if let Some(root) = self.canonical_cache_root_opt() { @@ -18369,7 +18358,6 @@ mod callgraph_store_for_ops_tests { use crate::parser::TreeSitterProvider; use crate::protocol::RawRequest; use serde_json::json; - use std::collections::HashSet; use std::path::Path; use std::sync::Barrier; use tempfile::TempDir; @@ -20268,20 +20256,6 @@ mod callgraph_store_for_ops_tests { assert!(status.success(), "git {args:?} failed with {status}"); } - fn publish(&self) { - let report = self - .ctx - .publish_view_paths(BTreeSet::new(), true) - .expect("view publication"); - assert!(report.published, "first view generation must publish"); - assert_eq!( - self.ctx - .view_runtime_snapshot() - .and_then(|view| view.generation), - report.generation - ); - } - fn callers(&self) -> crate::protocol::Response { crate::commands::callers::handle_callers( &RawRequest { @@ -20342,92 +20316,6 @@ mod callgraph_store_for_ops_tests { } } - #[test] - fn worktree_view_publishes_dirty_source_after_a_watcher_event() { - let fixture = ViewsCallgraphFixture::new(); - fixture.ctx.set_cache_role(true, None); - fixture - .ctx - .update_config(|config| config.worktree.ram_overlay = false); - fixture.publish(); - let edited = "pub fn dirty_caller() { target(); }\npub fn target() {}\n"; - std::fs::write(&fixture.source, edited).unwrap(); - fixture - .watcher_tx - .send(crate::watcher_filter::WatcherDispatchEvent::Paths(vec![ - fixture.source.clone(), - ])) - .unwrap(); - let outcome = crate::runtime_drain::drain_watcher_events_bounded(&fixture.ctx, 1); - assert_eq!(outcome.processed, 1); - let mut state = fixture.ctx.watcher_drain_slice().lock().take().unwrap(); - assert!(state.view_publication_due.is_some()); - state.view_publication_due = Some(Instant::now()); - crate::runtime_drain::publish_view_if_quiet(&fixture.ctx, &mut state); - assert!(state.view_publication_due.is_none()); - *fixture.ctx.watcher_drain_slice().lock() = Some(state); - - let response = fixture.callers(); - assert!(response.success, "{response:?}"); - assert_eq!(response.data["total_callers"], 1); - assert!( - response.data.to_string().contains("dirty_caller"), - "{response:?}" - ); - assert!(fixture.ctx.shared_artifacts_read_only()); - assert!(!fixture.ctx.callgraph_writer()); - assert!(!fixture.ctx.ram_overlay_active()); - assert!( - crate::callgraph_store::CallGraphRead::node_for( - fixture.legacy.as_ref(), - Path::new("lib.rs"), - "dirty_caller" - ) - .is_err(), - "watcher must not update the legacy graph" - ); - } - - #[test] - fn worktree_view_publishes_callgraph_without_importing_legacy_semantics() { - let fixture = ViewsCallgraphFixture::new(); - fixture.ctx.set_cache_role(true, None); - fixture.commit_source( - "pub fn local_caller() { target(); }\npub fn target() {}\n", - "local source", - ); - fixture - .ctx - .update_config(|config| config.indexes.semantic = true); - assert!(!fixture.ctx.checkout_semantic.active()); - assert!(fixture.ctx.semantic_index().read().unwrap().is_none()); - - let report = fixture - .ctx - .publish_view_paths(BTreeSet::new(), true) - .unwrap(); - assert!( - report.published, - "callgraph must not wait for a borrowed legacy semantic index" - ); - assert_eq!( - report.pending_inputs[b"lib.rs".as_slice()].plane, - "semantic" - ); - assert_eq!( - fixture.ctx.view_runtime_snapshot().unwrap().pending_inputs[b"lib.rs".as_slice()].plane, - "semantic" - ); - let response = fixture.callers(); - assert!(response.success, "{response:?}"); - assert!( - response.data.to_string().contains("local_caller"), - "{response:?}" - ); - assert!(fixture.ctx.shared_artifacts_read_only()); - assert!(fixture.ctx.semantic_index().read().unwrap().is_none()); - } - #[test] fn readonly_assembly_building_names_missing_callgraph_blob() { let fixture = ViewsCallgraphFixture::new(); @@ -20563,29 +20451,6 @@ mod callgraph_store_for_ops_tests { .expect("exclusive derived lock"); holder } - - /// Hold the derived database of the pinned generation busy the way a - /// writer does mid-maintenance: an exclusive lock that a zero-wait - /// reader cannot get past. Waits for the publication's own deferred - /// checkpoint first so only this connection holds the file. The lock - /// lasts as long as the returned connection. - fn hold_pinned_derived_busy(&self) -> rusqlite::Connection { - let view = self.ctx.pinned_view_runtime().expect("pinned view runtime"); - crate::views::wait_for_derived_checkpoint_for_test(&view.view_dir); - let generation = view.generation.expect("pinned generation"); - let path = crate::views::resolve_derived_path(&view.view_dir, &generation) - .expect("derived path"); - assert!( - path.is_file(), - "derived database {} missing", - path.display() - ); - let holder = rusqlite::Connection::open(&path).expect("derived writer"); - holder - .execute_batch("PRAGMA locking_mode=EXCLUSIVE; BEGIN EXCLUSIVE;") - .expect("exclusive derived lock"); - holder - } } /// A call graph turned off is never built and never answered: with views @@ -20721,88 +20586,6 @@ mod callgraph_store_for_ops_tests { ); } - /// An automatic dead-code refresh that finds the published graph's - /// database locked has nothing else to wake it: the lock clearing is not a - /// view publication and not a source edit. It must keep a timed retry, so - /// on a quiet checkout dead code refreshes by itself once the lock clears. - #[test] - fn views_busy_dead_code_refresh_retries_after_lock_clears_without_edit() { - use crate::inspect::{InspectCategory, InspectSnapshot, JobScope, Tier2TriggerReason}; - let fixture = ViewsCallgraphFixture::new(); - fixture.ctx.set_harness(crate::harness::Harness::Opencode); - fixture.publish(); - let manager = fixture.ctx.inspect_manager(); - let config = fixture.ctx.config(); - let wait_idle = |what: &str| { - let deadline = Instant::now() + Duration::from_secs(60); - while manager.tier2_any_in_flight() { - assert!(Instant::now() < deadline, "{what} did not finish"); - std::thread::sleep(Duration::from_millis(10)); - } - manager.drain_completions(); - }; - - let busy = fixture.hold_pinned_derived_busy(); - fixture.ctx.reset_tier2_refresh_scheduler_at(Instant::now()); - assert!(fixture.ctx.request_tier2_refresh_pull()); - let dispatched_at = Instant::now(); - assert_eq!( - fixture - .ctx - .tick_tier2_refresh_scheduler_at(Instant::now(), 0), - Some(Tier2TriggerReason::Pull) - ); - wait_idle("refresh against the locked view"); - let retry_at = manager - .try_tier2_resource_retry_at(&config) - .flatten() - .expect("a refresh that found the view locked must keep a timed retry"); - assert!( - retry_at - <= dispatched_at - + crate::inspect::tier2_scheduler::TIER2_CACHE_WRITER_RETRY_BACKOFF - + Duration::from_secs(60), - "the retry must fall within the resource retry bound" - ); - let detail = manager.tier2_builder_state_detail(InspectCategory::DeadCode); - assert!(detail.contains("call graph view"), "{detail}"); - - drop(busy); - assert!(!fixture.ctx.tier2_pull_demand_pending()); - manager.expire_tier2_resource_waits_for_test(); - assert!( - fixture.ctx.tier2_refresh_dispatch_due(), - "a quiet checkout must come due for the retry without an edit" - ); - assert_eq!( - fixture - .ctx - .tick_tier2_refresh_scheduler_at(Instant::now(), 0), - Some(Tier2TriggerReason::ResourceRetry) - ); - wait_idle("retry after the lock cleared"); - let outcome = manager.tier2_read_cached_readonly( - InspectSnapshot { - project_root: fixture.root.clone(), - inspect_dir: fixture.ctx.inspect_dir(), - config: config.clone(), - symbol_cache: fixture.ctx.symbol_cache(), - inspect_writer: true, - callgraph_writer: true, - }, - InspectCategory::DeadCode, - JobScope::for_project(fixture.root.clone()), - ); - let payload = outcome.payload().expect("dead-code result after the retry"); - assert_eq!(payload["callgraph_available"], true, "{payload:#}"); - assert!(payload["count"].as_u64().is_some(), "{payload:#}"); - assert_eq!( - manager.try_tier2_resource_retry_at(&config), - Some(None), - "a successful retry must not leave another one armed" - ); - } - /// Zoom's call graph field reads the same store as `callers` and must give /// the same verdict for a busy database: building, retry shortly. A busy /// open says nothing about whether the graph exists, so reporting it as @@ -20853,134 +20636,6 @@ mod callgraph_store_for_ops_tests { ); } - #[test] - fn views_published_navigation_uses_cached_head_without_git_spawns() { - let fixture = ViewsCallgraphFixture::new(); - fixture.publish(); - crate::alias::reset_head_tree_entry_calls_for_test(); - - for _ in 0..20 { - let response = fixture.callers(); - assert!(response.success, "{response:?}"); - assert_eq!(response.data["total_callers"], 1); - } - - assert_eq!( - crate::alias::head_tree_entry_calls_for_test(), - 0, - "navigation must not rebuild the HEAD manifest on its read path" - ); - } - - #[test] - fn views_legacy_refresh_ignores_busy_process_worker() { - // Only the process-worker control needs this lock: refresh-worker tests - // shut that worker down or hold its queue while exercising exit paths. - let _guard = crate::callgraph_store::REFRESH_WORKER_TEST_LOCK - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - // Other roots may legitimately be waiting for a store or writer lease. - // The gate proves this fixture's batch started without flushing that work. - let (_busy_dirs, busy_ctx) = cold_build_context(); - let busy_root = std::fs::canonicalize(busy_ctx.config().project_root.as_ref().unwrap()) - .expect("canonical busy root"); - let family = crate::search_index::artifact_cache_key(&busy_root); - crate::root_cache::configure_artifact_access(&busy_root, &family, false); - busy_ctx.set_canonical_cache_root(busy_root.clone()); - busy_ctx - .ensure_callgraph_store() - .expect("busy root callgraph build") - .expect("busy root callgraph reader"); - let (held, release) = - crate::callgraph_store::install_callgraph_refresh_worker_test_gate(busy_root.clone()); - assert!(busy_ctx.enqueue_callgraph_store_refresh([busy_root.join("src/lib.rs")])); - held.recv_timeout(Duration::from_secs(12)) - .expect("process worker must be held on the unrelated root"); - - // Exercise the entire migration window while another root cannot finish - // its refresh. A views fixture must not inherit that queue's backlog. - views_first_publication_retires_legacy_after_migration_window(); - - release.send(()).expect("release busy process worker"); - assert!( - crate::callgraph_store::wait_for_process_callgraph_refresh_for_test( - &busy_root, - Duration::from_secs(12) - ) - ); - } - - #[test] - fn views_first_publication_retires_legacy_after_migration_window() { - let fixture = ViewsCallgraphFixture::new(); - // Process-worker tests hold unrelated roots and shut down the shared - // queue. Keep this root's migration refreshes on a fixture-owned worker. - let worker = crate::callgraph_store::isolated_callgraph_refresh_worker_for_test( - fixture.root.clone(), - ); - crate::runtime_drain::reset_legacy_watcher_refreshes_for_test(); - crate::runtime_drain::refresh_callgraph_store_for_watcher( - &fixture.ctx, - &HashSet::from([fixture.source.clone()]), - ); - assert_eq!( - crate::runtime_drain::legacy_watcher_refreshes_for_test(), - 1, - "legacy refresh must remain active before the first publication" - ); - assert!( - worker.wait(Duration::from_secs(12)), - "legacy refresh worker hung" - ); - let legacy_response = fixture.callers(); - assert!(legacy_response.success, "{legacy_response:?}"); - assert_eq!(legacy_response.data["total_callers"], 1); - - fixture.publish(); - assert!(matches!( - fixture.ctx.callgraph_store_for_ops(), - CallgraphStoreAccess::Ready(store) if store.reader_kind() == "view" - )); - crate::runtime_drain::reset_legacy_watcher_refreshes_for_test(); - crate::runtime_drain::refresh_callgraph_store_for_watcher( - &fixture.ctx, - &HashSet::from([fixture.source.clone()]), - ); - assert_eq!( - crate::runtime_drain::legacy_watcher_refreshes_for_test(), - 0, - "the first publication must retire watcher writes to legacy" - ); - - std::fs::write( - &fixture.source, - "pub fn caller() { target(); }\npub fn resumed() { target(); }\npub fn target() {}\n", - ) - .expect("views-off source update"); - fixture - .ctx - .update_config(|config| config.views.enabled = false); - crate::runtime_drain::refresh_callgraph_store_for_watcher( - &fixture.ctx, - &HashSet::from([fixture.source.clone()]), - ); - assert_eq!( - crate::runtime_drain::legacy_watcher_refreshes_for_test(), - 1, - "disabling views must enqueue legacy refresh again" - ); - assert!( - worker.wait(Duration::from_secs(12)), - "views-off legacy refresh worker hung" - ); - let response = fixture.callers(); - assert!(response.success, "{response:?}"); - assert_eq!( - response.data["total_callers"], 2, - "views-off legacy refresh did not converge: {response:?}" - ); - } - #[test] fn take_pending_callgraph_store_paths_drops_paths_outside_current_root() { let project = TempDir::new().expect("project tempdir"); diff --git a/crates/aft/src/db/file_identity.rs b/crates/aft/src/db/file_identity.rs index 22d06c9913..6d48882cc3 100644 --- a/crates/aft/src/db/file_identity.rs +++ b/crates/aft/src/db/file_identity.rs @@ -761,26 +761,6 @@ pub(crate) fn has_open_connections_under(path: &Path) -> bool { .any(|(path, database)| path.starts_with(&key) && !database.openers.is_empty()) } -/// Describe every registered connection to a database under `path`, naming -/// the database, the opener's store label and the thread that opened it. -/// Tests use it to show what still holds files they are about to delete. -#[cfg(test)] -pub(crate) fn open_connections_under_for_test(path: &Path) -> Vec { - let key = registry_key(path); - open_databases() - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .iter() - .filter(|(path, _)| path.starts_with(&key)) - .flat_map(|(path, database)| { - database - .openers - .iter() - .map(move |opener| format!("{} held by {}", path.display(), opener.describe())) - }) - .collect() -} - /// Report `action` if it is about to delete, rename, or replace the database /// file at `path` while this process still has a connection open on it. /// diff --git a/crates/aft/src/executor/view_publication.rs b/crates/aft/src/executor/view_publication.rs index a0ad328c5b..a2c49ece8f 100644 --- a/crates/aft/src/executor/view_publication.rs +++ b/crates/aft/src/executor/view_publication.rs @@ -977,9 +977,6 @@ pub(crate) fn schedule_with_reason( #[path = "view_publication_tests.rs"] mod tests; -#[cfg(test)] -pub(crate) use tests::{CompletionOutcome, Completions}; - #[cfg(test)] thread_local! { static IN_COMMIT_BARRIER: std::cell::Cell = const { std::cell::Cell::new(false) }; @@ -1005,12 +1002,6 @@ impl Drop for CommitBarrierMark { } } -/// Whether this thread is inside a publication's commit barrier. -#[cfg(test)] -pub(crate) fn in_commit_barrier_for_test() -> bool { - IN_COMMIT_BARRIER.get() -} - #[cfg(test)] fn test_gate(id: u64, root: &std::path::Path, phase: &str) { tests::phase_gate(id, root, phase); diff --git a/crates/aft/src/executor/view_publication_tests.rs b/crates/aft/src/executor/view_publication_tests.rs index c577473d74..024384d51f 100644 --- a/crates/aft/src/executor/view_publication_tests.rs +++ b/crates/aft/src/executor/view_publication_tests.rs @@ -1,12 +1,4 @@ use super::*; -use crate::{ - config::Config, - context::{CallgraphStoreAccess, ViewRuntimeSnapshot}, - executor::{Executor, ExecutorConfig, Lane}, - parser::TreeSitterProvider, - path_identity::ProjectRootId, - protocol::Response, -}; use crossbeam_channel::{Receiver, Sender}; use std::{path::Path, time::Duration}; @@ -216,9 +208,6 @@ impl Completions { SETTLED.lock().insert(root.clone(), tx); Self { root, rx } } - fn next(&self) -> u64 { - self.next_event().id - } pub(crate) fn next_event(&self) -> Completion { self.rx .recv_timeout(Duration::from_secs(60)) @@ -278,503 +267,6 @@ fn publication_completion_reports_a_worker_panic_as_failure() { ); } -#[test] -fn publication_completion_distinguishes_a_noop() { - let fixture = Fixture::new(); - let completions = Completions::new(fixture.job_root()); - fixture.schedule(); - let event = completions.next_event(); - assert_eq!(event.root, fixture.job_root()); - assert_eq!(event.outcome, CompletionOutcome::Noop); - assert_eq!( - fixture.ctx.view_runtime_snapshot().unwrap().generation, - Some(fixture.initial.clone()) - ); -} - -#[test] -fn publication_non_manifest_batches_take_no_slots_or_manifests() { - let fixture = Fixture::with_files(1225); - fixture.ctx.isolate_cold_build_limiter_for_test(2); - let limiter = fixture.ctx.cold_build_limiter(); - let before = fixture.ctx.view_publication_attempts_for_test(); - std::fs::create_dir(fixture.job_root().join("generated")).unwrap(); - let paths: BTreeSet<_> = (0..581) - .map(|n| { - let relative = format!("generated/output_{n}.rs"); - std::fs::write( - fixture.job_root().join(&relative), - format!("pub fn generated_{n}() {{}}\n"), - ) - .unwrap(); - relative.into_bytes() - }) - .collect(); - for _ in 0..113 { - fixture.schedule_paths(paths.clone()); - fixture.wait_idle_for(Duration::from_secs(60)); - } - let manifests = fixture.ctx.view_publication_attempts_for_test() - before; - let slots = limiter - .admission_events() - .last() - .map_or(0, |event| event.admission_order); - eprintln!("NON_MANIFEST_2_5H batches=113 pending_notifications=581 members=1226 slots={slots} manifests={manifests} slots_per_hour={:.1}", slots as f64 / 2.5); - assert_eq!( - slots, 0, - "irrelevant notifications acquired cold-build slots" - ); - assert_eq!(manifests, 0, "irrelevant notifications assembled manifests"); - assert_eq!( - fixture.view.current_generation().unwrap(), - Some(fixture.initial.clone()) - ); - - // HEAD and the index are unchanged: the watcher path must still admit a - // real working-tree edit, even when it arrives mixed with build outputs. - std::fs::write( - fixture.project.path().join("tracked.rs"), - "pub fn edited() {}\n", - ) - .unwrap(); - let mut changed = paths; - changed.insert(b"tracked.rs".to_vec()); - fixture.schedule_paths(changed); - fixture.wait_idle_for(Duration::from_secs(60)); - assert_eq!(limiter.admission_events().len(), 1); - assert_eq!(fixture.ctx.view_publication_attempts_for_test() - before, 1); - assert_ne!( - fixture.view.current_generation().unwrap(), - Some(fixture.initial.clone()) - ); - fixture.assert_cold_parity(); -} - -#[test] -fn publication_precheck_preserves_rescans_controls_and_ambiguous_paths() { - let fixture = Fixture::new(); - fixture.ctx.isolate_cold_build_limiter_for_test(2); - for (n, path) in [ - None, - Some("TRACKED.RS"), - Some(".gitattributes"), - Some(".gitignore"), - Some(".aftignore"), - Some(".ignore"), - Some("../tracked.rs"), - Some("tracked.rs."), - Some("tracked.rs "), - Some("TRACK~1.RS"), - Some("tracked.rs::$DATA"), - Some("caf\u{e9}.rs"), - ] - .into_iter() - .enumerate() - { - let before = fixture.view.current_generation().unwrap(); - std::fs::write( - fixture.job_root().join("tracked.rs"), - format!("pub fn edit_{n}() {{}}\n"), - ) - .unwrap(); - fixture.schedule_paths(path.into_iter().map(|p| p.as_bytes().to_vec()).collect()); - fixture.wait_idle(); - assert_ne!( - fixture.view.current_generation().unwrap(), - before, - "lost edit on {path:?}" - ); - } - fixture.assert_cold_parity(); -} - -#[test] -fn publication_precheck_scopes_database_admission_to_its_members() { - let fixture = Fixture::new(); - fixture.ctx.isolate_cold_build_limiter_for_test(2); - let outside = tempfile::tempdir().unwrap(); - let database = outside.path().join("outside.sqlite"); - let connection = - crate::db::file_identity::IdentityConnection::open(&database, "view precheck test") - .unwrap(); - connection - .execute_batch("CREATE TABLE unrelated(value TEXT)") - .unwrap(); - let noise = fixture.non_member_paths(); - assert!( - fixture.ctx.can_skip_irrelevant_view_paths(&noise), - "unrelated live databases must not force manifest work" - ); - std::fs::remove_file(fixture.job_root().join("tracked.rs")).unwrap(); - std::fs::hard_link(&database, fixture.job_root().join("tracked.rs")).unwrap(); - assert!( - !fixture.ctx.can_skip_irrelevant_view_paths(&noise), - "a member that became a database alias must still reconcile" - ); - fixture.schedule_paths(noise); - fixture.wait_idle(); - let generation = fixture.view.current_generation().unwrap().unwrap(); - let tracked = crate::views::RelPath::new(b"tracked.rs".to_vec()).unwrap(); - assert!(fixture - .view - .load_manifest(&generation) - .unwrap() - .get(&tracked) - .is_none()); -} - -// Admission is process-wide. Run these probes alone so another test's database -// lifetime cannot activate the fresh-stat fallback and hide a missing guard. -fn precheck_guard_child(test: &str) -> bool { - const CHILD: &str = "AFT_VIEW_PRECHECK_GUARD_CHILD"; - if std::env::var(CHILD).as_deref() == Ok(test) { - return true; - } - let output = std::process::Command::new(std::env::current_exe().unwrap()) - .args(["--exact", test, "--nocapture", "--test-threads=1"]) - .env(CHILD, test) - .output() - .unwrap(); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!( - stdout.contains("running 1 test"), - "isolated test did not run: {stdout}" - ); - assert!( - output.status.success(), - "isolated {test} failed:\n{}\n{}", - stdout, - String::from_utf8_lossy(&output.stderr) - ); - false -} - -fn settled_precheck_admission(fixture: &Fixture) -> crate::db::file_identity::AdmissionSnapshot { - assert!(fixture - .ctx - .can_skip_irrelevant_view_paths(&fixture.non_member_paths())); - let admission = crate::db::file_identity::admission_snapshot(fixture._storage.path()); - // Refresh the successful proof after the positive control. From this - // publication to the tested probe, do not query the view's pointer database. - fixture - .ctx - .publish_view_paths(BTreeSet::new(), true) - .unwrap(); - assert_eq!( - crate::db::file_identity::admission_snapshot(fixture._storage.path()), - admission, - "settled publication changed external database admission" - ); - admission -} - -#[test] -fn publication_precheck_guard_refuses_named_tracked_edit_without_admission_change() { - if !precheck_guard_child("executor::view_publication::tests::publication_precheck_guard_refuses_named_tracked_edit_without_admission_change") { - return; - } - let fixture = Fixture::new(); - let admission = settled_precheck_admission(&fixture); - std::fs::write( - fixture.job_root().join("tracked.rs"), - "pub fn tracked_worktree_edit() {}\n", - ) - .unwrap(); - assert_eq!( - crate::db::file_identity::admission_snapshot(fixture._storage.path()), - admission - ); - let skippable = fixture - .ctx - .can_skip_irrelevant_view_paths(&BTreeSet::from([b"tracked.rs".to_vec()])); - assert_eq!( - crate::db::file_identity::admission_snapshot(fixture._storage.path()), - admission, - "probe changed database admission" - ); - assert!( - !skippable, - "a named tracked edit must be refused by the membership guard" - ); -} - -#[test] -fn publication_precheck_guard_refuses_index_only_add_without_admission_change() { - if !precheck_guard_child("executor::view_publication::tests::publication_precheck_guard_refuses_index_only_add_without_admission_change") { - return; - } - let fixture = Fixture::new(); - let admission = settled_precheck_admission(&fixture); - let head_members = || { - let mut command = std::process::Command::new("git"); - crate::test_env::apply_hermetic_git_env(command.current_dir(fixture.project.path())); - let output = command.args(["ls-tree", "-r", "HEAD"]).output().unwrap(); - assert!( - output.status.success(), - "{}", - String::from_utf8_lossy(&output.stderr) - ); - output.stdout - }; - let head_before = head_members(); - let tracked_before = std::fs::read(fixture.job_root().join("tracked.rs")).unwrap(); - let index = fixture.job_root().join(".git/index"); - let index_before = std::fs::read(&index).unwrap(); - std::fs::write( - fixture.job_root().join("staged-only.rs"), - "pub fn staged_only() {}\n", - ) - .unwrap(); - git(fixture.project.path(), &["add", "staged-only.rs"]); - assert_ne!(std::fs::read(index).unwrap(), index_before); - assert_eq!( - head_members(), - head_before, - "staging must not change HEAD membership" - ); - assert_eq!( - std::fs::read(fixture.job_root().join("tracked.rs")).unwrap(), - tracked_before - ); - assert_eq!( - crate::db::file_identity::admission_snapshot(fixture._storage.path()), - admission - ); - let skippable = fixture - .ctx - .can_skip_irrelevant_view_paths(&BTreeSet::from([b"staged-only.rs".to_vec()])); - assert_eq!( - crate::db::file_identity::admission_snapshot(fixture._storage.path()), - admission, - "probe changed database admission" - ); - assert!( - !skippable, - "a staged nonmember must be refused by the index guard" - ); -} - -#[test] -fn publication_precheck_revision_hard_link_alias_updates_tracked_manifest() { - if !precheck_guard_child("executor::view_publication::tests::publication_precheck_revision_hard_link_alias_updates_tracked_manifest") { - return; - } - let fixture = Fixture::new(); - fixture.ctx.isolate_cold_build_limiter_for_test(2); - let admission = settled_precheck_admission(&fixture); - let root = fixture.job_root(); - std::fs::hard_link(root.join("tracked.rs"), root.join("output.rs")).unwrap(); - let edited = b"pub fn changed_through_nonmember_alias() {}\n"; - std::fs::write(root.join("output.rs"), edited).unwrap(); - let paths = BTreeSet::from([b"output.rs".to_vec()]); - assert_eq!( - crate::db::file_identity::admission_snapshot(fixture._storage.path()), - admission - ); - let skippable = fixture.ctx.can_skip_irrelevant_view_paths(&paths); - assert_eq!( - crate::db::file_identity::admission_snapshot(fixture._storage.path()), - admission - ); - assert!( - !skippable, - "a newly linked nonmember can modify a manifest member" - ); - fixture.schedule_paths(paths); - fixture.wait_idle(); - let generation = fixture.view.current_generation().unwrap().unwrap(); - assert_ne!(generation, fixture.initial); - let manifest = fixture.view.load_manifest(&generation).unwrap(); - let path = crate::views::RelPath::new(b"tracked.rs".to_vec()).unwrap(); - let Some(crate::views::ManifestEntry::Regular { planes, .. }) = manifest.get(&path) else { - panic!("tracked member must remain in the published manifest"); - }; - let expected = crate::blob_store::CallgraphKey::from_bytes( - edited, - "rust", - crate::views::callgraph::PRODUCER, - ) - .full_key() - .to_hex(); - assert_eq!(planes.callgraph.as_deref(), Some(expected.as_str())); -} - -#[test] -fn publication_precheck_revision_paths_are_built_once_per_head() { - if !precheck_guard_child("executor::view_publication::tests::publication_precheck_revision_paths_are_built_once_per_head") { - return; - } - let fixture = Fixture::with_files(1225); - let noise = fixture.non_member_paths(); - let before = crate::views::assembly::precheck_path_builds_for_test(); - for _ in 0..50 { - assert!(!fixture - .ctx - .can_skip_irrelevant_view_paths(&BTreeSet::from([b"tracked.rs".to_vec()]))); - assert!(fixture.ctx.can_skip_irrelevant_view_paths(&noise)); - } - let builds = crate::views::assembly::precheck_path_builds_for_test() - before; - eprintln!("PRECHECK_HEAD_PATHS members=1226 batches=100 builds={builds}"); - assert_eq!( - builds, 1, - "the same HEAD must reuse its folded membership set" - ); -} - -#[test] -fn publication_precheck_revision_missing_named_path_refuses_shortcut() { - let fixture = Fixture::new(); - let paths = BTreeSet::from([b"missing.rs".to_vec()]); - assert!( - !fixture.ctx.can_skip_irrelevant_view_paths(&paths), - "an unstatable event path is not evidence for skipping publication" - ); -} - -#[test] -fn publication_precheck_preserves_head_index_directories_and_verification() { - let fixture = Fixture::new(); - fixture.ctx.isolate_cold_build_limiter_for_test(2); - let noise = fixture.non_member_paths(); - std::fs::create_dir(fixture.job_root().join("src")).unwrap(); - std::fs::write( - fixture.job_root().join("src/member.rs"), - "pub fn member() {}\n", - ) - .unwrap(); - commit(&fixture.job_root()); - fixture.schedule_paths(noise.clone()); - fixture.wait_idle(); - let generation = fixture.view.current_generation().unwrap().unwrap(); - let member = crate::views::RelPath::new(b"src/member.rs".to_vec()).unwrap(); - assert!(fixture - .view - .load_manifest(&generation) - .unwrap() - .get(&member) - .is_some()); - - std::fs::write( - fixture.job_root().join("tracked.rs"), - "pub fn staged() {}\n", - ) - .unwrap(); - git(&fixture.job_root(), &["add", "tracked.rs"]); - fixture.schedule_paths(noise.clone()); - fixture.wait_idle(); - assert_ne!(fixture.view.current_generation().unwrap(), Some(generation)); - - std::fs::remove_file(fixture.job_root().join("src/member.rs")).unwrap(); - fixture.schedule_paths(BTreeSet::from([b"src".to_vec()])); - fixture.wait_idle(); - let generation = fixture.view.current_generation().unwrap().unwrap(); - assert!(fixture - .view - .load_manifest(&generation) - .unwrap() - .get(&member) - .is_none()); - - std::fs::write( - fixture.job_root().join("tracked.rs"), - "pub fn missed() {}\n", - ) - .unwrap(); - crate::views::assembly::expire_verification_for_test(fixture.view.view_dir()); - fixture.schedule_paths(noise); - fixture.wait_idle(); - assert_ne!(fixture.view.current_generation().unwrap(), Some(generation)); - fixture.assert_cold_parity(); -} - -#[test] -fn publication_precheck_keeps_pending_work_and_payload_repair() { - let fixture = Fixture::new(); - fixture.ctx.isolate_cold_build_limiter_for_test(2); - let noise = fixture.non_member_paths(); - assert!(fixture.ctx.can_skip_irrelevant_view_paths(&noise)); - let mut snapshot = fixture.ctx.view_runtime_snapshot().unwrap(); - snapshot.pending_paths.insert(b"tracked.rs".to_vec()); - fixture.ctx.install_view_runtime(snapshot, None); - assert!(!fixture.ctx.can_skip_irrelevant_view_paths(&noise)); - let mut snapshot = fixture.ctx.view_runtime_snapshot().unwrap(); - snapshot.pending_paths.clear(); - snapshot.pending_inputs.insert( - b"tracked.rs".to_vec(), - crate::views::assembly::PendingInput { - plane: "semantic", - reason: "shared semantic blob unavailable", - blob_key: None, - }, - ); - fixture.ctx.install_view_runtime(snapshot.clone(), None); - assert!(!fixture.ctx.can_skip_irrelevant_view_paths(&noise)); - snapshot.pending_inputs.clear(); - fixture.ctx.install_view_runtime(snapshot.clone(), None); - let mut status = crate::path_status::PathStatusStore::open(fixture.view.view_dir()).unwrap(); - status - .mark_pending(b"gone.rs", "view publication scheduled", 1) - .unwrap(); - assert!(!fixture.ctx.can_skip_irrelevant_view_paths(&noise)); - fixture.schedule_paths(noise.clone()); - fixture.wait_idle(); - assert!(status.status_for(b"gone.rs").unwrap().is_none()); - - let store = crate::blob_store::BlobStore::open( - &snapshot.storage, - snapshot.family, - crate::blob_store::BlobPlane::Callgraph, - ) - .unwrap(); - let path = store.path().to_owned(); - drop(store); - rusqlite::Connection::open(path) - .unwrap() - .execute_batch("DELETE FROM blob_payloads") - .unwrap(); - assert!(!fixture.ctx.can_skip_irrelevant_view_paths(&noise)); - fixture.schedule_paths(noise); - fixture.wait_idle(); - fixture.assert_cold_parity(); -} - -#[test] -fn publication_precheck_requires_idle_cached_and_ready_generation() { - let fixture = Fixture::new(); - fixture.ctx.isolate_cold_build_limiter_for_test(2); - let noise = fixture.non_member_paths(); - let mut prepared = fixture - .ctx - .prepare_view_paths(BTreeSet::new(), true, &mut |_| Ok(())) - .unwrap(); - assert!( - !fixture.ctx.can_skip_irrelevant_view_paths(&noise), - "an active preparation must revoke the shortcut" - ); - fixture.ctx.commit_view_update(&mut prepared).unwrap(); - prepared.settle_status(); - drop(prepared); - assert!(fixture.ctx.can_skip_irrelevant_view_paths(&noise)); - - let generation = fixture.view.current_generation().unwrap().unwrap(); - rusqlite::Connection::open(fixture.view.derived_path(&generation).unwrap()) - .unwrap() - .execute( - "UPDATE meta SET v = 'previous-build-output' WHERE k = 'fingerprint'", - [], - ) - .unwrap(); - assert!(!fixture.ctx.can_skip_irrelevant_view_paths(&noise)); - fixture.schedule_paths(noise.clone()); - fixture.wait_idle(); - assert_ne!(fixture.view.current_generation().unwrap(), Some(generation)); - crate::views::assembly::release_manifest_cache(fixture.view.view_dir()); - assert!( - !fixture.ctx.can_skip_irrelevant_view_paths(&noise), - "an absent success cache cannot prove irrelevance" - ); -} - struct GateEntry { phase: &'static str, /// Matching phase reports to let through before the gate holds one. @@ -810,1628 +302,6 @@ pub(super) fn phase_gate(id: u64, root: &Path, phase: &str) { gate.release.recv().expect("release publication gate"); } } -struct Gate { - arrived: Receiver, - release: Sender<()>, -} -impl Gate { - fn new(root: &Path, phase: &'static str) -> Self { - Self::after(root, phase, 0) - } - /// Holds the publication at the report of `phase` that follows `skip` - /// earlier reports of the same phase on `root`. - fn after(root: &Path, phase: &'static str, skip: usize) -> Self { - let (started, arrived) = crossbeam_channel::bounded(1); - let (release, released) = crossbeam_channel::bounded(1); - GATES.lock().insert( - root.to_owned(), - GateEntry { - phase, - skip, - started, - release: released, - }, - ); - Self { arrived, release } - } - fn started(&self) -> u64 { - self.arrived - .recv_timeout(Duration::from_secs(60)) - .expect("publication reached gate") - } -} -impl Drop for Gate { - fn drop(&mut self) { - let _ = self.release.try_send(()); - } -} - -struct Fixture { - project: tempfile::TempDir, - _storage: tempfile::TempDir, - ctx: Arc, - executor: Executor, - root: ProjectRootId, - view: crate::views::ViewStore, - initial: String, -} - -#[test] -fn views_production_publication_reuses_rust_receiver_source_index() { - use crate::callgraph_store::join::extraction_work::measure; - - let fixture = Fixture::with_files(128); - let mut checkouts = Vec::new(); - let mut measurements = Vec::new(); - for revision in 0..3 { - let checkout = tempfile::tempdir().unwrap(); - git( - fixture.project.path(), - &[ - "-c", - "core.autocrlf=false", - "worktree", - "add", - "--quiet", - "--detach", - checkout.path().to_str().unwrap(), - "HEAD", - ], - ); - let root = std::fs::canonicalize(checkout.path()).unwrap(); - let ctx = fixture.linked_context(&root); - // Linked checkouts do not migrate shared semantic payloads. The context's - // publication path enables their immutable callgraph blob contributions. - let allow_semantic_blob_put = !ctx.shared_artifacts_read_only(); - let runtime = ctx.view_runtime_snapshot().unwrap(); - let blobs = crate::blob_store::BlobStore::open( - &runtime.storage, - &runtime.family, - crate::blob_store::BlobPlane::Callgraph, - ) - .unwrap(); - let rows = blobs.usage().unwrap().rows; - let (_, clean_work) = - measure(|| super::schedule(&ctx, BTreeSet::new(), allow_semantic_blob_put).unwrap()); - assert!(ctx.view_runtime_snapshot().unwrap().generation.is_some()); - assert_eq!(blobs.usage().unwrap().rows, rows); - assert_eq!(clean_work.whole_file_parses, 0); - // Real assembler source exercises receiver analysis in a large Rust - // file; changing only a trailing comment models a small source edit. - std::fs::write( - root.join("file_0.rs"), - format!( - "{}\n// publication revision {revision}\n", - include_str!("../views/assembly.rs") - ), - ) - .unwrap(); - if revision == 1 { - let runtime = ctx.view_runtime_snapshot().unwrap(); - crate::views::assembly::expire_verification_for_test(&runtime.view_dir); - } - let started = Instant::now(); - let before = ctx.view_runtime_snapshot().unwrap().generation; - let (_, work) = measure(|| { - super::schedule_with_reason( - &ctx, - BTreeSet::from([b"file_0.rs".to_vec()]), - allow_semantic_blob_put, - crate::views::publication_reason::Reason { - events: crate::views::publication_reason::Reason::PATHS - | crate::views::publication_reason::Reason::CONTENT, - ..Default::default() - }, - ) - .unwrap() - }); - let inserted = blobs.usage().unwrap().rows - rows; - eprintln!( - "production_receiver revision={revision} inserted={} elapsed_ms={} parses={} receiver_calls={} function_nodes={} constructor_scans={} constructor_nodes={} index_passes={} index_nodes={}", - inserted, - started.elapsed().as_millis(), - work.whole_file_parses, - work.receiver_type_calls, - work.receiver_function_nodes, - work.receiver_constructor_scans, - work.receiver_constructor_nodes, - work.receiver_index_passes, - work.receiver_index_nodes, - ); - assert_ne!(ctx.view_runtime_snapshot().unwrap().generation, before); - assert_eq!(inserted, 1); - measurements.push(work); - if revision == 2 { - std::fs::write( - root.join("file_0.rs"), - format!( - "{}\n// publication revision 0\n", - include_str!("../views/assembly.rs") - ), - ) - .unwrap(); - let (_, reused) = measure(|| { - super::schedule( - &ctx, - BTreeSet::from([b"file_0.rs".to_vec()]), - allow_semantic_blob_put, - ) - .unwrap() - }); - eprintln!( - "production_receiver shared_edit parses={} receiver_calls={} inserted={}", - reused.whole_file_parses, - reused.receiver_type_calls, - blobs.usage().unwrap().rows - rows - inserted - ); - assert_eq!(reused.whole_file_parses, 0); - assert_eq!(reused.receiver_type_calls, 0); - assert_eq!(blobs.usage().unwrap().rows - rows, 1); - } - checkouts.push(checkout); - } - let source = format!( - "{}\n// publication revision 0\n", - include_str!("../views/assembly.rs") - ); - let runtime = fixture.ctx.view_runtime_snapshot().unwrap(); - let blobs = crate::blob_store::BlobStore::open( - &runtime.storage, - &runtime.family, - crate::blob_store::BlobPlane::Callgraph, - ) - .unwrap(); - let key = crate::blob_store::CallgraphKey::from_bytes( - source.as_bytes(), - "rust", - crate::views::callgraph::PRODUCER, - ) - .full_key(); - let reference = crate::callgraph_store::join::dispatch::with_unindexed_receiver_lookup(|| { - crate::callgraph_store::join::CallgraphBlob::extract( - &source, - "rust", - crate::views::callgraph::PRODUCER, - ) - .unwrap() - .to_bytes() - .unwrap() - }); - assert_eq!( - blobs.get(&key).unwrap().unwrap(), - reference, - "production payload bytes must equal the unindexed algorithm" - ); - assert_eq!( - measurements[1].whole_file_parses, 2, - "the verification pass independently reads the new content" - ); - for work in measurements { - assert!((1..=2).contains(&work.whole_file_parses)); - assert!(work.receiver_type_calls > 0); - assert_eq!( - work.receiver_index_passes, work.whole_file_parses, - "one receiver source index per extraction: {work:?}" - ); - assert!(work.receiver_index_nodes > 0); - assert_eq!( - work.receiver_function_nodes, 0, - "receiver function subtrees must be borrowed: {work:?}" - ); - assert_eq!( - work.receiver_constructor_scans, 0, - "constructor declarations must not be rescanned: {work:?}" - ); - assert_eq!(work.receiver_constructor_nodes, 0); - } -} -#[test] -fn views_production_receiver_index_preserves_payload_database_and_query_bytes() { - use crate::callgraph_store::join::{dispatch::with_unindexed_receiver_lookup, CallgraphBlob}; - - const SOURCE: &str = r#" -pub struct Good; -impl Good { pub fn new() -> Self { Self } pub fn method(&self) {} } -pub struct Named; -impl Named { pub fn new() -> Named { Named } pub fn method(&self) {} } -pub struct Wrong; -impl Wrong { pub fn new() -> u32 { 0 } pub fn method(&self) {} } -pub fn good_caller() { let value = Good::new(); value.method(); } -pub fn named_caller() { let value = Named::new(); value.method(); } -pub fn wrong_caller() { let value = Wrong::new(); value.method(); } -"#; - let reference = Fixture::with_files(8); - let indexed = Fixture::with_files(8); - for fixture in [&reference, &indexed] { - std::fs::write(fixture.project.path().join("file_0.rs"), SOURCE).unwrap(); - } - let publish = |fixture: &Fixture| { - fixture - .ctx - .publish_view_paths( - BTreeSet::from([b"file_0.rs".to_vec()]), - !fixture.ctx.shared_artifacts_read_only(), - ) - .unwrap() - }; - let before = with_unindexed_receiver_lookup(|| publish(&reference)); - let after = publish(&indexed); - assert_eq!(before.blob_puts, 1); - assert_eq!(after.blob_puts, 1); - assert_eq!( - before.manifest.as_ref().unwrap().to_json_bytes().unwrap(), - after.manifest.as_ref().unwrap().to_json_bytes().unwrap() - ); - let payload = |fixture: &Fixture| { - let runtime = fixture.ctx.view_runtime_snapshot().unwrap(); - let blobs = crate::blob_store::BlobStore::open( - &runtime.storage, - &runtime.family, - crate::blob_store::BlobPlane::Callgraph, - ) - .unwrap(); - let key = crate::blob_store::CallgraphKey::from_bytes( - SOURCE.as_bytes(), - "rust", - crate::views::callgraph::PRODUCER, - ) - .full_key(); - blobs.get(&key).unwrap().unwrap() - }; - let bytes = payload(&indexed); - assert_eq!(payload(&reference), bytes, "published payload bytes"); - let CallgraphBlob::Parse(parse) = CallgraphBlob::from_bytes(&bytes).unwrap() else { - panic!("Rust parse payload expected"); - }; - let hints = parse - .dispatch - .sites - .iter() - .filter(|site| site.member.as_deref() == Some("method")) - .map(|site| (site.caller.clone().unwrap(), site.receiver.clone())) - .collect::>(); - assert_eq!( - hints, - std::collections::BTreeMap::from([ - ("good_caller".to_owned(), Some("Good".to_owned())), - ("named_caller".to_owned(), Some("Named".to_owned())), - ("wrong_caller".to_owned(), None), - ]) - ); - for fixture in [&reference, &indexed] { - crate::views::wait_for_derived_checkpoint_for_test(fixture.view.view_dir()); - fixture.assert_cold_parity(); - } - crate::views::materialization::parity::assert_logical_parity( - &reference - .view - .derived_path(before.generation.as_deref().unwrap()) - .unwrap(), - &indexed - .view - .derived_path(after.generation.as_deref().unwrap()) - .unwrap(), - ); - // Compare identical query inputs even though the publications used separate - // checkout roots and stores. Both source trees contain the same file bytes. - let query_root = reference.ctx.canonical_cache_root(); - let open = |fixture: &Fixture| { - let runtime = fixture.ctx.view_runtime_snapshot().unwrap(); - crate::callgraph_store::ReadonlyCallGraphStore::open_pinned_derived( - query_root.clone(), - runtime.family, - fixture.view.view_dir().to_path_buf(), - runtime.generation.as_deref().unwrap(), - ) - .unwrap() - }; - let old = open(&reference); - let new = open(&indexed); - for symbol in [ - "Good::method", - "Named::method", - "good_caller", - "wrong_caller", - ] { - let path = Path::new("file_0.rs"); - let callers = new.callers_of(path, symbol, 5).unwrap(); - if symbol == "Good::method" { - assert!(callers - .callers - .iter() - .any(|site| site.caller.symbol == "good_caller")); - } - assert_eq!( - format!("{callers:?}").as_bytes(), - format!("{:?}", old.callers_of(path, symbol, 5).unwrap()).as_bytes(), - "callers bytes for {symbol}" - ); - assert_eq!( - format!("{:?}", new.impact_of(path, symbol, 5).unwrap()).as_bytes(), - format!("{:?}", old.impact_of(path, symbol, 5).unwrap()).as_bytes(), - "impact bytes for {symbol}" - ); - assert_eq!( - serde_json::to_vec(&new.trace_to(path, symbol, 5).unwrap()).unwrap(), - serde_json::to_vec(&old.trace_to(path, symbol, 5).unwrap()).unwrap(), - "trace bytes for {symbol}" - ); - } - eprintln!( - "receiver_parity entries=9 payload_bytes={} query_cases=4 operators=3", - bytes.len() - ); -} - -impl Fixture { - fn new() -> Self { - Self::with_files(0) - } - fn with_files(files: usize) -> Self { - let _ = env_logger::Builder::new() - .is_test(true) - .filter_module("aft::views::generation", log::LevelFilter::Info) - .try_init(); - let project = tempfile::tempdir().unwrap(); - let storage = tempfile::tempdir().unwrap(); - let root_path = std::fs::canonicalize(project.path()).unwrap(); - git(&root_path, &["init", "--quiet"]); - std::fs::write(root_path.join("tracked.rs"), "pub fn previous() {}\n").unwrap(); - for file in 0..files { - std::fs::write( - root_path.join(format!("file_{file}.rs")), - format!("pub fn previous_{file}() {{}}\n"), - ) - .unwrap(); - } - commit(&root_path); - let mut config = Config { - project_root: Some(root_path.clone()), - storage_dir: Some(storage.path().to_owned()), - indexes: crate::config::IndexesConfig { - trigram: false, - semantic: false, - callgraph: true, - }, - ..Config::default() - }; - config.views.enabled = true; - let ctx = Arc::new(AppContext::new(Box::new(TreeSitterProvider::new()), config)); - ctx.set_canonical_cache_root(root_path.clone()); - let scope = crate::path_identity::project_scope_key(&root_path); - let view = crate::views::ViewStore::open(storage.path(), &scope).unwrap(); - ctx.install_view_runtime( - ViewRuntimeSnapshot { - query_pin: None, - storage: storage.path().to_owned(), - family: "publication-fixture".to_owned(), - scope, - view_dir: view.view_dir().to_owned(), - generation: None, - manifest: None, - head_fingerprint: String::new(), - head_metadata: crate::alias::capture_git_head_metadata(&root_path, None).unwrap(), - pending_paths: BTreeSet::new(), - pending_inputs: Default::default(), - }, - None, - ); - let initial = ctx - .publish_view_paths(BTreeSet::new(), true) - .unwrap() - .generation - .unwrap(); - let root = ProjectRootId::from_path(&root_path).unwrap(); - let executor = Executor::with_config(ExecutorConfig { - pool_size: 4, - read_cap: 2, - actor_cap: 3, - heavy_permits: 2, - drr_quantum: 1, - }); - executor.register_actor(root.clone(), Arc::clone(&ctx)); - Self { - project, - _storage: storage, - ctx, - executor, - root, - view, - initial, - } - } - - fn linked_context(&self, root: &Path) -> Arc { - let mut config = Config { - project_root: Some(root.to_path_buf()), - storage_dir: Some(self._storage.path().to_path_buf()), - indexes: crate::config::IndexesConfig { - trigram: false, - semantic: false, - callgraph: true, - }, - ..Config::default() - }; - config.views.enabled = true; - let ctx = Arc::new(AppContext::new(Box::new(TreeSitterProvider::new()), config)); - ctx.set_canonical_cache_root(root.to_path_buf()); - let common = std::fs::canonicalize(self.project.path().join(".git")).unwrap(); - ctx.set_cache_role(true, Some(common.clone())); - ctx.set_cache_writer_capabilities(false, true); - assert!(ctx.shared_artifacts_read_only()); - assert!(ctx.defer_callgraph_build()); - let scope = crate::path_identity::project_scope_key(root); - let view = crate::views::ViewStore::open(self._storage.path(), &scope).unwrap(); - let mut snapshot = self.ctx.view_runtime_snapshot().unwrap(); - snapshot.query_pin = None; - snapshot.scope = scope; - snapshot.view_dir = view.view_dir().to_path_buf(); - snapshot.generation = None; - snapshot.manifest = None; - snapshot.head_fingerprint.clear(); - snapshot.head_metadata = - crate::alias::capture_git_head_metadata(root, Some(&common)).unwrap(); - snapshot.pending_paths.clear(); - snapshot.pending_inputs.clear(); - ctx.install_view_runtime(snapshot, None); - ctx - } - /// The root spelling the publication job records and reports: the context's - /// canonical cache root (a `std::fs::canonicalize` result, verbatim on - /// Windows). `ProjectRootId::as_path()` is a different spelling there, so - /// gates, timing tables and health rows are keyed on this one. - fn job_root(&self) -> PathBuf { - self.ctx - .canonical_cache_root_opt() - .expect("fixture configured a canonical cache root") - } - fn non_member_paths(&self) -> BTreeSet> { - let root = self.job_root(); - std::fs::create_dir_all(root.join("output")).unwrap(); - std::fs::write(root.join("output/untracked.rs"), "pub fn generated() {}\n").unwrap(); - BTreeSet::from([b"output/untracked.rs".to_vec()]) - } - fn change(&self, symbol: &str) { - std::fs::write( - self.project.path().join("tracked.rs"), - format!("pub fn {symbol}() {{}}\n"), - ) - .unwrap(); - commit(self.project.path()); - } - fn schedule(&self) { - self.schedule_paths(BTreeSet::from([b"tracked.rs".to_vec()])); - } - fn schedule_paths(&self, paths: BTreeSet>) { - let response = self - .executor - .submit( - self.root.clone(), - Lane::MaintenanceCommit, - "views.schedule".to_owned(), - Box::new(move |ctx| match schedule(ctx, paths, true) { - Ok(()) => Response::success("views.schedule", serde_json::json!({})), - Err(error) => Response::error("views.schedule", "publication", error), - }), - ) - .recv_timeout(Duration::from_secs(60)) - .unwrap(); - assert!(response.success, "{:?}", response.data); - } - fn wait_idle(&self) { - self.wait_idle_for(Duration::from_secs(10)); - } - fn wait_idle_for(&self, limit: Duration) { - let deadline = Instant::now() + limit; - while running_for_context(&self.ctx) { - assert!( - Instant::now() < deadline, - "publication did not settle: {}", - health_snapshot() - ); - std::thread::sleep(Duration::from_millis(5)); - } - } - - fn assert_cold_parity(&self) { - let runtime = self.ctx.view_runtime_snapshot().unwrap(); - let generation = runtime.generation.unwrap(); - let manifest = runtime.manifest.unwrap(); - let cold_dir = tempfile::tempdir().unwrap(); - let cold = cold_dir.path().join("cold.sqlite"); - let blobs = crate::blob_store::BlobStore::open( - &runtime.storage, - &runtime.family, - crate::blob_store::BlobPlane::Callgraph, - ) - .unwrap(); - crate::views::materialization::materialize_manifest_view_database( - &cold, - blobs.path(), - &manifest, - ) - .unwrap(); - crate::views::materialization::parity::assert_logical_parity( - &self.view.derived_path(&generation).unwrap(), - &cold, - ); - self.ctx.publish_view_paths(BTreeSet::new(), true).unwrap(); - let rebuilt = self.ctx.view_runtime_snapshot().unwrap().manifest.unwrap(); - assert_eq!( - manifest.to_json_bytes().unwrap(), - rebuilt.to_json_bytes().unwrap(), - "the incremental manifest must equal a full read of the final tree" - ); - } -} - -#[test] -fn publication_burst_is_single_flight_and_converges() { - let fixture = Fixture::with_files(20); - let completions = Completions::new(fixture.job_root()); - fixture.change("first"); - let gate = Gate::new(&fixture.job_root(), "derived"); - fixture.schedule(); - gate.started(); - for edit in 0..20 { - let path = format!("file_{edit}.rs"); - std::fs::write( - fixture.project.path().join(&path), - format!("pub fn edit_{edit}() {{}}\n"), - ) - .unwrap(); - fixture.schedule_paths(BTreeSet::from([path.into_bytes()])); - } - let in_flight = health_snapshot() - .as_array() - .unwrap() - .iter() - .filter(|row| row["root"] == fixture.job_root().to_string_lossy().as_ref()) - .count(); - drop(gate); - fixture.wait_idle_for(Duration::from_secs(60)); - let event = completions.next_event(); - assert_eq!(event.root, fixture.job_root()); - assert_eq!(event.outcome, CompletionOutcome::Published); - assert_eq!(CAS_TIMINGS.lock()[&fixture.job_root()].len(), 2); - assert_eq!(in_flight, 1, "edits must not start competing publications"); - assert_eq!( - crate::views::assembly::publication_outcomes_for_test(&fixture.job_root()), - vec!["published", "published", "published"] - ); - fixture.assert_cold_parity(); -} - -#[test] -fn publication_failed_batch_is_included_in_follow_up() { - let fixture = Fixture::with_files(1); - let completions = Completions::new(fixture.job_root()); - fixture.change("failed_batch"); - let failures = Failures::new(fixture.job_root()); - let gate = Gate::new(&fixture.job_root(), "working_tree_read"); - fixture.schedule(); - gate.started(); - let tracked = fixture.project.path().join("tracked.rs"); - std::fs::remove_file(&tracked).unwrap(); - std::fs::create_dir(&tracked).unwrap(); - std::fs::write( - fixture.project.path().join("file_0.rs"), - "pub fn pending() {}\n", - ) - .unwrap(); - fixture.schedule_paths(BTreeSet::from([b"file_0.rs".to_vec()])); - let retry_gate = Gate::new(&fixture.job_root(), "retry_wait"); - drop(gate); - failures.next(); - retry_gate.started(); - std::fs::remove_dir(&tracked).unwrap(); - std::fs::write(&tracked, "pub fn failed_batch() {}\n").unwrap(); - drop(retry_gate); - fixture.wait_idle_for(Duration::from_secs(60)); - let event = completions.next_event(); - assert_eq!(event.root, fixture.job_root()); - assert_eq!(event.outcome, CompletionOutcome::Published); - assert_eq!(CAS_TIMINGS.lock()[&fixture.job_root()].len(), 1); - assert_eq!( - crate::views::assembly::publication_outcomes_for_test(&fixture.job_root()), - vec!["published", "cancelled_or_failed", "published"] - ); - fixture.assert_cold_parity(); -} - -#[test] -fn publication_rescan_only_promotes_its_first_follow_up_to_full_read() { - let fixture = Fixture::with_files(2); - fixture.change("rescan"); - let gate = Gate::new(&fixture.job_root(), "derived"); - fixture.schedule_paths(BTreeSet::new()); - gate.started(); - std::fs::write( - fixture.project.path().join("file_0.rs"), - "pub fn first() {}\n", - ) - .unwrap(); - fixture.schedule_paths(BTreeSet::from([b"file_0.rs".to_vec()])); - let follow_up = Gate::new(&fixture.job_root(), "derived"); - drop(gate); - follow_up.started(); - std::fs::write( - fixture.project.path().join("file_1.rs"), - "pub fn second() {}\n", - ) - .unwrap(); - fixture.schedule_paths(BTreeSet::from([b"file_1.rs".to_vec()])); - let pending = { - let jobs = JOBS.lock(); - let job = jobs - .values() - .find(|job| job.root == fixture.job_root()) - .unwrap(); - assert!( - job.paths.is_empty(), - "the first follow-up must read everything" - ); - job.pending.as_ref().unwrap().paths.clone() - }; - drop(follow_up); - fixture.wait_idle_for(Duration::from_secs(60)); - assert_eq!(pending, BTreeSet::from([b"file_1.rs".to_vec()])); - assert_eq!(CAS_TIMINGS.lock()[&fixture.job_root()].len(), 3); - fixture.assert_cold_parity(); -} - -#[test] -fn publication_unbind_abandons_in_flight_and_pending_work() { - let fixture = Fixture::new(); - let completions = Completions::new(fixture.job_root()); - fixture - .ctx - .set_unbound_build_abandon_grace_for_test(Duration::ZERO); - fixture.change("unbound"); - let gate = Gate::new(&fixture.job_root(), "derived"); - fixture.schedule(); - gate.started(); - fixture.schedule(); - fixture.ctx.mark_subc_unbound(); - drop(gate); - fixture.wait_idle_for(Duration::from_secs(60)); - let event = completions.next_event(); - assert_eq!(event.root, fixture.job_root()); - assert!( - matches!(event.outcome, CompletionOutcome::Cancelled { reason } if !reason.is_empty()), - "an abandoned publication must report cancellation with its reason" - ); - assert_eq!( - fixture.view.current_generation().unwrap().as_deref(), - Some(fixture.initial.as_str()) - ); - assert!(!CAS_TIMINGS.lock().contains_key(&fixture.job_root())); -} - -#[test] -fn publication_follow_up_converges_to_final_tree() { - let fixture = Fixture::with_files(2); - fixture.change("first"); - let gate = Gate::new(&fixture.job_root(), "cas"); - fixture.schedule(); - gate.started(); - for file in 0..2 { - let path = format!("file_{file}.rs"); - std::fs::write( - fixture.project.path().join(&path), - format!("pub fn final_{file}() {{}}\n"), - ) - .unwrap(); - fixture.schedule_paths(BTreeSet::from([path.into_bytes()])); - } - drop(gate); - fixture.wait_idle_for(Duration::from_secs(60)); - fixture.assert_cold_parity(); -} - -#[test] -fn publication_conflict_follow_up_uses_winning_base() { - let fixture = Fixture::with_files(1); - fixture.change("older"); - let gate = Gate::new(&fixture.job_root(), "cas"); - fixture.schedule(); - gate.started(); - std::fs::write( - fixture.project.path().join("tracked.rs"), - "pub fn winner() {}\n", - ) - .unwrap(); - let winner = fixture - .ctx - .publish_view_paths(BTreeSet::new(), true) - .unwrap() - .generation - .unwrap(); - std::fs::write( - fixture.project.path().join("file_0.rs"), - "pub fn pending() {}\n", - ) - .unwrap(); - fixture.schedule_paths(BTreeSet::from([b"file_0.rs".to_vec()])); - drop(gate); - fixture.wait_idle_for(Duration::from_secs(60)); - assert_ne!( - fixture.view.current_generation().unwrap().as_deref(), - Some(winner.as_str()) - ); - assert_eq!( - CAS_TIMINGS.lock()[&fixture.job_root()].len(), - 1, - "the stale attempt must not commit" - ); - fixture.assert_cold_parity(); -} - -#[test] -fn publication_configure_invalidates_old_work_without_losing_pending_paths() { - let fixture = Fixture::with_files(2); - fixture.change("older"); - let gate = Gate::new(&fixture.job_root(), "cas"); - fixture.schedule(); - gate.started(); - std::fs::write( - fixture.project.path().join("file_0.rs"), - "pub fn pending_before() {}\n", - ) - .unwrap(); - fixture.schedule_paths(BTreeSet::from([b"file_0.rs".to_vec()])); - cancel_for_context(&fixture.ctx); - fixture - .ctx - .configure_content_generation_flag() - .fetch_add(1, std::sync::atomic::Ordering::AcqRel); - std::fs::write( - fixture.project.path().join("file_1.rs"), - "pub fn pending_after() {}\n", - ) - .unwrap(); - fixture.schedule_paths(BTreeSet::from([b"file_1.rs".to_vec()])); - drop(gate); - fixture.wait_idle_for(Duration::from_secs(60)); - assert_eq!( - CAS_TIMINGS.lock()[&fixture.job_root()].len(), - 1, - "the old configuration must not commit" - ); - fixture.assert_cold_parity(); -} - -/// Run alone because the assembly delay environment applies to every root in -/// the process. The release file holds all attempts while the edit burst lands. -#[test] -#[ignore = "5k-file before/after measurement; run with --ignored --test-threads=1"] -fn publication_burst_5k_measurement() { - let fixture = Fixture::with_files(5000); - fixture.change("first"); - let release = fixture._storage.path().join("publication-release"); - std::env::set_var("AFT_TEST_VIEW_PUBLICATION_GATE_FILE", &release); - std::env::set_var("AFT_TEST_VIEW_PUBLICATION_DELAY_MS", "25"); - let gate = Gate::new(&fixture.job_root(), "manifest"); - let started = Instant::now(); - fixture.schedule(); - gate.started(); - drop(gate); - for edit in 0..20 { - let path = format!("file_{edit}.rs"); - std::fs::write( - fixture.project.path().join(&path), - format!("pub fn final_{edit}() {{}}\n"), - ) - .unwrap(); - fixture.schedule_paths(BTreeSet::from([path.into_bytes()])); - } - std::fs::write(&release, "release").unwrap(); - fixture.wait_idle_for(Duration::from_secs(300)); - std::env::remove_var("AFT_TEST_VIEW_PUBLICATION_GATE_FILE"); - std::env::remove_var("AFT_TEST_VIEW_PUBLICATION_DELAY_MS"); - let outcomes = crate::views::assembly::publication_outcomes_for_test(&fixture.job_root()); - eprintln!("BURST_5K edits=20 files=5000 attempts={} committed={} published={} cancelled_or_failed={} total_ms={} outcomes={:?}", - outcomes.len() - 1, - CAS_TIMINGS.lock().get(&fixture.job_root()).map_or(0, Vec::len), - outcomes[1..].iter().filter(|outcome| **outcome == "published").count(), - outcomes[1..].iter().filter(|outcome| **outcome == "cancelled_or_failed").count(), - started.elapsed().as_millis(), &outcomes[1..]); - fixture.assert_cold_parity(); -} -fn git(root: &Path, args: &[&str]) { - let mut command = std::process::Command::new("git"); - crate::test_env::apply_hermetic_git_env(command.current_dir(root)); - assert!(command.args(args).output().unwrap().status.success()); -} -fn commit(root: &Path) { - git(root, &["add", "."]); - git( - root, - &[ - "-c", - "user.name=AFT", - "-c", - "user.email=aft@example.com", - "commit", - "--quiet", - "-m", - "fixture", - ], - ); -} - -#[test] -fn inspect_view_pending_publication_without_deferred_job_requests_no_refresh() { - let fixture = Fixture::new(); - fixture.ctx.set_harness(crate::harness::Harness::Opencode); - fixture.ctx.reset_tier2_refresh_scheduler_at(Instant::now()); - fixture.change("no_inspection_requested"); - let before = fixture - .ctx - .inspect_manager() - .automatic_tier2_schedule_count_for_test(); - fixture.schedule(); - fixture.wait_idle(); - assert!( - !fixture.ctx.tier2_pull_demand_pending(), - "publication without deferred analysis must not request a Tier-2 pull" - ); - assert_eq!( - fixture - .ctx - .inspect_manager() - .automatic_tier2_schedule_count_for_test(), - before - ); -} - -#[test] -fn inspect_view_pending_publication_reschedules_dead_code_without_another_inspect() { - 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); - fixture.ctx.reset_tier2_refresh_scheduler_at(Instant::now()); - fixture.change("after_publication"); - let manager = fixture.ctx.inspect_manager(); - 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 pending = manager.tier2_run_with_reuse_blocking_fresh( - snapshot.clone(), - InspectCategory::DeadCode, - JobScope::for_project(root.clone()), - ); - assert_eq!( - pending.payload().map(|payload| &payload["code"]), - Some(&serde_json::json!("view_pending")), - "{pending:?}" - ); - assert_ne!( - pending - .payload() - .and_then(|payload| payload["callgraph_available"].as_bool()), - Some(true) - ); - assert!(!fixture.ctx.tier2_pull_demand_pending()); - - fixture.schedule(); - fixture.wait_idle_for(Duration::from_secs(30)); - assert!( - fixture.ctx.tier2_pull_demand_pending(), - "view publication must wake deferred Tier-2 work" - ); - // A publication callback can run before the job records its pending result. - // Recreate that scheduler state and let only the idle probe discover demand. - fixture.ctx.reset_tier2_refresh_scheduler_at(Instant::now()); - assert!(!fixture.ctx.tier2_pull_demand_pending()); - manager.set_tier2_in_flight_for_test(InspectCategory::DeadCode, true); - assert!(!fixture.ctx.tier2_refresh_dispatch_due()); - assert_eq!( - fixture - .ctx - .tick_tier2_refresh_scheduler_at(Instant::now(), 0), - None - ); - assert!(manager.deferred_view_refresh_ready(&fixture.ctx.canonical_cache_root())); - manager.set_tier2_in_flight_for_test(InspectCategory::DeadCode, false); - assert!( - fixture.ctx.tier2_refresh_dispatch_due(), - "a quiet checkout must discover its owed published view without a watcher edit" - ); - assert_eq!( - fixture - .ctx - .tick_tier2_refresh_scheduler_at(Instant::now(), 0), - Some(crate::inspect::Tier2TriggerReason::Pull) - ); - let deadline = Instant::now() + Duration::from_secs(30); - while manager.tier2_any_in_flight() { - assert!( - Instant::now() < deadline, - "automatic Tier-2 refresh did not finish" - ); - std::thread::sleep(Duration::from_millis(10)); - } - manager.drain_completions(); - let outcome = manager.tier2_read_cached_readonly( - snapshot, - InspectCategory::DeadCode, - JobScope::for_project(root), - ); - assert_eq!( - outcome - .payload() - .map(|payload| &payload["callgraph_available"]), - Some(&serde_json::json!(true)), - "{outcome:?}" - ); - assert!(!manager.deferred_view_refresh_ready(&fixture.ctx.canonical_cache_root())); - fixture.change("no_more_deferred_analysis"); - fixture.schedule(); - fixture.wait_idle_for(Duration::from_secs(30)); - assert!( - !fixture.ctx.tier2_pull_demand_pending(), - "a later publication must not repeat the consumed refresh" - ); -} - -#[test] -fn publication_busy_readiness_uses_backoff_and_recovers_after_unlock() { - let fixture = Fixture::new(); - let derived = fixture.view.derived_path(&fixture.initial).unwrap(); - crate::views::wait_for_derived_checkpoint_for_test(fixture.view.view_dir()); - let writer = crate::db::file_identity::IdentityConnection::open( - &derived, - "publication_busy_readiness_test", - ) - .unwrap(); - writer - .execute_batch("PRAGMA journal_mode=DELETE; BEGIN EXCLUSIVE;") - .unwrap(); - fixture.change("after_unlock"); - let failures = Failures::new(fixture.job_root()); - let completions = Completions::new(fixture.job_root()); - fixture.schedule(); - let (_, delay, count, _) = failures.next(); - assert_eq!(delay, Duration::from_secs(1)); - assert_eq!(count, 1); - assert_eq!( - fixture.view.current_generation().unwrap().as_deref(), - Some(fixture.initial.as_str()) - ); - assert_eq!( - crate::views::assembly::cold_build_counts(fixture.view.view_dir()), - Default::default() - ); - assert!(fixture - .ctx - .view_publication_retry() - .lock() - .error - .as_deref() - .unwrap() - .contains("database is locked")); - assert!(!fixture - .ctx - .view_publication_retry() - .lock() - .error - .as_deref() - .unwrap() - .contains("invalid view manifest")); - writer.execute_batch("ROLLBACK").unwrap(); - drop(writer); - - completions.next(); - fixture.wait_idle(); - assert_ne!( - fixture.view.current_generation().unwrap().as_deref(), - Some(fixture.initial.as_str()) - ); - assert_eq!( - crate::views::assembly::cold_build_counts(fixture.view.view_dir()), - Default::default() - ); - assert!(fixture.ctx.view_publication_retry().lock().due().is_none()); - match fixture.ctx.callgraph_store_for_ops() { - CallgraphStoreAccess::Ready(store) => { - assert!(crate::callgraph_store::CallGraphRead::node_for( - &store, - Path::new("tracked.rs"), - "after_unlock" - ) - .is_ok()) - } - _ => panic!("publication did not recover after the writer released its lock"), - } -} - -#[test] -fn publication_omits_staged_and_unstaged_working_tree_deletions() { - use crate::views::{ - contracts::{PlaneAdapter, PlaneLoader, ViewAccess}, - first_load::{ - CallgraphBridge, CheckoutDriver, ConfiguredMembershipWalker, SiblingLoader, - TrigramBridge, - }, - manifest_v2::Producers, - registry::FamilyRegistry, - }; - let mut failures = Vec::new(); - for staged in [false, true] { - let fixture = Fixture::new(); - std::fs::write(fixture.project.path().join("kept.rs"), "pub fn kept() {}\n").unwrap(); - commit(fixture.project.path()); - let root = fixture.job_root(); - let registry = - FamilyRegistry::open(fixture._storage.path(), "publication-fixture").unwrap(); - let owner = registry - .register_view_with_storage(registry.storage(), "query", &root) - .unwrap(); - let policy = crate::blob_store::v2::TrigramPolicy { - max_file_size: 1 << 20, - }; - let trigram = Arc::new(TrigramBridge::new( - fixture._storage.path().to_owned(), - policy, - )); - let callgraph = Arc::new(CallgraphBridge::default()); - let adapters: Vec> = - vec![trigram.adapter.clone(), callgraph.adapter.clone()]; - let driver = Arc::new( - CheckoutDriver::new( - owner.clone(), - Producers { - trigram: policy.fingerprint_hex(), - semantic: None, - callgraph: crate::views::callgraph::PRODUCER.into(), - }, - None, - Arc::new(ConfiguredMembershipWalker), - vec![trigram.clone(), callgraph.clone()], - ) - .with_adapters(adapters.clone()), - ); - let loader = SiblingLoader::new(driver.clone(), adapters); - let access = ViewAccess::Owner(owner); - let before = loader.load(&access).unwrap(); - let index = trigram - .adapter - .resident("query", before.snapshot.generation().name()) - .unwrap(); - assert_eq!( - index - .query(&root, &before.snapshot, "previous") - .matches - .len(), - 1 - ); - assert_eq!( - callgraph - .adapter - .reader(&access, &before.snapshot) - .unwrap() - .store - .indexed_file_count() - .unwrap(), - 2 - ); - std::fs::remove_file(fixture.project.path().join("tracked.rs")).unwrap(); - if staged { - git(fixture.project.path(), &["add", "-u"]); - } - let result = fixture.ctx.publish_view_paths(BTreeSet::new(), true); - let report = match result { - Ok(report) => report, - Err(error) => { - failures.push(format!("staged={staged}: {error}")); - continue; - } - }; - assert!(report.published); - assert!(report - .manifest - .unwrap() - .get(&crate::views::RelPath::new(b"tracked.rs".to_vec()).unwrap()) - .is_none()); - let CallgraphStoreAccess::Ready(store) = fixture.ctx.callgraph_store_for_ops() else { - panic!("deleted-file view reader unavailable") - }; - assert_eq!( - crate::callgraph_store::CallGraphRead::indexed_file_count(&store).unwrap(), - 1 - ); - driver.record_absolute_change(&root.join("tracked.rs")); - let after = loader.load(&access).unwrap(); - assert!(after.pending_planes.is_empty()); - let index = trigram - .adapter - .resident("query", after.snapshot.generation().name()) - .unwrap(); - let deleted = index.query(&root, &after.snapshot, "previous"); - assert!(deleted.matches.is_empty()); - assert!(deleted.gaps.is_empty()); - assert_eq!(index.query(&root, &after.snapshot, "kept").matches.len(), 1); - let reader = callgraph.adapter.reader(&access, &after.snapshot).unwrap(); - assert_eq!(reader.store.indexed_file_count().unwrap(), 1); - assert!(reader - .store - .node_for(Path::new("tracked.rs"), "previous") - .is_err()); - assert!(reader.store.node_for(Path::new("kept.rs"), "kept").is_ok()); - } - assert!(failures.is_empty(), "{}", failures.join("\n")); -} - -/// An upgrade that bumps the call-graph producer finds the checkout's view -/// already published under the previous producer. The owner must rebuild and -/// serve a new generation rather than keep serving, or refusing, the old one. -#[test] -fn callgraph_producer_change_republishes_an_existing_generation() { - use crate::blob_store::v2::FamilyPlane; - use crate::views::{ - callgraph::{BlobReader, PRODUCER}, - contracts::{PlaneAdapter, PlaneLoader, ViewAccess}, - first_load::{ - CallgraphBridge, CheckoutDriver, ConfiguredMembershipWalker, SiblingLoader, - TrigramBridge, - }, - manifest_v2::{GenerationName, Producers}, - registry::FamilyRegistry, - }; - // The producer the previous release stamped on its generations. - const PREVIOUS_PRODUCER: &str = "ruled-callgraph-v9-without-ast-index"; - assert_ne!(PREVIOUS_PRODUCER, PRODUCER); - let fixture = Fixture::new(); - let root = fixture.job_root(); - let registry = FamilyRegistry::open(fixture._storage.path(), "publication-fixture").unwrap(); - let owner = registry - .register_view_with_storage(fixture._storage.path(), "query", &root) - .unwrap(); - let access = ViewAccess::Owner(owner.clone()); - let policy = crate::blob_store::v2::TrigramPolicy { - max_file_size: 1 << 20, - }; - let load = || { - let trigram = Arc::new(TrigramBridge::new( - fixture._storage.path().to_owned(), - policy, - )); - let callgraph = Arc::new(CallgraphBridge::default()); - let adapters: Vec> = - vec![trigram.adapter.clone(), callgraph.adapter.clone()]; - let driver = Arc::new( - CheckoutDriver::new( - owner.clone(), - Producers { - trigram: policy.fingerprint_hex(), - semantic: None, - callgraph: PRODUCER.into(), - }, - None, - Arc::new(ConfiguredMembershipWalker), - vec![trigram.clone(), callgraph.clone()], - ) - .with_adapters(adapters.clone()), - ); - let loaded = SiblingLoader::new(driver, adapters).load(&access).unwrap(); - (callgraph, loaded) - }; - - // Build the checkout's generation, then publish the same files stamped - // with the previous producer, as the previous release left it on disk. - let (_, built) = load(); - let views = owner.view_store().unwrap(); - let built_name = built.snapshot.generation().name().to_string(); - let mut previous = built.snapshot.generation().flatten().unwrap(); - previous.header_mut().producers.callgraph = PREVIOUS_PRODUCER.into(); - let previous_name = GenerationName::for_manifest(&previous).unwrap(); - let store = owner.open_store(FamilyPlane::Callgraph).unwrap(); - // The current build refuses to materialize under another producer, so the - // previous generation reuses the database built for the same files. - crate::views::clone_derived_for_test( - &views.derived_path(&built_name).unwrap(), - &views.derived_path(&previous_name.to_string()).unwrap(), - ) - .unwrap(); - let prepared = views - .prepare_v2(&previous_name, Some(&built_name), &previous, None) - .unwrap(); - views.commit_v2(prepared, None).unwrap(); - let previous_name = previous_name.to_string(); - assert_eq!( - views.current_generation().unwrap().as_deref(), - Some(previous_name.as_str()) - ); - - let (callgraph, current) = load(); - assert!( - current.pending_planes.is_empty(), - "{:?}", - current.pending_planes - ); - let generation = current.snapshot.generation(); - assert_ne!(generation.name(), previous_name); - assert_eq!(generation.header().producers.callgraph, PRODUCER); - assert_eq!( - views.current_generation().unwrap().as_deref(), - Some(generation.name()) - ); - let reader = callgraph - .adapter - .reader(&access, ¤t.snapshot) - .unwrap(); - assert!(reader - .store - .node_for(Path::new("tracked.rs"), "previous") - .is_ok()); - - // The served database equals a cold build of the new manifest. - let cold_dir = tempfile::tempdir().unwrap(); - let cold = cold_dir.path().join("cold.sqlite"); - crate::views::callgraph::materialize(&cold, generation, &BlobReader(store.reader())).unwrap(); - crate::views::materialization::parity::assert_logical_parity( - &views.derived_path(generation.name()).unwrap(), - &cold, - ); -} - -#[test] -fn publication_omits_file_deleted_after_head_listing() { - let fixture = Fixture::new(); - let mut deleted = false; - let mut prepared = fixture - .ctx - .prepare_view_paths(BTreeSet::new(), true, &mut |phase| { - if phase == "working_tree_read" && !deleted { - std::fs::remove_file(fixture.project.path().join("tracked.rs")).unwrap(); - deleted = true; - } - Ok(()) - }) - .unwrap(); - assert!( - deleted, - "the deletion must happen after listing, before reading" - ); - let report = fixture.ctx.commit_view_update(&mut prepared).unwrap(); - assert!(report.published); - assert!(report.manifest.unwrap().entries().next().is_none()); -} - -#[test] -fn publication_keeps_reused_semantic_rows_pinned_during_handoff() { - let fixture = Fixture::new(); - let root = std::fs::canonicalize(fixture.project.path()).unwrap(); - let source = b"pub fn next_semantic() {}\n"; - let path = root.join("tracked.rs"); - std::fs::write(&path, source).unwrap(); - let mut index = crate::semantic_index::SemanticIndex::build( - &root, - &[path], - &mut |texts: Vec| Ok(texts.iter().map(|_| vec![1.0, 0.0]).collect()), - 64, - ) - .unwrap(); - let fingerprint = crate::semantic_index::SemanticIndexFingerprint { - dimension: 2, - chunking_version: 2, - ..Default::default() - }; - index.set_fingerprint(fingerprint.clone()); - let snapshot = fixture.ctx.view_runtime_snapshot().unwrap(); - let mut request = crate::migration::SemanticMigrationRequest::for_root( - &snapshot.storage, - &root, - fingerprint.as_string(), - ); - request.family.clone_from(&snapshot.family); - request.view.clone_from(&snapshot.scope); - let key = crate::blob_store::SemanticKey::from_bytes( - source, - b"tracked.rs", - &request.chunker_version, - &request.embed_template_version, - &request.configured_model_fingerprint, - ) - .full_key(); - drop(crate::migration::store_live_semantic_blobs(&request, &index).unwrap()); - *fixture.ctx.semantic_index().write().unwrap() = Some(index); - let mut config = (*fixture.ctx.config()).clone(); - config.indexes.semantic = true; - fixture.ctx.set_config(config); - let collect = || { - crate::gc::sweep_semantic_handoff_for_test(crate::gc::SweepRequest { - durable_storage: &snapshot.storage, - storage: &snapshot.storage, - family: &snapshot.family, - view_dir: &snapshot.view_dir, - byte_budget: 0, - now_ms: crate::pins::now_ms().saturating_add(crate::gc::BLOB_AGE_FLOOR_MS + 1), - references: crate::gc::SweepReferences::default(), - }) - .unwrap() - }; - let assert_semantic_present = || { - let store = crate::blob_store::BlobStore::open( - &snapshot.storage, - &snapshot.family, - crate::blob_store::BlobPlane::Semantic, - ) - .unwrap(); - assert!( - store.get(&key).unwrap().is_some(), - "publication dropped the semantic pin during handoff" - ); - }; - let mut swept_handoff = false; - let mut prepared = fixture - .ctx - .prepare_view_paths( - BTreeSet::from([b"tracked.rs".to_vec()]), - true, - &mut |phase| { - if phase == "working_tree_read" && !swept_handoff { - swept_handoff = true; - collect(); - assert_semantic_present(); - } - Ok(()) - }, - ) - .unwrap(); - assert!(swept_handoff, "handoff sweep callback did not run"); - collect(); - { - let store = crate::blob_store::BlobStore::open( - &snapshot.storage, - &snapshot.family, - crate::blob_store::BlobPlane::Semantic, - ) - .unwrap(); - assert!( - store.get(&key).unwrap().is_some(), - "prepared publication dropped the semantic pin before commit" - ); - } - let report = fixture.ctx.commit_view_update(&mut prepared).unwrap(); - assert!(report.generation.is_some()); -} - -#[test] -fn publication_retires_root_deleted_during_source_read() { - let fixture = Fixture::new(); - let result = fixture - .ctx - .prepare_view_paths(BTreeSet::new(), true, &mut |phase| { - if phase == "working_tree_read" { - std::fs::remove_dir_all(fixture.project.path()).unwrap(); - } - Ok(()) - }); - assert!(matches!(result, Err(error) if error == "view publication root was deleted")); - assert!(fixture.ctx.view_runtime_snapshot().is_none()); - assert_eq!( - fixture.view.current_generation().unwrap().unwrap(), - fixture.initial - ); -} - -#[test] -fn publication_does_not_reuse_a_deleted_unchanged_member() { - let fixture = Fixture::new(); - std::fs::remove_file(fixture.project.path().join("tracked.rs")).unwrap(); - let report = fixture - .ctx - .publish_view_paths(BTreeSet::from([b"other.rs".to_vec()]), true) - .unwrap(); - assert!(report.published); - assert!(report.manifest.unwrap().entries().next().is_none()); -} - -#[test] -fn publication_rescan_then_event_still_reads_the_whole_checkout() { - use crate::watcher_filter::{RescanReason, WatcherDispatchEvent}; - let fixture = Fixture::new(); - std::fs::write(fixture.job_root().join("later.rs"), "pub fn before() {}\n").unwrap(); - commit(&fixture.job_root()); - let generation = fixture - .ctx - .publish_view_paths(BTreeSet::new(), true) - .unwrap() - .generation - .unwrap(); - let before = fixture.view.load_manifest(&generation).unwrap(); - std::fs::write( - fixture.job_root().join("tracked.rs"), - "pub fn missed_by_watcher() {}\n", - ) - .unwrap(); - std::fs::write( - fixture.job_root().join("later.rs"), - "pub fn reported() {}\n", - ) - .unwrap(); - let (tx, rx) = crossbeam_channel::unbounded(); - *fixture.ctx.watcher_rx().lock() = Some(rx); - tx.send(WatcherDispatchEvent::RescanRequired( - RescanReason::KernelDropped, - )) - .unwrap(); - // Complete the rescan before the later event arrives in the quiet window. - crate::runtime_drain::drain_watcher_to_completion_for_test(&fixture.ctx, usize::MAX); - assert!(fixture - .ctx - .watcher_drain_slice() - .lock() - .as_ref() - .unwrap() - .view_publication_due - .is_some()); - tx.send(WatcherDispatchEvent::Paths(vec![fixture - .job_root() - .join("later.rs")])) - .unwrap(); - crate::runtime_drain::drain_watcher_to_completion_for_test(&fixture.ctx, usize::MAX); - let mut state = fixture.ctx.watcher_drain_slice().lock().take().unwrap(); - state.view_publication_due = Some(Instant::now()); - crate::runtime_drain::publish_view_if_quiet(&fixture.ctx, &mut state); - let generation = fixture.view.current_generation().unwrap().unwrap(); - let after = fixture.view.load_manifest(&generation).unwrap(); - let missed = crate::views::RelPath::new(b"tracked.rs".to_vec()).unwrap(); - assert_ne!( - before.get(&missed), - after.get(&missed), - "the scheduled publication must read the edit omitted by the dropped events" - ); - assert!(state.view_publication_due.is_none()); -} - -#[test] -fn publication_unrepresentable_watcher_path_preserves_full_read() { - for root_event in [false, true] { - let fixture = Fixture::new(); - std::fs::write(fixture.job_root().join("other.rs"), "pub fn before() {}\n").unwrap(); - commit(&fixture.job_root()); - fixture - .ctx - .publish_view_paths(BTreeSet::new(), true) - .unwrap(); - let generation = fixture.view.current_generation().unwrap().unwrap(); - let before = fixture.view.load_manifest(&generation).unwrap(); - std::fs::write( - fixture.job_root().join("tracked.rs"), - "pub fn missed_spelling() {}\n", - ) - .unwrap(); - std::fs::write( - fixture.job_root().join("other.rs"), - "pub fn reported() {}\n", - ) - .unwrap(); - let mut state = crate::context::WatcherDrainSliceState::new( - fixture.ctx.configure_generation(), - fixture.ctx.configure_content_generation(), - ); - state - .view_publication_paths - .insert(fixture.job_root().join("other.rs")); - state.view_publication_paths.insert(if root_event { - fixture.job_root() - } else { - fixture - .job_root() - .with_file_name("different-root-spelling") - .join("tracked.rs") - }); - state.view_publication_due = Some(Instant::now()); - crate::runtime_drain::publish_view_if_quiet(&fixture.ctx, &mut state); - let generation = fixture.view.current_generation().unwrap().unwrap(); - let after = fixture.view.load_manifest(&generation).unwrap(); - let path = crate::views::RelPath::new(b"tracked.rs".to_vec()).unwrap(); - assert_ne!( - before.get(&path), - after.get(&path), - "unrepresentable event was silently discarded: root_event={root_event}" - ); - } -} - -#[test] -fn publication_idle_full_verification_runs_without_a_watcher_event() { - for with_receiver in [false, true] { - let fixture = Fixture::new(); - let _sender = if with_receiver { - let (tx, rx) = crossbeam_channel::unbounded(); - *fixture.ctx.watcher_rx().lock() = Some(rx); - Some(tx) - } else { - None - }; - let before = fixture.view.load_manifest(&fixture.initial).unwrap(); - std::fs::write( - fixture.job_root().join("tracked.rs"), - "pub fn missed_during_silence() {}\n", - ) - .unwrap(); - crate::views::assembly::expire_verification_for_test(fixture.view.view_dir()); - assert!( - fixture.ctx.watcher_drain_has_work(), - "the maintenance pump must wake an idle root at its verification deadline" - ); - crate::runtime_drain::drain_watcher_to_completion_for_test(&fixture.ctx, usize::MAX); - let generation = fixture.view.current_generation().unwrap().unwrap(); - let after = fixture.view.load_manifest(&generation).unwrap(); - assert_ne!( - before, after, - "no new watcher event is needed to discover the missed edit: with_receiver={with_receiver}" - ); - assert!(!fixture.ctx.view_full_verification_due()); - assert!(fixture - .ctx - .watcher_drain_slice() - .lock() - .as_ref() - .unwrap() - .view_publication_due - .is_none()); - } -} - -#[test] -fn publication_cache_is_released_on_quiesce_eviction_and_clear() { - for action in ["quiesce", "evict", "try_evict", "clear", "replace"] { - let fixture = Fixture::new(); - let view_dir = fixture.view.view_dir(); - assert!(crate::views::assembly::cache_contains_for_test(view_dir)); - match action { - "quiesce" => fixture.ctx.mark_subc_unbound(), - "evict" => assert!(fixture.ctx.evict_idle_artifacts()), - "try_evict" => assert!(fixture.ctx.try_evict_idle_artifacts()), - "clear" => fixture.ctx.clear_view_runtime(), - "replace" => { - let mut snapshot = fixture.ctx.view_runtime_snapshot().unwrap(); - let replacement = - crate::views::ViewStore::open(fixture._storage.path(), "replacement").unwrap(); - snapshot.scope = "replacement".into(); - snapshot.view_dir = replacement.view_dir().to_path_buf(); - snapshot.generation = None; - snapshot.manifest = None; - snapshot.query_pin = None; - fixture.ctx.install_view_runtime(snapshot, None); - } - _ => unreachable!(), - } - assert!( - !crate::views::assembly::cache_contains_for_test(view_dir), - "cache survived {action}" - ); - } -} - -#[test] -fn publication_io_error_names_operation_and_path() { - let fixture = Fixture::new(); - let path = fixture.project.path().join("tracked.rs"); - std::fs::remove_file(&path).unwrap(); - std::fs::create_dir(&path).unwrap(); - let error = fixture - .ctx - .publish_view_paths(BTreeSet::new(), true) - .unwrap_err(); - let error = error.to_string(); - assert!(error.starts_with("view I/O failed reading "), "{error}"); - assert!(error.contains("tracked.rs"), "{error}"); - assert_ne!(fixture.view.current_generation().unwrap(), None); -} #[test] fn publication_failure_streak_survives_changing_artifact_error_paths() { @@ -2448,648 +318,3 @@ fn publication_failure_streak_survives_changing_artifact_error_paths() { assert_eq!(retry.failures, 2); assert_eq!(retry.delay, Duration::from_secs(2)); } - -#[test] -fn publication_repeated_failure_backs_off_and_edit_resets_deadline() { - let fixture = Fixture::new(); - let root = fixture.job_root(); - { - let mut retry = fixture.ctx.view_publication_retry().lock(); - retry.initial = Duration::from_millis(80); - retry.maximum = Duration::from_millis(320); - } - let failures = Failures::new(root.clone()); - let completions = Completions::new(root.clone()); - let path = root.join("tracked.rs"); - std::fs::remove_file(&path).unwrap(); - std::fs::create_dir(&path).unwrap(); - fixture.schedule(); - let mut observed = Vec::new(); - for _ in 0..4 { - observed.push(failures.next()); - } - // Cancel before asserting, so even a broken retry policy leaves no live - // worker behind when an assertion fails. - cancel_for_context(&fixture.ctx); - completions.next(); - fixture.wait_idle(); - assert_eq!( - observed - .iter() - .map(|row| row.1.as_millis()) - .collect::>(), - vec![80, 160, 320, 320] - ); - assert_eq!( - observed.iter().map(|row| row.2).collect::>(), - vec![1, 2, 3, 4] - ); - assert_eq!( - observed.iter().map(|row| row.3).collect::>(), - vec![true, true, false, false] - ); - for pair in observed.windows(2) { - assert!( - pair[1].0.duration_since(pair[0].0) >= pair[0].1, - "retry ran before its deadline: {pair:?}" - ); - } - // A fresh scheduling request without a source edit must retain the streak. - fixture.schedule(); - let repeated = failures.next(); - cancel_for_context(&fixture.ctx); - completions.next(); - fixture.wait_idle(); - assert_eq!(repeated.2, 5); - let edited = root.join("untracked.rs"); - std::fs::write(&edited, "pub fn edit() {}\n").unwrap(); - fixture.ctx.record_checkout_watcher_change(&edited); - assert!(fixture.ctx.view_publication_retry().lock().due().is_none()); - fixture.schedule(); - let reset = failures.next(); - cancel_for_context(&fixture.ctx); - completions.next(); - fixture.wait_idle(); - assert_eq!( - (reset.1, reset.2, reset.3), - (Duration::from_millis(80), 1, true) - ); - std::fs::remove_dir(&path).unwrap(); - std::fs::write(&path, "pub fn recovered() {}\n").unwrap(); - fixture.ctx.record_checkout_watcher_change(&path); - fixture.schedule(); - completions.next(); - fixture.wait_idle(); - assert!(fixture.ctx.view_publication_retry().lock().due().is_none()); - assert_ne!( - fixture.view.current_generation().unwrap().unwrap(), - fixture.initial - ); -} - -#[test] -fn publication_inline_failure_obeys_the_shared_retry_deadline() { - let fixture = Fixture::new(); - let root = fixture.job_root(); - let path = root.join("tracked.rs"); - std::fs::remove_file(&path).unwrap(); - std::fs::create_dir(&path).unwrap(); - let before = fixture.ctx.view_publication_attempts_for_test(); - let mut state = crate::context::WatcherDrainSliceState::new( - fixture.ctx.configure_generation(), - fixture.ctx.configure_content_generation(), - ); - state.view_publication_paths.insert(path.clone()); - state.view_publication_due = Some(Instant::now()); - crate::runtime_drain::publish_view_if_quiet(&fixture.ctx, &mut state); - let due = fixture.ctx.view_publication_retry().lock().due().unwrap(); - assert_eq!(fixture.ctx.view_publication_attempts_for_test(), before + 1); - assert!(state.view_publication_paths.contains(&path)); - state.view_publication_due = Some(Instant::now()); - crate::runtime_drain::publish_view_if_quiet(&fixture.ctx, &mut state); - assert_eq!(fixture.ctx.view_publication_attempts_for_test(), before + 1); - assert_eq!(state.view_publication_due, Some(due)); - fixture.ctx.record_checkout_watcher_change(&path); - state.view_publication_due = Some(Instant::now()); - crate::runtime_drain::publish_view_if_quiet(&fixture.ctx, &mut state); - assert_eq!(fixture.ctx.view_publication_attempts_for_test(), before + 2); -} - -#[cfg(unix)] -#[test] -fn publication_omits_deleted_symlinks() { - for staged in [false, true] { - let fixture = Fixture::new(); - let link = fixture.project.path().join("link.rs"); - std::os::unix::fs::symlink("tracked.rs", &link).unwrap(); - commit(fixture.project.path()); - fixture - .ctx - .publish_view_paths(BTreeSet::new(), true) - .unwrap(); - std::fs::remove_file(&link).unwrap(); - if staged { - git(fixture.project.path(), &["add", "-u"]); - } - let report = fixture - .ctx - .publish_view_paths(BTreeSet::new(), true) - .unwrap(); - assert!(report.published); - assert_eq!(report.manifest.unwrap().entries().count(), 1); - } -} - -#[test] -fn publication_build_does_not_delay_same_root_bind_and_read() { - let fixture = Fixture::new(); - fixture.change("next"); - let gate = Gate::new(fixture.job_root().as_path(), "derived"); - fixture.schedule(); - gate.started(); - let health = health_snapshot(); - assert!(health.as_array().unwrap().iter().any(|job| job["root"] - == fixture.job_root().as_path().to_string_lossy().as_ref() - && job["phase"] == "derived" - && job["barrier_holder"] == false)); - let bind = fixture.executor.submit( - fixture.root.clone(), - Lane::Mutating, - "route.bind".to_owned(), - Box::new(|_| Response::success("route.bind", serde_json::json!({}))), - ); - let read = fixture.executor.submit( - fixture.root.clone(), - 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!("checkout view reader unavailable") - }; - assert!(crate::callgraph_store::CallGraphRead::node_for( - &store, - Path::new("tracked.rs"), - "next" - ) - .is_ok()); - Response::success("view.read", serde_json::json!({})) - }), - ); - // Completion before releasing the build proves it holds neither the writer - // barrier nor a reader reservation. These timeouts only guard against hangs; - // thread scheduling and pointer fsync latency are not the property under test. - let bound = bind - .recv_timeout(Duration::from_secs(60)) - .expect("same-root bind must complete while publication is held"); - assert!(bound.success, "same-root bind failed: {bound:?}"); - let read_result = read - .recv_timeout(Duration::from_secs(60)) - .expect("same-root read must complete while publication is held"); - assert!( - read_result.success, - "same-root view read failed: {read_result:?}" - ); - assert_eq!( - fixture.view.current_generation().unwrap().as_deref(), - Some(fixture.initial.as_str()) - ); - drop(gate); - fixture.wait_idle(); - assert_ne!( - fixture.view.current_generation().unwrap().as_deref(), - Some(fixture.initial.as_str()) - ); -} - -#[test] -fn invalidated_publication_cancels_before_derived_and_removes_generation_files() { - let fixture = Fixture::new(); - fixture.change("older"); - let completions = Completions::new(fixture.job_root()); - let gate = Gate::new(fixture.job_root().as_path(), "blobs"); - fixture.schedule(); - let older = gate.started(); - cancel_for_context(&fixture.ctx); - drop(gate); - assert_eq!(completions.next(), older); - fixture.change("newer"); - let newer_gate = Gate::new(fixture.job_root().as_path(), "cas"); - fixture.schedule(); - let newer_id = newer_gate.started(); - let delay = std::env::var("AFT_TEST_TIMING_DELAY_MS") - .ok() - .and_then(|value| value.parse().ok()) - .unwrap_or(0); - std::thread::sleep(Duration::from_millis(delay)); - drop(newer_gate); - // Waiting for lifecycle completion also observes cleanup, not just CAS. - assert_eq!(completions.next(), newer_id); - let newer = fixture.view.current_generation().unwrap(); - assert_ne!(newer.as_deref(), Some(fixture.initial.as_str())); - assert_eq!(fixture.view.current_generation().unwrap(), newer); - assert!( - !PHASES.lock()[fixture.job_root().as_path()] - .iter() - .any(|(id, phase)| *id == older && phase == "derived"), - "superseded assembly entered derived phase instead of cancelling" - ); - let files = std::fs::read_dir(fixture.view.view_dir()) - .unwrap() - .map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned()) - .filter(|name| { - name.starts_with("derived-") - || name.starts_with("manifest-") - || name.starts_with("trigram-") - }) - .collect::>(); - assert!(files.iter().all(|name| name.contains(&fixture.initial) || name.contains(newer.as_deref().unwrap())), "orphan generation files: {files:?}"); - let CallgraphStoreAccess::Ready(store) = fixture.ctx.callgraph_store_for_ops() else { - panic!("newer reader unavailable") - }; - assert!(crate::callgraph_store::CallGraphRead::node_for( - &store, - Path::new("tracked.rs"), - "newer" - ) - .is_ok()); -} - -#[test] -fn publication_health_reports_root_and_each_off_lane_phase() { - let fixture = Fixture::new(); - for phase in ["manifest", "blobs", "derived", "cas"] { - fixture.change(&format!("phase_{phase}")); - let gate = Gate::new(fixture.job_root().as_path(), phase); - fixture.schedule(); - let id = gate.started(); - let health = health_snapshot(); - let job = health - .as_array() - .unwrap() - .iter() - .find(|job| job["id"] == id) - .unwrap(); - assert_eq!( - job["root"], - fixture.job_root().as_path().to_string_lossy().as_ref() - ); - assert_eq!(job["phase"], phase); - assert_eq!(job["barrier_holder"], false); - assert!( - !fixture.executor.actor_is_idle(&fixture.root), - "an off-lane build must prevent actor retirement" - ); - drop(gate); - fixture.wait_idle(); - } -} - -#[test] -fn deleted_root_pending_publication_retires_once_without_rescheduling() { - let fixture = Fixture::new(); - fixture.change("next"); - let root = fixture.job_root(); - let before = fixture.ctx.view_publication_attempts_for_test(); - // The detached job reports "manifest" twice per attempt: once before it - // queues for its cold-build slot, and again as the first step of - // preparation, after the attempt counter has moved. Hold it at the second - // report, so the root disappears while preparation is running. - // - // Holding it at the first report instead makes the count depend on timing. - // Slot admission checks cancellation, and that check probes the root on - // disk again only once 50 ms (or 256 checks) have passed since its last - // probe on the worker thread. When the deletion takes longer than that, as - // it can on Windows, admission sees the deleted root and the job retires - // without preparing at all: zero attempts, which is also correct. - let gate = Gate::after(&root, "manifest", 1); - let completions = Completions::new(root.clone()); - fixture.schedule(); - gate.started(); - assert_eq!( - fixture.ctx.view_publication_attempts_for_test(), - before + 1, - "the gate must hold the publication inside its preparation attempt" - ); - std::fs::remove_dir_all(fixture.project.path()).unwrap(); - drop(gate); - completions.next(); - fixture.wait_idle(); - assert_eq!( - fixture.ctx.view_publication_attempts_for_test(), - before + 1, - "pending publication must make exactly one preparation attempt" - ); - assert!( - fixture.ctx.view_runtime_snapshot().is_none(), - "deletion must retire views state, not just stop the current thread" - ); - // Repeated quiet-window ticks simulate watcher publication retries after - // checkout deletion. Late paths must not recreate a retired publication. - let mut state = crate::context::WatcherDrainSliceState::new( - fixture.ctx.configure_generation(), - fixture.ctx.configure_content_generation(), - ); - for _ in 0..3 { - state.view_publication_due = Some(Instant::now()); - state.view_publication_paths.insert(root.join("tracked.rs")); - crate::runtime_drain::publish_view_if_quiet(&fixture.ctx, &mut state); - assert!(state.view_publication_due.is_none()); - assert!(state.view_publication_paths.is_empty()); - assert!(schedule(&fixture.ctx, BTreeSet::new(), true).is_ok()); - } - assert_eq!( - fixture.ctx.view_publication_attempts_for_test(), - before + 1, - "deleted publication must never be re-scheduled" - ); - assert!(!running_for_context(&fixture.ctx)); -} - -/// Waits until the limiter's queue shows a waiter of `kind`. The deadline only -/// guards against a hang; the condition is the queue state itself. -fn wait_until_queued( - limiter: &crate::cold_build_limiter::ColdBuildLimiter, - kind: &str, - failure: &str, -) { - let deadline = Instant::now() + Duration::from_secs(10); - while !limiter.census().queued.iter().any(|job| job.kind == kind) { - assert!( - Instant::now() < deadline, - "{failure}: {:?}", - limiter.census() - ); - std::thread::sleep(Duration::from_millis(5)); - } -} - -#[test] -fn publication_queues_for_a_cold_build_slot_behind_an_earlier_waiter() { - let fixture = Fixture::new(); - fixture.ctx.isolate_cold_build_limiter_for_test(1); - let limiter = fixture.ctx.cold_build_limiter(); - let held = limiter.try_acquire().expect("hold the only slot"); - let (admitted_tx, admitted_rx) = crossbeam_channel::bounded(1); - let earlier = { - let limiter = Arc::clone(&limiter); - let root = fixture.job_root(); - std::thread::spawn(move || { - let permit = crate::cold_build_limiter::acquire_blocking_while_with_limiter( - &limiter, - "earlier maintenance", - || true, - ) - .expect("earlier waiter is admitted"); - // Observed while the earlier waiter owns the slot: the publication - // must still be queued and must not have started building. - let publication_queued = limiter - .census() - .queued - .iter() - .any(|job| job.kind == PUBLICATION_LIMITER_KIND); - let publication_built = PHASES - .lock() - .get(&root) - .is_some_and(|phases| phases.iter().any(|(_, phase)| phase == "blobs")); - admitted_tx - .send((publication_queued, publication_built)) - .unwrap(); - drop(permit); - }) - }; - wait_until_queued( - &limiter, - "earlier maintenance", - "the earlier request never queued", - ); - fixture.change("next"); - fixture.schedule(); - wait_until_queued( - &limiter, - PUBLICATION_LIMITER_KIND, - "the publication never queued for a cold-build slot", - ); - drop(held); - let (publication_queued, publication_built) = admitted_rx - .recv_timeout(Duration::from_secs(60)) - .expect("the earlier waiter was admitted"); - earlier.join().unwrap(); - assert!( - publication_queued && !publication_built, - "the publication took the freed slot ahead of an earlier waiter \ - (queued={publication_queued} built={publication_built})" - ); - fixture.wait_idle(); - assert_ne!( - fixture.view.current_generation().unwrap().as_deref(), - Some(fixture.initial.as_str()) - ); -} - -#[test] -fn publication_waiting_for_the_epoch_write_gate_holds_no_cold_build_slot() { - let fixture = Fixture::new(); - fixture.ctx.isolate_cold_build_limiter_for_test(1); - let limiter = fixture.ctx.cold_build_limiter(); - let (epoch, detached_writers) = { - let state = fixture.executor.inner.state.lock(); - let actor = state.actors.get(&fixture.root).expect("actor"); - ( - Arc::clone(&actor.epoch), - Arc::clone(&actor.detached_writers), - ) - }; - fixture.change("next"); - // The held read guard simulates maintenance work on this root that has - // not yet released the actor's read gate. - let reader = epoch.read(); - fixture.schedule(); - // A detached publication counts itself as writer demand while it waits - // for the epoch write gate. - let deadline = Instant::now() + Duration::from_secs(60); - while detached_writers.load(std::sync::atomic::Ordering::Acquire) == 0 { - assert!( - Instant::now() < deadline, - "the publication never reached the epoch write gate: {}", - health_snapshot() - ); - std::thread::sleep(Duration::from_millis(5)); - } - let census = limiter.census(); - assert!( - census.holders.is_empty(), - "the publication held a cold-build slot while waiting for the epoch write gate: {census:?}" - ); - drop(reader); - fixture.wait_idle(); - assert_ne!( - fixture.view.current_generation().unwrap().as_deref(), - Some(fixture.initial.as_str()) - ); -} - -/// A publication writes path status only after it has left its commit -/// barrier: the root actor's write gate and the job table are held only for -/// the pointer swap, never for status writes into SQLite, which on a large -/// checkout would make reads and other projects' publications wait. -#[test] -fn publication_status_writes_run_outside_the_commit_barrier() { - let fixture = Fixture::new(); - let view_dir = fixture.view.view_dir().to_owned(); - // A row the next publication settles, so it has status to write. - crate::path_status::PathStatusStore::open(&view_dir) - .unwrap() - .mark_pending(b"gone.rs", "view publication scheduled", 1) - .unwrap(); - let writes = Arc::new(std::sync::Mutex::new(Vec::new())); - let seen = Arc::clone(&writes); - crate::views::assembly::before_settle_write_for_test(&view_dir, move || { - seen.lock() - .unwrap() - .push(super::in_commit_barrier_for_test()); - }); - fixture.change("next"); - fixture.schedule(); - fixture.wait_idle(); - assert_eq!( - *writes.lock().unwrap(), - vec![false], - "status written inside the commit barrier, or not at all" - ); - assert!(crate::path_status::PathStatusStore::open(&view_dir) - .unwrap() - .status_for(b"gone.rs") - .unwrap() - .is_none()); -} - -#[test] -fn context_keeps_a_typed_conflict_and_the_winning_generation_for_retry() { - let fixture = Fixture::new(); - std::fs::write( - fixture.project.path().join("tracked.rs"), - "pub fn contender() {}\n", - ) - .unwrap(); - let paths = BTreeSet::from([b"tracked.rs".to_vec()]); - let mut first = fixture - .ctx - .prepare_view_paths(paths.clone(), true, &mut |_| Ok(())) - .unwrap(); - let mut second = fixture - .ctx - .prepare_view_paths(paths, true, &mut |_| Ok(())) - .unwrap(); - let winner = fixture - .ctx - .commit_view_update(&mut first) - .unwrap() - .generation - .unwrap(); - let error = fixture.ctx.commit_view_update(&mut second).unwrap_err(); - assert!( - matches!(&error, PublicationFailure::View(crate::views::ViewError::PublicationConflict { - attempted_generation, current_generation: Some(current), - }) if !attempted_generation.is_empty() && *current == winner) - ); - assert_eq!( - fixture - .ctx - .view_runtime_snapshot() - .unwrap() - .generation - .as_deref(), - Some(winner.as_str()) - ); - let mut retry = PublicationRetry::default(); - retry.failed(fixture.project.path(), "storage failure"); - retry.failed(fixture.project.path(), "storage failure"); - retry.failed_publication(fixture.project.path(), &error); - assert!(matches!(retry.category, Some(FailureCategory::Conflict))); - assert_eq!(retry.failures, 1); - assert_eq!(retry.delay, retry.initial); - assert!(retry.due().is_some()); - retry.failed_publication( - fixture.project.path(), - &PublicationFailure::Other(error.to_string()), - ); - assert!(matches!( - retry.category, - Some(FailureCategory::StorageOrBuild) - )); - assert_eq!( - retry.failures, 1, - "formatted text must not decide the retry category" - ); -} - -/// A publication writes its derived-output index row only after it has left -/// its commit barrier. The index is a SQLite file shared by every checkout of -/// the family, possibly held by another process; writing it under the root's -/// write gate or the process-wide job table would make this root's queries and -/// other roots' publications wait for an optional hint. -#[test] -fn publication_writes_the_derived_index_outside_the_commit_barrier() { - use crate::views::assembly::{before_index_write_for_test, index_writes_for_test, IndexWrite}; - let fixture = Fixture::new(); - let other = Fixture::new(); - let view_dir = fixture.view.view_dir().to_owned(); - assert_eq!( - index_writes_for_test(&view_dir), - vec![IndexWrite::Recorded], - "the fixture's first publication must build and index a derived graph" - ); - let index = fixture - ._storage - .path() - .join("blobs") - .join("publication-fixture") - .join("derived-index.sqlite"); - assert!(index.is_file()); - // Another connection holds the index's write lock, as another process - // updating the family's index would. - let holder = rusqlite::Connection::open(&index).unwrap(); - holder.execute_batch("BEGIN IMMEDIATE;").unwrap(); - let epoch = { - let state = fixture.executor.inner.state.lock(); - Arc::clone(&state.actors.get(&fixture.root).expect("actor").epoch) - }; - let (entered_tx, entered_rx) = crossbeam_channel::bounded::<(bool, bool, bool)>(1); - let (release_tx, release_rx) = crossbeam_channel::bounded::<()>(1); - before_index_write_for_test(&view_dir, move || { - // Whether this thread still holds the barrier, whether the job table - // is free, and whether this root's write gate is released. - let state = ( - super::in_commit_barrier_for_test(), - super::JOBS.try_lock().is_some(), - epoch.try_read().is_some(), - ); - let _ = entered_tx.try_send(state); - // Blocks only the first write: the test drops the sender to release it. - let _ = release_rx.recv_timeout(Duration::from_secs(30)); - }); - - fixture.change("next"); - fixture.schedule(); - let state = entered_rx - .recv_timeout(Duration::from_secs(60)) - .expect("the publication never reached its index write"); - assert_eq!( - state, - (false, true, true), - "(inside commit barrier, job table free, write gate free) at the index write" - ); - // The new generation is already served while the index write waits. - assert_ne!( - fixture.view.current_generation().unwrap().as_deref(), - Some(fixture.initial.as_str()) - ); - // A publication on another root needs the job table and finishes now. - other.change("elsewhere"); - other.schedule(); - other.wait_idle(); - assert_ne!( - other.view.current_generation().unwrap().as_deref(), - Some(other.initial.as_str()) - ); - drop(release_tx); - fixture.wait_idle_for(Duration::from_secs(30)); - assert_eq!( - index_writes_for_test(&view_dir).last(), - Some(&IndexWrite::Failed), - "a locked index is skipped with a log line, not waited on" - ); - - // Once the lock is gone, the next publication records its row. - holder.execute_batch("ROLLBACK;").unwrap(); - drop(holder); - fixture.change("again"); - fixture.schedule(); - fixture.wait_idle_for(Duration::from_secs(30)); - assert_eq!( - index_writes_for_test(&view_dir).last(), - Some(&IndexWrite::Recorded) - ); -} diff --git a/crates/aft/src/gc/mod.rs b/crates/aft/src/gc/mod.rs index 56190c3ca2..49e0b70abc 100644 --- a/crates/aft/src/gc/mod.rs +++ b/crates/aft/src/gc/mod.rs @@ -114,16 +114,6 @@ pub(crate) fn sweep_bounded( ) } -#[cfg(test)] -pub(crate) fn sweep_semantic_handoff_for_test( - request: SweepRequest<'_>, -) -> Result { - // The view-publication test runs GC while its assembler holds a callgraph - // SQLite connection. Collect only semantic blobs so the test never opens a - // second descriptor on that live callgraph database. - sweep_planes_bounded(request, &|| false, &[BlobPlane::Semantic]) -} - fn sweep_planes_bounded( request: SweepRequest<'_>, cancelled: &dyn Fn() -> bool, diff --git a/crates/aft/src/runtime_drain.rs b/crates/aft/src/runtime_drain.rs index 808813de4e..baea209fec 100644 --- a/crates/aft/src/runtime_drain.rs +++ b/crates/aft/src/runtime_drain.rs @@ -4954,201 +4954,6 @@ mod tests { assert_eq!(ignore_rule_refreshes_for_test(), 1); } - fn view_publication_context_for_reason_test( - root: &Path, - storage: &Path, - ) -> ( - Arc, - crossbeam_channel::Sender, - ) { - for arguments in [ - vec!["init", "--quiet"], - vec!["add", "."], - vec![ - "-c", - "user.name=Fixture", - "-c", - "user.email=fixture@example.invalid", - "-c", - "commit.gpgsign=false", - "commit", - "--quiet", - "-m", - "fixture", - ], - ] { - let output = std::process::Command::new("git") - .arg("-C") - .arg(root) - .args(arguments) - .output() - .unwrap(); - assert!( - output.status.success(), - "{}", - String::from_utf8_lossy(&output.stderr) - ); - } - let (ctx, tx) = watcher_context(root); - ctx.update_config(|config| { - config.views.enabled = true; - config.indexes.semantic = false; - config.indexes.callgraph = false; - }); - ctx.set_cache_writer_capabilities(true, true); - ctx.rebuild_gitignore(); - let scope = crate::path_identity::project_scope_key(root); - let view = crate::views::ViewStore::open(storage, &scope).unwrap(); - ctx.install_view_runtime( - crate::context::ViewRuntimeSnapshot { - query_pin: None, - storage: storage.to_path_buf(), - family: crate::search_index::artifact_cache_key(root), - scope, - view_dir: view.view_dir().to_path_buf(), - generation: None, - manifest: None, - head_fingerprint: String::new(), - head_metadata: crate::alias::capture_git_head_metadata(root, None).unwrap(), - pending_paths: BTreeSet::new(), - pending_inputs: Default::default(), - }, - None, - ); - // Seed a current manifest so periodic verification does not add an - // unrelated scheduling reason to these watcher publications. - ctx.publish_view_paths(BTreeSet::new(), true).unwrap(); - (Arc::new(ctx), tx) - } - - fn publish_due_view_for_reason_test(ctx: &AppContext) { - let mut state = ctx.watcher_drain_slice().lock().take().unwrap(); - // Make the publication deadline runnable without advancing the ignore - // refresh test clock. - if state.view_publication_due.is_some() { - state.view_publication_due = Some(Instant::now()); - } - publish_view_if_quiet(ctx, &mut state); - *ctx.watcher_drain_slice().lock() = Some(state); - } - - #[test] - fn delayed_view_publication_retains_ignore_reason_after_edit() { - let _reset = begin_ignore_rule_refresh_test(); - let temp = tempfile::tempdir().unwrap(); - let root = temp.path().canonicalize().unwrap(); - let storage = tempfile::tempdir().unwrap(); - let source = root.join("changed.rs"); - let ignore = root.join(".aftignore"); - std::fs::write(&source, "pub fn value() -> i32 { 1 }\n").unwrap(); - std::fs::write(&ignore, "old/\n").unwrap(); - let (ctx, tx) = view_publication_context_for_reason_test(&root, storage.path()); - let before = crate::views::assembly::publication_scheduling_for_test(&root).len(); - - std::fs::write(&source, "pub fn value() -> i32 { 2 }\n").unwrap(); - std::fs::write(&ignore, "generated/\n").unwrap(); - tx.send(WatcherDispatchEvent::Paths(vec![source])).unwrap(); - send_ignore_rule_change(&tx, ignore); - drain_watcher_to_completion_for_test(&ctx, WATCHER_PATH_DRAIN_BATCH_CAP); - publish_due_view_for_reason_test(&ctx); - - let first = crate::views::assembly::publication_scheduling_for_test(&root); - assert_eq!(first.len(), before + 1); - assert!( - first[before].contains("schedule_event_kinds=paths,ignore_rules "), - "{}", - first[before] - ); - assert_eq!(ignore_rule_refreshes_for_test(), 0); - assert_eq!( - ctx.watcher_drain_slice() - .lock() - .as_ref() - .unwrap() - .view_publication_reason - .events, - 0 - ); - - advance_ignore_rule_refresh_clock(Duration::from_millis(4_999)); - drain_watcher_to_completion_for_test(&ctx, WATCHER_PATH_DRAIN_BATCH_CAP); - assert_eq!(ignore_rule_refreshes_for_test(), 0); - assert_eq!( - crate::views::assembly::publication_scheduling_for_test(&root).len(), - before + 1 - ); - advance_ignore_rule_refresh_clock(Duration::from_millis(1)); - drain_watcher_to_completion_for_test(&ctx, WATCHER_PATH_DRAIN_BATCH_CAP); - assert_eq!(ignore_rule_refreshes_for_test(), 1); - publish_due_view_for_reason_test(&ctx); - - let publications = crate::views::assembly::publication_scheduling_for_test(&root); - assert_eq!(publications.len(), before + 2); - let second = &publications[before + 1]; - assert!( - second.contains("schedule_event_kinds=ignore_rules "), - "second publication lost ignore_rules: {second}" - ); - assert!(second.contains("schedule_full_read=true"), "{second}"); - } - - #[test] - fn delayed_view_publication_retains_deferred_rescan_reason() { - let temp = tempfile::tempdir().unwrap(); - let root = temp.path().canonicalize().unwrap(); - let storage = tempfile::tempdir().unwrap(); - std::fs::write(root.join("source.rs"), "pub fn value() {}\n").unwrap(); - let (ctx, tx) = view_publication_context_for_reason_test(&root, storage.path()); - let before = crate::views::assembly::publication_scheduling_for_test(&root).len(); - ctx.watcher_counters().note_overflow_ignore_relevance(false); - tx.send(WatcherDispatchEvent::RescanRequired(RescanReason::Unknown)) - .unwrap(); - let (reached, release) = install_watcher_rescan_gate_for_test(&ctx); - let worker_ctx = ctx.clone(); - let drain = std::thread::spawn(move || { - drain_watcher_events_bounded(&worker_ctx, WATCHER_PATH_DRAIN_BATCH_CAP) - }); - reached.recv_timeout(Duration::from_secs(5)).unwrap(); - ctx.watcher_counters().note_overflow_ignore_relevance(false); - assert!(ctx - .watcher_counters() - .note_overflow(RescanReason::KernelDropped, Vec::new())); - release.send(()).unwrap(); - assert!(drain.join().unwrap().has_more); - assert!( - ctx.watcher_drain_slice() - .lock() - .as_ref() - .unwrap() - .rescan_required - ); - publish_due_view_for_reason_test(&ctx); - assert_eq!( - crate::views::assembly::publication_scheduling_for_test(&root).len(), - before + 1 - ); - assert_eq!( - ctx.watcher_drain_slice() - .lock() - .as_ref() - .unwrap() - .view_publication_reason - .events, - 0 - ); - - drain_watcher_to_completion_for_test(&ctx, WATCHER_PATH_DRAIN_BATCH_CAP); - publish_due_view_for_reason_test(&ctx); - let publications = crate::views::assembly::publication_scheduling_for_test(&root); - assert_eq!(publications.len(), before + 2); - let second = &publications[before + 1]; - assert!( - second.contains("schedule_event_kinds=rescan "), - "second publication lost rescan: {second}" - ); - assert!(second.contains("schedule_full_read=true"), "{second}"); - } - #[test] fn repeated_bare_wildcard_ignore_rewrites_have_zero_downstream_work() { const REWRITES: usize = 8; @@ -8773,7 +8578,7 @@ mod tests { mod watcher_slice_tests { use super::*; use crate::config::Config; - use crate::context::{default_language_provider_factory, AppContext, ViewRuntimeSnapshot}; + use crate::context::{default_language_provider_factory, AppContext}; #[test] fn request_watcher_drain_applies_at_most_one_slice() { @@ -8846,46 +8651,15 @@ mod watcher_slice_tests { } #[test] - fn views_published_watcher_batch_skips_legacy_refresh_and_still_schedules_publication() { + fn views_routed_watcher_batch_skips_legacy_refresh_and_publishes_no_v1_view() { let root = tempfile::tempdir().unwrap(); - let storage = tempfile::tempdir().unwrap(); let (ctx, tx) = context_with_watcher(root.path()); ctx.update_config(|config| { config.indexes.callgraph = true; config.views.enabled = true; }); ctx.set_cache_writer_capabilities(false, false); - let family = crate::search_index::artifact_cache_key(root.path()); - let scope = crate::path_identity::project_scope_key(root.path()); - let view = crate::views::ViewStore::open(storage.path(), &scope).unwrap(); - ctx.install_view_runtime( - ViewRuntimeSnapshot { - query_pin: None, - storage: storage.path().to_path_buf(), - family, - scope, - view_dir: view.view_dir().to_path_buf(), - generation: Some("1-published".to_owned()), - manifest: Some( - crate::views::Manifest::new(std::iter::empty::<( - crate::views::RelPath, - crate::views::ManifestEntry, - )>()) - .unwrap(), - ), - head_fingerprint: "published".to_owned(), - head_metadata: crate::alias::capture_git_head_metadata(root.path(), None) - .unwrap_or_else(|_| crate::alias::GitHeadMetadata { - head_path: root.path().join(".git/HEAD"), - head_mtime: None, - resolved_ref_path: None, - resolved_ref_mtime: None, - }), - pending_paths: BTreeSet::new(), - pending_inputs: Default::default(), - }, - None, - ); + assert!(ctx.views_route_root()); reset_legacy_watcher_refreshes_for_test(); tx.send(WatcherDispatchEvent::Paths(vec![root .path() @@ -8898,15 +8672,16 @@ mod watcher_slice_tests { assert_eq!( legacy_watcher_refreshes_for_test(), 0, - "a published view must own watcher callgraph writes" - ); - assert!( - ctx.watcher_drain_slice() - .lock() - .as_ref() - .is_some_and(|state| state.view_publication_due.is_some()), - "view publication must remain scheduled" + "a routed root's v3 views must own watcher callgraph writes" ); + // Even once the batch's publication deadline passes, nothing is + // assembled or published into a v1 view. + if let Some(state) = ctx.watcher_drain_slice().lock().as_mut() { + state.view_publication_due = Some(Instant::now()); + } + drain_watcher_to_completion_for_test(&ctx, 1); + assert_eq!(ctx.view_publication_attempts_for_test(), 0); + assert!(ctx.view_runtime_snapshot().is_none()); ctx.update_config(|config| config.views.enabled = false); tx.send(WatcherDispatchEvent::Paths(vec![root diff --git a/crates/aft/src/subc/mod.rs b/crates/aft/src/subc/mod.rs index b709cee33f..6f7ede1cc8 100644 --- a/crates/aft/src/subc/mod.rs +++ b/crates/aft/src/subc/mod.rs @@ -17749,401 +17749,6 @@ mod tests { assert!(!deferred); } - /// Edit the fixture `edit_count` times, then check that the view - /// publication starts once, on a Watcher drain, after the quiet window. - /// - /// Other threads take the language-server manager lock at moments these - /// edits do not control: each watcher batch starts a background - /// applicability walk that locks the manager to store its result, and - /// status pushes read it. A scheduler probe that finds the manager busy - /// counts it as pending Lsp work, and a watcher drain that finds it busy - /// keeps its diagnostics step and is due again at once. Both are - /// legitimate, so the checks below count Watcher drains only (only they - /// publish views) and let a retried drain finish before checking that a - /// future quiet deadline alone wakes nothing. With `lsp_manager_busy` the - /// helper holds the manager during every probe, and during each edit's - /// drains until one of them has reached the diagnostics step, so both - /// cases happen on every run. `first_drain_runs_out_of_budget` makes the - /// first edit's drain spend its whole time slice before that step. - async fn assert_quiet_view_publication_wake( - edit_count: usize, - lsp_manager_busy: bool, - first_drain_runs_out_of_budget: bool, - ) { - assert!( - lsp_manager_busy || !first_drain_runs_out_of_budget, - "first_drain_runs_out_of_budget relies on lsp_manager_busy to follow the drain to its diagnostics step" - ); - use crate::context::ViewRuntimeSnapshot; - use crate::watcher_filter::WatcherDispatchEvent; - use std::collections::BTreeSet; - - let (_dir, root) = test_root("maintenance-view-deadline"); - let root_path = std::fs::canonicalize(root.as_path()).unwrap(); - let storage = tempfile::tempdir().unwrap(); - for args in [ - vec!["init", "--quiet"], - vec!["config", "user.name", "Publication test"], - vec!["config", "user.email", "publication@example.invalid"], - ] { - assert!(std::process::Command::new("git") - .args(args) - .current_dir(&root_path) - .status() - .unwrap() - .success()); - } - for name in ["first.rs", "second.rs"] { - std::fs::write(root_path.join(name), "pub fn original() {}\n").unwrap(); - } - for args in [ - vec!["add", "."], - vec![ - "-c", - "commit.gpgsign=false", - "commit", - "--quiet", - "-m", - "fixture", - ], - ] { - assert!(std::process::Command::new("git") - .args(args) - .current_dir(&root_path) - .status() - .unwrap() - .success()); - } - let ctx = test_ctx(); - ctx.update_config(|config| { - config.project_root = Some(root_path.clone()); - config.storage_dir = Some(storage.path().to_owned()); - config.views.enabled = true; - config.indexes.trigram = false; - config.indexes.semantic = false; - config.indexes.callgraph = true; - }); - ctx.set_canonical_cache_root(root_path.clone()); - let scope = crate::path_identity::project_scope_key(&root_path); - let view = crate::views::ViewStore::open(storage.path(), &scope).unwrap(); - ctx.install_view_runtime( - ViewRuntimeSnapshot { - query_pin: None, - storage: storage.path().to_owned(), - family: "maintenance-view-fixture".to_owned(), - scope, - view_dir: view.view_dir().to_owned(), - generation: None, - manifest: None, - head_fingerprint: String::new(), - head_metadata: crate::alias::capture_git_head_metadata(&root_path, None).unwrap(), - pending_paths: BTreeSet::new(), - pending_inputs: Default::default(), - }, - None, - ); - let initial = ctx - .publish_view_paths(BTreeSet::new(), true) - .unwrap() - .generation; - runtime_drain::drain_build_completions(&ctx); - runtime_drain::drain_inspect_events(&ctx); - let publications = crate::executor::view_publication::Completions::new(root_path.clone()); - let (tx, rx) = crossbeam_channel::unbounded(); - *ctx.watcher_rx().lock() = Some(rx); - let executor = Arc::new(Executor::new()); - assert!(executor.register_actor(root.clone(), Arc::clone(&ctx))); - let metrics = Arc::new(DispatchPathMetrics::new()); - let (completion_tx, mut completion_rx) = mpsc::channel(8); - let mut live_roots = HashMap::from([(root.clone(), RootMeta::new(Instant::now()))]); - let lsp_drains_run = std::cell::Cell::new(0usize); - // Probe once and run every due job to completion, as the frame loop - // would. `hold_through_drains` keeps a busy manager held while the - // jobs run, not just while the scheduler probes. - let mut jobs = async |hold_through_drains: bool| { - let mut held_lsp = lsp_manager_busy.then(|| ctx.lsp()); - let (jobs, deferred) = due_maintenance_jobs( - &mut live_roots, - Some(&executor), - &BgSubsBySession::new(), - &BgWakePending::new(), - MAINTENANCE_SUBMIT_BUDGET, - &HashSet::new(), - ); - if !hold_through_drains { - held_lsp = None; - } - assert!(!deferred); - for (job_root, _) in &jobs { - assert_eq!(job_root, &root); - } - for (job_root, kind) in &jobs { - if *kind == MaintenanceDrainKind::Lsp { - lsp_drains_run.set(lsp_drains_run.get() + 1); - } - submit_maintenance_job( - &executor, - job_root.clone(), - *kind, - Vec::new(), - &completion_tx, - &metrics, - ); - let completion = tokio::time::timeout(Duration::from_secs(5), completion_rx.recv()) - .await - .unwrap_or_else(|_| panic!("{kind:?} maintenance must finish")) - .unwrap(); - assert!(completion.response.success); - note_maintenance_completion( - live_roots.get_mut(job_root).unwrap(), - completion.requeue_kind, - false, - false, - ); - } - drop(held_lsp); - jobs.iter() - .filter(|(_, kind)| *kind == MaintenanceDrainKind::Watcher) - .count() - }; - assert_eq!( - jobs(false).await, - 0, - "no watcher drain is due before the first edit" - ); - let baseline = ctx.view_publication_attempts_for_test(); - let mut changed = BTreeSet::new(); - let mut previous_due = None; - let mut last_edit = Instant::now(); - for edit in 0..edit_count { - let path = root_path.join(if edit % 2 == 0 { - "first.rs" - } else { - "second.rs" - }); - last_edit = Instant::now(); - std::fs::write(&path, format!("pub fn changed_{edit}() {{}}\n")).unwrap(); - changed.insert(path.clone()); - tx.send(WatcherDispatchEvent::Paths(vec![path])).unwrap(); - let slow_slice = (first_drain_runs_out_of_budget && edit == 0).then(|| { - // The drain calls the language-server applicability refresh - // after its slice clock has started and before its symbol - // cache step. Holding the symbol cache from before the drain - // until a full budget after that refresh began therefore - // makes the drain spend its whole slice, whatever the - // scheduling, and park before the diagnostics step. - assert!( - !ctx.project_applicability_walk_running(), - "no applicability walk runs before the first edit" - ); - let holder_ctx = Arc::clone(&ctx); - let (held_tx, held_rx) = std::sync::mpsc::channel(); - let holder = std::thread::spawn(move || { - let cache = holder_ctx.symbol_cache(); - let _read = cache.read().unwrap(); - held_tx.send(()).unwrap(); - let deadline = Instant::now() + Duration::from_secs(10); - while !holder_ctx.project_applicability_walk_running() { - assert!( - Instant::now() < deadline, - "the drain's applicability walk was not seen running within 10 s" - ); - std::thread::sleep(Duration::from_millis(1)); - } - std::thread::sleep( - runtime_drain::WATCHER_DRAIN_SLICE_BUDGET + Duration::from_millis(50), - ); - }); - held_rx.recv().unwrap(); - holder - }); - assert_eq!(jobs(lsp_manager_busy).await, 1, "each batch gets one drain"); - if let Some(holder) = slow_slice { - holder.join().expect("symbol cache holder"); - let slice = ctx.watcher_drain_slice().lock(); - assert!( - matches!( - &slice.as_ref().unwrap().phase, - crate::context::WatcherDrainPhase::Apply { stage, .. } - if !matches!( - stage, - crate::context::WatcherDrainApplyPhase::LspDiagnostics - | crate::context::WatcherDrainApplyPhase::Complete - ) - ), - "the first drain runs out of its slice before the diagnostics step" - ); - } - if lsp_manager_busy { - // Keep the manager held across drains until one reaches the - // diagnostics step: a slow drain may spend its slice on - // earlier steps first. Six steps come before it and a drain - // parks only after a 250 ms slice, so 10 s fails only if the - // drain stops making progress. - let deadline = Instant::now() + Duration::from_secs(10); - while !ctx.watcher_lsp_diagnostics_retry_pending_for_test() { - assert!( - Instant::now() < deadline, - "a drain that finds the manager busy keeps its diagnostics step" - ); - jobs(true).await; - tokio::time::sleep(Duration::from_millis(5)).await; - } - } - // Finish drains that stopped early: a busy manager (above) or an - // exhausted time slice leaves path work that is due at once. 100 - // retries is far more than a manager held for a moment needs. - let mut retries = 0; - while ctx - .watcher_drain_slice() - .lock() - .as_ref() - .is_some_and(|state| state.has_pending_work()) - { - retries += 1; - assert!(retries <= 100, "an interrupted watcher drain must finish"); - jobs(false).await; - tokio::time::sleep(Duration::from_millis(5)).await; - } - let slice = ctx.watcher_drain_slice().lock(); - let state = slice.as_ref().unwrap(); - assert_eq!(state.view_publication_paths, changed); - let due = state.view_publication_due.unwrap(); - assert!(due > last_edit); - if let Some(previous) = previous_due { - assert!(due > previous, "each batch extends the quiet window"); - } - previous_due = Some(due); - drop(slice); - assert!( - ctx.view_publication_attempts_for_test() == baseline, - "the debounce must not publish early" - ); - assert_eq!( - jobs(false).await, - 0, - "a future deadline must not spin the dispatcher" - ); - tokio::time::sleep(Duration::from_millis(50)).await; - } - let due = previous_due.unwrap(); - let deadline = last_edit + Duration::from_secs(2); - while ctx.view_publication_attempts_for_test() == baseline && Instant::now() < deadline { - assert!( - !ctx.tier2_refresh_dispatch_due(), - "no Tier-2 rescue wake is due" - ); - jobs(false).await; - tokio::time::sleep(Duration::from_millis(20)).await; - } - let start_diagnostics = |observer: Instant| { - let producer = ctx.view_publication_attempt_started_for_test(baseline); - let relative = |stamp: Option, origin: Instant| match stamp { - Some(stamp) if stamp >= origin => format!("+{:?}", stamp.duration_since(origin)), - Some(stamp) => format!("-{:?}", origin.duration_since(stamp)), - None => "not recorded".to_owned(), - }; - format!( - "root={} producer_since_edit={} producer_after_due={} observer_since_edit={} observer_after_due={}", - root_path.display(), - relative(producer, last_edit), - relative(producer, due), - relative(Some(observer), last_edit), - relative(Some(observer), due), - ) - }; - assert_eq!( - ctx.view_publication_attempts_for_test() - baseline, - 1, - "view publication did not start within 2s of the edit without other maintenance due; {}", - start_diagnostics(Instant::now()) - ); - let started = Instant::now(); - assert!( - started >= due, - "publication must respect the quiet window; {}", - start_diagnostics(started) - ); - assert!( - started <= due + Duration::from_secs(1), - "publication start exceeded the quiet deadline + 1s; {}", - start_diagnostics(started) - ); - // Building a generation can take longer under CPU or I/O contention. - // Wait for this root's terminal event before checking its installation. - let publication = tokio::task::spawn_blocking(move || publications.next_event()) - .await - .expect("publication-settled observer must not panic"); - assert_eq!(publication.root, root_path); - assert_eq!( - publication.outcome, - crate::executor::view_publication::CompletionOutcome::Published, - "publication did not succeed: {:?}", - publication.outcome - ); - assert_ne!( - ctx.view_runtime_snapshot().unwrap().generation, - initial, - "a successful publication completion must follow generation installation" - ); - runtime_drain::drain_build_completions(&ctx); - runtime_drain::drain_inspect_events(&ctx); - let slice = ctx.watcher_drain_slice().lock(); - assert!(slice.as_ref().unwrap().view_publication_due.is_none()); - assert!(slice.as_ref().unwrap().view_publication_paths.is_empty()); - drop(slice); - for _ in 0..10 { - assert_eq!( - jobs(false).await, - 0, - "an unchanged root must return to idle" - ); - tokio::time::sleep(Duration::from_millis(20)).await; - } - assert_eq!( - ctx.view_publication_attempts_for_test() - baseline, - 1, - "a burst publishes exactly once" - ); - if lsp_manager_busy { - assert!( - lsp_drains_run.get() > 0, - "a probe that finds the manager busy schedules an Lsp drain" - ); - } - } - - #[tokio::test] - async fn view_publication_quiet_root_wakes_without_other_maintenance() { - assert_quiet_view_publication_wake(1, false, false).await; - } - - #[tokio::test] - async fn view_publication_burst_coalesces_and_returns_idle() { - assert_quiet_view_publication_wake(3, false, false).await; - } - - /// Every watcher batch with a project root starts the language-server - /// applicability walk on a background thread, and that walk takes the - /// manager lock to store its result. A probe in that window counts the - /// busy manager as pending Lsp work, so an Lsp drain may be due beside - /// the publication, and a drain in that window is due again to finish its - /// diagnostics step. The publication must still start once, on the - /// Watcher wake after the quiet window. - #[tokio::test] - async fn view_publication_wakes_while_the_language_server_manager_is_busy() { - assert_quiet_view_publication_wake(3, true, false).await; - } - - /// A slow or descheduled drain can spend its whole time slice on the - /// steps before the diagnostics step and save the rest of the batch for - /// the next drain. The busy-manager checks must wait for the drain that - /// actually reaches that step instead of assuming the first one does. - #[tokio::test] - async fn view_publication_wakes_when_the_first_drain_runs_out_of_budget() { - assert_quiet_view_publication_wake(3, true, true).await; - } - /// The maintenance probes run on the frame loop, which also answers /// health checks and routes requests. A probe that waits for its source's /// lock parks the whole module for as long as another thread holds it, @@ -21539,101 +21144,6 @@ mod tests { } } - /// The bind-time check that a view's payloads are still stored runs in the - /// configure tail, after the bind is answered: a bind never waits on it. - /// Held inside the check, the tail leaves the first bind already answered - /// and still lets another session's bind of the root be answered. - #[test] - fn route_binds_are_answered_while_the_view_payload_check_is_held() { - const ACK_BOUND: Duration = Duration::from_secs(2); - let _git_env = crate::test_env::hermetic_git_env_guard(); - let executor = Arc::new(Executor::new()); - let configured = ConfiguredRoot::unconfigured(&executor); - let root = configured.canonical_root.clone(); - // A published generation with call-graph keys gives the check work. - let report = - crate::views::assembly::publish_checkout(&crate::views::assembly::AssemblyRequest { - storage: configured.storage.path().to_path_buf(), - project_root: root.clone(), - family: crate::search_index::artifact_cache_key(&root), - scope: crate::path_identity::project_scope_key(&root), - desired_head: crate::views::assembly::head_tree_fingerprint( - &crate::alias::head_tree_entries(&root).unwrap(), - ), - changed_paths: std::collections::BTreeSet::new(), - semantic_keys: Default::default(), - require_semantic: false, - allow_blob_put: true, - callgraph: true, - }) - .unwrap(); - assert!(report.published, "{report:?}"); - let (_gate, check_held, release) = - crate::commands::configure::gate_configure_tail_stage_for_test( - root.clone(), - "view_payload_check", - ); - let views_bind = |index: usize| { - let mut request = configured.bind_request(index, "opencode"); - request.params["config"] = json!([{ - "tier": "user", - "source": "/u/aft.jsonc", - "doc": json!({ - "views": { "enabled": true }, - "indexes": { "semantic": false, "trigram": false, "callgraph": true }, - }) - .to_string(), - }]); - request - }; - - let started = Instant::now(); - let first = configured - .submit_bind(&executor, views_bind(0)) - .blocking_recv() - .expect("first bind completes"); - let first_ack = started.elapsed(); - assert!(first.success, "{}", first.data); - assert!( - check_held.try_recv().is_err(), - "the payload check ran before the bind was answered" - ); - let tail = executor.submit_maintenance_async( - configured.root.clone(), - Lane::MaintenanceCommit, - format!("subc-maintenance-drain-configure-tail-{}", root.display()), - Box::new(|ctx| { - let requeue = runtime_drain::drain_deferred_configure_maintenance_yielding(ctx); - Response::success("tail", json!({ "requeue": requeue })) - }), - ); - check_held - .recv_timeout(Duration::from_secs(30)) - .expect("the configure tail reaches the payload check"); - - let second_started = Instant::now(); - let mut second = configured.submit_bind(&executor, views_bind(1)); - let mut second_ack = None; - while second_started.elapsed() < ACK_BOUND { - if let Ok(response) = second.try_recv() { - assert!(response.success, "{}", response.data); - second_ack = Some(second_started.elapsed()); - break; - } - std::thread::sleep(Duration::from_millis(2)); - } - let _ = release.send(()); - assert!(tail.blocking_recv().expect("tail completion").success); - crate::commands::configure::drain_deferred_configure_maintenance(&configured.ctx); - eprintln!( - "bind acks with the payload check held: first={first_ack:?} second={second_ack:?}" - ); - assert!( - second_ack.is_some(), - "a bind was not answered within {ACK_BOUND:?} while the payload check was held" - ); - } - /// After a daemon restart every session of a root rebinds at once, while /// that root's first configure tail runs one long unit that cannot step /// aside (a view or artifact load on a large root). The herd rebinds with diff --git a/crates/aft/src/views/assembly.rs b/crates/aft/src/views/assembly.rs index dc240087db..0f45c9b729 100644 --- a/crates/aft/src/views/assembly.rs +++ b/crates/aft/src/views/assembly.rs @@ -41,11 +41,6 @@ pub(crate) fn full_verification_due(view_dir: &Path) -> bool { manifest_incremental::full_verification_due(view_dir) } -#[cfg(test)] -pub(crate) fn precheck_path_builds_for_test() -> usize { - manifest_incremental::precheck_path_builds_for_test() -} - /// Avoid build admission for notifications outside committed membership. This /// does not replace rescans, semantic handoffs, artifact repair or graph builds. pub(crate) fn can_skip_irrelevant_paths(request: &AssemblyRequest, defer_derived: bool) -> bool { @@ -86,16 +81,6 @@ pub(crate) fn can_skip_irrelevant_paths(request: &AssemblyRequest, defer_derived check().unwrap_or(false) } -#[cfg(test)] -pub(crate) fn expire_verification_for_test(view_dir: &Path) { - manifest_incremental::expire_verification_for_test(view_dir); -} - -#[cfg(test)] -pub(crate) fn cache_contains_for_test(view_dir: &Path) -> bool { - manifest_incremental::cache_contains_for_test(view_dir) -} - #[derive(Clone, Debug)] pub struct AssemblyRequest { pub storage: PathBuf, @@ -430,19 +415,6 @@ static INDEX_WRITES: std::sync::LazyLock< std::sync::Mutex>>, > = std::sync::LazyLock::new(Default::default); -/// Runs `hook` each time a derived-index row for `view_dir` is about to be -/// written, before the served generation is checked. -#[cfg(test)] -pub(crate) fn before_index_write_for_test( - view_dir: &Path, - hook: impl Fn() + Send + Sync + 'static, -) { - BEFORE_INDEX_WRITE - .lock() - .unwrap() - .insert(view_dir.to_path_buf(), std::sync::Arc::new(hook)); -} - /// The outcomes of every derived-index write for `view_dir` so far. #[cfg(test)] pub(crate) fn index_writes_for_test(view_dir: &Path) -> Vec { @@ -1864,29 +1836,6 @@ static PUBLICATION_OUTCOMES: std::sync::LazyLock< parking_lot::Mutex>>, > = std::sync::LazyLock::new(Default::default); -#[cfg(test)] -pub(crate) fn publication_outcomes_for_test(root: &Path) -> Vec<&'static str> { - PUBLICATION_OUTCOMES - .lock() - .get(root) - .map(|profiles| profiles.iter().map(|(outcome, _)| *outcome).collect()) - .unwrap_or_default() -} - -#[cfg(test)] -pub(crate) fn publication_scheduling_for_test(root: &Path) -> Vec { - PUBLICATION_OUTCOMES - .lock() - .get(root) - .map(|profiles| { - profiles - .iter() - .map(|(_, scheduling)| scheduling.clone()) - .collect() - }) - .unwrap_or_default() -} - fn log_publication_profile(profile: &PublicationProfile) { crate::slog_info!( "{} {} {}", diff --git a/crates/aft/src/views/manifest_incremental.rs b/crates/aft/src/views/manifest_incremental.rs index 0b71503d15..b1194e075b 100644 --- a/crates/aft/src/views/manifest_incremental.rs +++ b/crates/aft/src/views/manifest_incremental.rs @@ -7,11 +7,6 @@ thread_local! { static PRECHECK_PATH_BUILDS: std::cell::Cell = const { std::cell::Cell::new(0) }; } -#[cfg(test)] -pub(super) fn precheck_path_builds_for_test() -> usize { - PRECHECK_PATH_BUILDS.with(std::cell::Cell::get) -} - #[inline] fn record_path_comparison() { #[cfg(test)] diff --git a/crates/aft/src/views/mod.rs b/crates/aft/src/views/mod.rs index ea1d3e378f..f0720f4267 100644 --- a/crates/aft/src/views/mod.rs +++ b/crates/aft/src/views/mod.rs @@ -17,7 +17,7 @@ pub mod base_sweep; pub(crate) mod donor; mod generation; #[cfg(test)] -pub(crate) use generation::{clone_derived_for_test, wait_for_derived_checkpoint_for_test}; +pub(crate) use generation::wait_for_derived_checkpoint_for_test; pub(crate) mod io; #[cfg(aft_views_lazy_benchmark)] pub mod lazy_read_benchmark; diff --git a/crates/aft/src/views/parent/worker.rs b/crates/aft/src/views/parent/worker.rs index c3dc99ead0..133f52dd20 100644 --- a/crates/aft/src/views/parent/worker.rs +++ b/crates/aft/src/views/parent/worker.rs @@ -249,10 +249,9 @@ fn refresh_trigram(session: &ParentSession, child: &Child) { Plane::Gap("no trigram index has been built for this repository yet".into()); return; }; - if read(&child.trigram) - .ready() - .is_some_and(|current| matches!(¤t.source, TrigramSource::Legacy(current) if *current == artifact)) - { + if read(&child.trigram).ready().is_some_and( + |current| matches!(¤t.source, TrigramSource::Legacy(current) if *current == artifact), + ) { // Same artifact: changes since it was read arrive as watcher paths // and through the backstop, never by reading the artifact again. return; @@ -327,10 +326,9 @@ fn install_view_trigram( generation: &Arc, ) { let published = PublishedChild::of(generation); - if read(&child.trigram) - .ready() - .is_some_and(|current| matches!(¤t.source, TrigramSource::View(current) if *current == published)) - { + if read(&child.trigram).ready().is_some_and( + |current| matches!(¤t.source, TrigramSource::View(current) if *current == published), + ) { return; } child.loads.fetch_add(1, Ordering::SeqCst); @@ -349,7 +347,9 @@ fn install_view_trigram( session, child, TrigramChild { - index: Arc::new(crate::search_index::SearchIndex::from_view_snapshot(snapshot)), + index: Arc::new(crate::search_index::SearchIndex::from_view_snapshot( + snapshot, + )), source: TrigramSource::View(published), }, ); diff --git a/crates/aft/tests/integration/callgraph_worktree_test.rs b/crates/aft/tests/integration/callgraph_worktree_test.rs index 44ad676c9c..a67eaae6e3 100644 --- a/crates/aft/tests/integration/callgraph_worktree_test.rs +++ b/crates/aft/tests/integration/callgraph_worktree_test.rs @@ -244,15 +244,27 @@ fn callgraph_worktree_publishes_unique_source_without_an_owner_writer_or_overlay aft::views::callgraph::PRODUCER, ) .full_key(); - let blobs = aft::blob_store::BlobStore::open( + // With views on, the worktree's own content lands in the family's v3 + // call-graph plane; the v1 blob store is a legacy writer a views-routed + // root no longer runs. + let family = ctx.memoized_artifact_cache_key(&checkout); + let rows = aft::blob_store::v3::PlaneReader::open_existing( &storage, - aft::search_index::artifact_cache_key(&checkout), - aft::blob_store::BlobPlane::Callgraph, + &family, + aft::blob_store::v2::FamilyPlane::Callgraph, ) .unwrap(); assert!( - blobs.contains(&key).unwrap(), - "worktree must put its own immutable blob" + rows.contains(&aft::blob_store::v2::FamilyKey::from(&key)) + .unwrap(), + "worktree must put its own immutable row" + ); + assert!( + !storage + .join("blobs") + .join(aft::search_index::artifact_cache_key(&checkout)) + .exists(), + "a views-routed worktree must not open a v1 blob store" ); assert!( aft::callgraph_store::CallGraphStore::open_readonly(ctx.callgraph_store_dir(), checkout) diff --git a/crates/aft/tests/integration/per_checkout_9.rs b/crates/aft/tests/integration/per_checkout_9.rs index 52b4d13012..78847cc8b7 100644 --- a/crates/aft/tests/integration/per_checkout_9.rs +++ b/crates/aft/tests/integration/per_checkout_9.rs @@ -848,14 +848,15 @@ fn parent_grep_and_glob_read_views_on_children_from_their_v3_lexical_view() { std::fs::remove_file(folder.root.join("README.md")).unwrap(); let mut expected_grep = BTreeSet::new(); let mut children = Vec::new(); - for child in &folder.children { + for (index, child) in folder.children.iter().enumerate() { + // The child's bind publishes its v3 lexical view. let ctx = configure(child, &folder.storage, config_doc(true, TRIGRAM_ONLY, None)); - let prefix = child.strip_prefix(&folder.root).unwrap().to_path_buf(); - expected_grep.extend(grep_rows(&grep(&ctx, COMMON), child, &prefix)); assert!(!trigram_artifact(child, &folder.storage).exists()); + let prefix = format!("repo-{index:02}"); + expected_grep.insert((format!("{prefix}/src/lib.rs"), 5)); + expected_grep.insert((format!("{prefix}/src/util.rs"), 2)); children.push(ctx); } - assert_eq!(expected_grep.len(), 4, "every child contributes two lines"); let parent = configure( &folder.root, @@ -873,7 +874,11 @@ fn parent_grep_and_glob_read_views_on_children_from_their_v3_lexical_view() { assert_eq!(answer["complete"], true, "{answer:#}"); assert!(session.view_route_counts()[1] >= 1); let globbed = glob(&parent, "**/*.rs"); - assert_eq!(globbed["files"].as_array().map(Vec::len), Some(4), "{globbed:#}"); + assert_eq!( + globbed["files"].as_array().map(Vec::len), + Some(4), + "{globbed:#}" + ); let routed = session.view_route_counts()[1]; assert!(routed >= 2, "{routed}"); @@ -894,7 +899,11 @@ fn parent_grep_and_glob_read_views_on_children_from_their_v3_lexical_view() { ); for child in session.children() { assert_eq!(child.loads(), 1, "an unchanged publication is not reloaded"); - assert_eq!(child.applied_paths(), 0, "the parent applied an edit itself"); + assert_eq!( + child.applied_paths(), + 0, + "the parent applied an edit itself" + ); } drop(children); } @@ -1042,7 +1051,11 @@ fn parent_callgraph_reads_child_v3_views_and_answers_as_of_their_publication() { // A foreign reader names the publication it answers as of; it never // claims the child's graph is current. assert_eq!(callers["as_of"]["child"], "graph", "{callers:#}"); - assert_eq!(callers["as_of"]["generation"], first.as_str(), "{callers:#}"); + assert_eq!( + callers["as_of"]["generation"], + first.as_str(), + "{callers:#}" + ); assert!(callers["as_of"]["published_at_ms"].is_u64(), "{callers:#}"); assert_eq!(session.view_route_counts()[0], 1); @@ -1180,7 +1193,11 @@ fn parent_callgraph_reads_child_v3_views_and_answers_as_of_their_publication() { ) .unwrap(), ); - assert_eq!(followed["as_of"]["generation"], second.as_str(), "{followed:#}"); + assert_eq!( + followed["as_of"]["generation"], + second.as_str(), + "{followed:#}" + ); assert!(followed.to_string().contains("second"), "{followed:#}"); // The parent built and wrote no call graph of its own. assert!(parent.callgraph_store().read().unwrap().is_none()); From b2d96785364d1e86953b14bde64343b933e60f75 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Tue, 27 Oct 2026 08:54:37 +0100 Subject: [PATCH 088/100] mason: search-quality descriptor for views step H routes Co-authored-by: alfonso-aft <289616620+alfonso-aft@users.noreply.github.com> --- .../land-views-h-semantic-routes.json | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 benchmarks/aft-search/slice-descriptors/land-views-h-semantic-routes.json diff --git a/benchmarks/aft-search/slice-descriptors/land-views-h-semantic-routes.json b/benchmarks/aft-search/slice-descriptors/land-views-h-semantic-routes.json new file mode 100644 index 0000000000..ab928ab701 --- /dev/null +++ b/benchmarks/aft-search/slice-descriptors/land-views-h-semantic-routes.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-h-semantic-routes" + ], + "note": "Views step H, first part: views-routed roots stop the v1 view writers, symbol prewarm reads and writes the v3 symbol plane, and parent folders read children's v3 lexical and call-graph views. search_index.rs gains only a file-entry accessor and a read-only index built from a view snapshot, both reached only with views on; commands/semantic_search is not changed. Views are default-off, so the benchmark runs the views-off path and ranked rows must remain byte-identical to the committed reference." +} From 21a32d55fed311df2d368af74a402ca014045fea Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Tue, 27 Oct 2026 08:59:51 +0100 Subject: [PATCH 089/100] mason: clarify two comments on view snapshots and the parent's lexical source Co-authored-by: alfonso-aft <289616620+alfonso-aft@users.noreply.github.com> --- crates/aft/src/search_index.rs | 4 ++-- crates/aft/src/views/parent/worker.rs | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/aft/src/search_index.rs b/crates/aft/src/search_index.rs index 385b7512b5..27dd66cf09 100644 --- a/crates/aft/src/search_index.rs +++ b/crates/aft/src/search_index.rs @@ -884,8 +884,8 @@ impl SearchIndexSnapshot { .map(|file| file.modified) } - /// The files this snapshot answers for, with the size, modification time - /// and content hash it verified for each. + /// The files this snapshot answers for, with the size and content hash it + /// records for each and the modification time observed when it was built. pub(crate) fn file_entries(&self) -> &[FileEntry] { &self.files } diff --git a/crates/aft/src/views/parent/worker.rs b/crates/aft/src/views/parent/worker.rs index 133f52dd20..316e1cdb01 100644 --- a/crates/aft/src/views/parent/worker.rs +++ b/crates/aft/src/views/parent/worker.rs @@ -301,8 +301,8 @@ fn refresh_trigram(session: &ParentSession, child: &Child) { ); } -/// The child's published v3 lexical view, or `None` when its sessions never -/// published one (a child bound with views off, or never bound). +/// The child's published v3 lexical view, or `None` when none is published +/// yet (a child bound only with views off, never bound, or not yet bound). fn open_child_lexical_view( session: &ParentSession, child: &Child, From 338e24aecdb4ce2a2d067e072dd48cc9af524913 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Tue, 27 Oct 2026 14:07:31 +0100 Subject: [PATCH 090/100] mason: let a call-graph view complete a base that lists documents as pending A base first built by the lexical view (no graph producer registered) lists every member's call-graph state as pending, documents and other files no graph is extracted for included. The call-graph view sharing that base then refused to complete it ('base member changed before its graph was completed') and could never project it, so callers on a worktree with Markdown files failed (callgraph_worktree_new_markdown_does_not_wait_for_ unrelated_planes). Completion now skips members the call-graph plane does not apply to, the projection gives them no key whatever state an older base recorded, and plane readiness no longer counts them as pending. Co-authored-by: alfonso-aft <289616620+alfonso-aft@users.noreply.github.com> --- crates/aft/src/views/callgraph.rs | 4 ++++ crates/aft/src/views/first_load.rs | 7 +++++++ crates/aft/src/views/readiness.rs | 4 +++- 3 files changed, 14 insertions(+), 1 deletion(-) diff --git a/crates/aft/src/views/callgraph.rs b/crates/aft/src/views/callgraph.rs index a1d2f59567..4e827dee1b 100644 --- a/crates/aft/src/views/callgraph.rs +++ b/crates/aft/src/views/callgraph.rs @@ -133,6 +133,10 @@ pub fn project_manifest( let key = match manifest.plane_readiness(path, FamilyPlane::Callgraph) { Some(PlaneState::Ready { key }) => Some(key.clone()), None => None, + // A member no graph is extracted for needs no key. A base + // built without a registered graph producer still lists + // its call-graph state as pending. + Some(_) if !applies_to(path) => None, Some(_) => { return Err(error(format!("callgraph member is not ready: {path:?}"))) } diff --git a/crates/aft/src/views/first_load.rs b/crates/aft/src/views/first_load.rs index f9e4bbf155..783c32bf5b 100644 --- a/crates/aft/src/views/first_load.rs +++ b/crates/aft/src/views/first_load.rs @@ -4651,6 +4651,13 @@ impl CompositePlane for CallgraphBridge { { continue; } + // A base built without a registered graph producer leaves every + // member pending, including members no graph is extracted for + // (documents, unsupported languages). Those need no blob and stay + // as they are; the graph projection ignores their state. + if !super::callgraph::applies_to(path) { + 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| { diff --git a/crates/aft/src/views/readiness.rs b/crates/aft/src/views/readiness.rs index 479f565e30..d3e17ed4ea 100644 --- a/crates/aft/src/views/readiness.rs +++ b/crates/aft/src/views/readiness.rs @@ -212,7 +212,9 @@ pub fn plane_readiness( let mut pending = 0; let mut failed = 0; for (path, entry) in generation.entries() { - if live.live_state(path).is_some() { + // A member the plane has no work for counts neither way, whatever + // state an older base recorded for it. + if live.live_state(path).is_some() || !applies_to(path) { continue; } let Some(content) = entry.content() else { From 297f5e6140197d6d73bd3657d4e86f98aa634a88 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Tue, 27 Oct 2026 14:07:33 +0100 Subject: [PATCH 091/100] mason: serve lexical live entries whose payload row the family already stored A live entry reconciled after another view of the family (the call-graph view registers the trigram plane too) stored the same bytes carries only the stored row's key. The lexical projection accepted only extracted payloads, so after a branch switch grep failed with 'lexical payload not ready' (views_round_trip_reuses_semantic_blobs_without_embedding). It now reads that row from the family's v3 trigram plane. Co-authored-by: alfonso-aft <289616620+alfonso-aft@users.noreply.github.com> --- crates/aft/src/views/trigram.rs | 33 ++++++++++++++++++++++----------- 1 file changed, 22 insertions(+), 11 deletions(-) diff --git a/crates/aft/src/views/trigram.rs b/crates/aft/src/views/trigram.rs index f8218addb1..c3c42bfd41 100644 --- a/crates/aft/src/views/trigram.rs +++ b/crates/aft/src/views/trigram.rs @@ -279,23 +279,34 @@ impl LexicalView { policy: self.policy, } .family_key(); + // A live entry whose payload row the family's store already held + // when it was reconciled (another view of the family stored the + // same bytes) carries only that row's key; read the row. + let mut stored_row = false; 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()), + Source::Live(entry) => match entry.attachments.get(&FamilyPlane::Trigram) { + Some(value) => match value.downcast_ref::() { + Some(attachment) => Some(attachment) + .filter(|attachment| { + attachment.content == content + && attachment.policy == self.policy.fingerprint() + }) + .map(|attachment| attachment.payload.clone()), + None => { + stored_row = true; + None + } + }, + None => None, + }, _ => None, }; let payload = match payload { Some(payload) => payload, None => { - if snapshot.plane_readiness(&path, FamilyPlane::Trigram) - != Some(&super::readiness::PlaneState::ready(&key)) + if !stored_row + && snapshot.plane_readiness(&path, FamilyPlane::Trigram) + != Some(&super::readiness::PlaneState::ready(&key)) { return Err(format!("lexical payload not ready: {}", absolute.display())); } From fc189d76543de3c663597004f84de07d883e1cfa Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Tue, 27 Oct 2026 14:07:36 +0100 Subject: [PATCH 092/100] mason: port views-on branch-switch and atomic-save tests to the v3 routes - branch_switch_test::wait_until_ready: with views on no legacy search index is built; grep readiness is checked by the grep answers the callers wait for. Fixes views_round_trip_reuses_semantic_blobs_without_ embedding (with the lexical fix). - Retired branch_switch_test::views_readiness_wait_rides_out_a_locked_ derived_database: its subject was the v1 published view's derived database being reopened per request without a busy wait. A v3 view keeps its pinned reader's connection open, so the test can no longer take the exclusive lock it relies on; lock contention while a v3 reader opens is covered by the views_keyed_generation_*_while_derived_store_is_busy lib tests. - callgraph_atomic_save_test: the views-on linked-worktree case required at least one stale answer during a refresh window. On the v3 route every answer comes from a bind that verifies the files against the disk, so there is no refresh window; any stale answer must still say it is incomplete (unchanged). The now-unused PendingWindow distinction is removed. Co-authored-by: alfonso-aft <289616620+alfonso-aft@users.noreply.github.com> --- .../tests/integration/branch_switch_test.rs | 57 ++++--------------- .../callgraph_atomic_save_test.rs | 45 +++------------ 2 files changed, 19 insertions(+), 83 deletions(-) diff --git a/crates/aft/tests/integration/branch_switch_test.rs b/crates/aft/tests/integration/branch_switch_test.rs index eaa46f46cd..6a3afbe73b 100644 --- a/crates/aft/tests/integration/branch_switch_test.rs +++ b/crates/aft/tests/integration/branch_switch_test.rs @@ -377,12 +377,16 @@ fn wait_until_ready(ctx: &AppContext) -> Arc None, CallgraphStoreAccess::Error(error) => panic!("callgraph failed while waiting: {error}"), }; - let search_ready = ctx - .search_index() - .read() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .as_ref() - .is_some_and(|index| index.ready); + // With views on, grep and glob read the checkout's lexical view on + // demand and no legacy search index is ever built; their readiness + // is checked by the grep answers the callers wait for. + let search_ready = ctx.config().views.enabled + || ctx + .search_index() + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .as_ref() + .is_some_and(|index| index.ready); let semantic_ready = matches!( &*ctx .semantic_index_status() @@ -605,47 +609,6 @@ fn views_round_trip_reuses_semantic_blobs_without_embedding() { } } -/// A publication can briefly lock the derived call-graph database a reader -/// opens (its deferred checkpoint, or its last connection closing the WAL). -/// The view reader has no busy wait and reports that as a retryable lock; -/// waiting for readiness must ride it out, not fail on "database is locked". -#[test] -fn views_readiness_wait_rides_out_a_locked_derived_database() { - let _watcher_guard = crate::helpers::watcher_serial_lock(); - let server = MockEmbeddingServer::start(); - let repo = RepoFixture::new(4, 2); - let storage = tempfile::tempdir().unwrap(); - let ctx = configure_context_with_views(&repo.root, storage.path(), &server, false, true); - let derived = wait_until_ready(&ctx).sqlite_path().to_path_buf(); - // The publication's own deferred checkpoint may still hold the file; take - // the exclusive lock once it lets go. - let holder = rusqlite::Connection::open(&derived).expect("derived writer"); - let deadline = Instant::now() + Duration::from_secs(30); - while holder - .execute_batch("PRAGMA locking_mode=EXCLUSIVE; BEGIN EXCLUSIVE;") - .is_err() - { - assert!(Instant::now() < deadline, "derived database stayed busy"); - thread::sleep(Duration::from_millis(20)); - } - match ctx.callgraph_store_for_ops() { - CallgraphStoreAccess::Error(error) => assert!( - error.is_transient_lock_contention(), - "the lock must surface as contention: {error}" - ), - _ => panic!("the exclusive lock must block the view reader"), - } - let release = thread::spawn(move || { - thread::sleep(Duration::from_millis(300)); - holder - .execute_batch("ROLLBACK") - .expect("release derived lock"); - }); - let store = wait_until_ready(&ctx); - release.join().unwrap(); - assert_eq!(store.sqlite_path(), derived); -} - /// Waits until the views-on semantic view has current vectors for every /// semantic file of the checkout as it is on disk. fn wait_for_semantic_view(ctx: &AppContext) { diff --git a/crates/aft/tests/watcher_integration/callgraph_atomic_save_test.rs b/crates/aft/tests/watcher_integration/callgraph_atomic_save_test.rs index eb698dfd7e..6700e99b7a 100644 --- a/crates/aft/tests/watcher_integration/callgraph_atomic_save_test.rs +++ b/crates/aft/tests/watcher_integration/callgraph_atomic_save_test.rs @@ -202,16 +202,6 @@ fn wait_for_initial_edge(aft: &mut AftProcess, file: &Path) { } } -/// What the save's refresh window must show before the edited edge appears. -#[derive(Clone, Copy, PartialEq, Eq)] -enum PendingWindow { - /// The refresh can finish between two polls, so the window may go unseen. - MayBeUnobserved, - /// The refresh waits out a quiet window after the watcher event, so at - /// least one poll lands in it and must be marked incomplete. - MustBeObserved, -} - /// What the test has learned about whether the daemon has received the save's /// watcher events, and how many stale answers said they were incomplete. #[derive(Default)] @@ -275,7 +265,6 @@ fn poll_round( fn poll_until_fresh( aft: &mut AftProcess, file: &Path, - window: PendingWindow, saved: Instant, evidence: &mut SaveEvidence, ) { @@ -289,10 +278,6 @@ fn poll_until_fresh( saved.elapsed().as_millis(), evidence.stale_marked ); - assert!( - window == PendingWindow::MayBeUnobserved || evidence.stale_marked > 0, - "no answer served while the save was pending said it was incomplete" - ); return; } assert!( @@ -319,11 +304,11 @@ fn prepare_for_save(aft: &mut AftProcess, file: &Path) { /// index answers with the edit, or an earlier answer was already marked /// pending), every answer that still shows the old edge must carry /// `complete: false`: the daemon cannot take back knowledge of a pending edit. -fn assert_atomic_save_reaches_callgraph(aft: &mut AftProcess, file: &Path, window: PendingWindow) { +fn assert_atomic_save_reaches_callgraph(aft: &mut AftProcess, file: &Path) { prepare_for_save(aft, file); atomic_save(file, BETA_SOURCE); let saved = Instant::now(); - poll_until_fresh(aft, file, window, saved, &mut SaveEvidence::default()); + poll_until_fresh(aft, file, saved, &mut SaveEvidence::default()); } #[test] @@ -335,11 +320,7 @@ fn atomic_save_reaches_callgraph_on_a_plain_checkout_with_views_off() { init_repo(&root); let mut aft = AftProcess::spawn_with_real_watcher(); configure(&mut aft, &root, &base.join("storage"), false); - assert_atomic_save_reaches_callgraph( - &mut aft, - &root.join("src/probe.ts"), - PendingWindow::MayBeUnobserved, - ); + assert_atomic_save_reaches_callgraph(&mut aft, &root.join("src/probe.ts")); assert!(aft.shutdown().success()); } @@ -414,13 +395,7 @@ fn an_answer_given_before_the_watcher_took_the_save_is_not_judged_by_later_evide "callers was asked after the watcher took the save, so it names the pending file: {:#}", round.callers ); - poll_until_fresh( - &mut aft, - &file, - PendingWindow::MayBeUnobserved, - saved, - &mut evidence, - ); + poll_until_fresh(&mut aft, &file, saved, &mut evidence); assert!(aft.shutdown().success()); } @@ -444,12 +419,10 @@ fn atomic_save_reaches_callgraph_on_a_linked_worktree_with_views_on() { ); let mut aft = AftProcess::spawn_with_real_watcher(); configure(&mut aft, &checkout, &base.join("storage"), true); - // A view publishes only after a quiet window following the last watcher - // event, so the pending state is always observable here. - assert_atomic_save_reaches_callgraph( - &mut aft, - &checkout.join("src/probe.ts"), - PendingWindow::MustBeObserved, - ); + // With views on, every call-graph answer comes from a bind of the + // checkout's v3 view that verifies the files against the disk, so there is + // no refresh window to observe. A stale answer, should one be served, must + // still say it is incomplete. + assert_atomic_save_reaches_callgraph(&mut aft, &checkout.join("src/probe.ts")); assert!(aft.shutdown().success()); } From 0b08c906362c7788c48fd212ecbf62fe9bc12d03 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Tue, 27 Oct 2026 14:28:34 +0100 Subject: [PATCH 093/100] mason: clarify why the graph projection ignores non-graph members Co-authored-by: alfonso-aft <289616620+alfonso-aft@users.noreply.github.com> --- crates/aft/src/views/callgraph.rs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/crates/aft/src/views/callgraph.rs b/crates/aft/src/views/callgraph.rs index 4e827dee1b..59ec2848cf 100644 --- a/crates/aft/src/views/callgraph.rs +++ b/crates/aft/src/views/callgraph.rs @@ -133,9 +133,11 @@ pub fn project_manifest( let key = match manifest.plane_readiness(path, FamilyPlane::Callgraph) { Some(PlaneState::Ready { key }) => Some(key.clone()), None => None, - // A member no graph is extracted for needs no key. A base - // built without a registered graph producer still lists - // its call-graph state as pending. + // Members no call graph is extracted for (Markdown, for + // example) need no key, so their state is ignored rather + // than reported as not ready. A composed generation can + // inherit them as pending from a base the lexical view + // built without a graph producer. Some(_) if !applies_to(path) => None, Some(_) => { return Err(error(format!("callgraph member is not ready: {path:?}"))) From 3d1521ba7c3eb17abb3e81c53ce308928e71cfd3 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Tue, 27 Oct 2026 18:03:13 +0100 Subject: [PATCH 094/100] mason: retire views-on v1 publication tests the writer stop broke, and serialize views tests on the process environment Views-routed roots open, import and publish no v1 view (Step H part 1), so tests whose subject was that v1 path fail on every platform. Retired, each covering v1-only behaviour with a v3 counterpart covered elsewhere: - busy_readiness_defers_revalidation_without_a_cold_rebuild and configure_busy_callgraph_readiness_revalidates_after_unlock: a locked v1 derived database at configure's payload check. v3 lock contention is covered by views_keyed_generation_*_while_derived_store_is_busy. - linked_worktree_defers_and_builds_on_first_query_when_main_is_not_bound, linked_worktree_that_never_queries_never_builds_a_call_graph, main_checkout_still_builds_its_call_graph_at_bind: the v1 lazy derived-graph policy (main built at bind, worktrees on first use). A routed checkout's call graph is its v3 view's, bound on first use; covered by the callgraph_views_* parity and linked-worktree view tests. Views-off roots keep the legacy store's bind-time warm unchanged. - legacy_semantic_import_runs_once_and_records_its_outcome: the one-time import of a legacy semantic set into the v1 view. The semantic lane's own import still runs (per_checkout_7). - per_checkout_9::standalone_view_publication_never_blocks_requests: a delayed v1 view publication; a routed root schedules none. Ported: configure_normal_project_keeps_root_work_enabled asserts the root is views-routed and opens no v1 view runtime (was: one is installed). Unused test helpers are removed. Shared state behind the full-suite-only failures: the process environment. AFT_STORAGE_DIR overrides every configured storage root (bash_background::storage_dir_from), and `git var GIT_ATTR_GLOBAL` (part of the Git conversion policy a verified bind records and revalidates) depends on HOME. Tests that change either hold the process environment lock (configure_semantic_checkout_uses_cache_volume_with_storage_env_override, home_env_mutex tests). The lexical and call-graph views resolve storage and revalidate the policy on every query, so the F/G/H views tests now hold that lock too: without it a callgraph parity test wrote into the env test's storage ('cache_write_on_durable_storage') or saw its bound policy change ('checkout Git, root identity or conversion policy changed'), and the env test found blobs in its durable storage. The scope-workdir bind helper and the cache-root doctor test resolve storage the same way and take the lock. Co-authored-by: alfonso-aft <289616620+alfonso-aft@users.noreply.github.com> --- crates/aft/src/commands/configure.rs | 520 +++--------------- crates/aft/src/context.rs | 5 - crates/aft/src/root_cache_paths_tests.rs | 5 + crates/aft/src/subc/scope_workdir_tests.rs | 5 + .../aft/tests/integration/per_checkout_9.rs | 67 --- 5 files changed, 95 insertions(+), 507 deletions(-) diff --git a/crates/aft/src/commands/configure.rs b/crates/aft/src/commands/configure.rs index 4834b94907..29367d09b6 100644 --- a/crates/aft/src/commands/configure.rs +++ b/crates/aft/src/commands/configure.rs @@ -11493,36 +11493,6 @@ mod tests { .success()); } - fn write_legacy_semantic_fixture(path: &Path, fingerprint: &str, source: &[u8]) { - fn put_field(bytes: &mut Vec, value: &[u8]) { - bytes.extend_from_slice(&(value.len() as u32).to_le_bytes()); - bytes.extend_from_slice(value); - } - let mut bytes = vec![7]; - bytes.extend_from_slice(&2_u32.to_le_bytes()); - bytes.extend_from_slice(&1_u32.to_le_bytes()); - put_field(&mut bytes, fingerprint.as_bytes()); - bytes.extend_from_slice(&1_u32.to_le_bytes()); - put_field(&mut bytes, b"tracked.rs"); - bytes.extend_from_slice(&0_u64.to_le_bytes()); - bytes.extend_from_slice(&0_u32.to_le_bytes()); - bytes.extend_from_slice(&(source.len() as u64).to_le_bytes()); - bytes.extend_from_slice(blake3::hash(source).as_bytes()); - put_field(&mut bytes, b"tracked.rs"); - put_field(&mut bytes, b"tracked"); - put_field(&mut bytes, b""); - bytes.push(0); - bytes.extend_from_slice(&1_u32.to_le_bytes()); - bytes.extend_from_slice(&1_u32.to_le_bytes()); - bytes.push(0); - put_field(&mut bytes, b"fn tracked() {}"); - put_field(&mut bytes, b"tracked function"); - bytes.extend_from_slice(&1.0_f32.to_le_bytes()); - bytes.extend_from_slice(&0.0_f32.to_le_bytes()); - fs::create_dir_all(path.parent().unwrap()).unwrap(); - fs::write(path, bytes).unwrap(); - } - #[test] fn views_gate_off_does_not_create_view_storage() { let _git_env = crate::test_env::hermetic_git_env_guard(); @@ -11910,106 +11880,6 @@ mod tests { assert!(!ctx.configure_tail_has_work()); } - #[test] - fn busy_readiness_defers_revalidation_without_a_cold_rebuild() { - 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 project = tempfile::tempdir().unwrap(); - let storage = tempfile::tempdir().unwrap(); - init_git_fixture(project.path()); - let ctx = test_context(); - let params = json!({ - "project_root": project.path(), "storage_dir": storage.path(), "harness": "opencode", - "config": [user_tier(json!({ "views": { "enabled": true }, - "indexes": { "trigram": false, "semantic": false, "callgraph": true } }))] - }); - let root = project.path().canonicalize().unwrap(); - let scope = crate::path_identity::project_scope_key(&root); - crate::views::assembly::publish_checkout(&crate::views::assembly::AssemblyRequest { - storage: storage.path().to_owned(), - family: crate::search_index::artifact_cache_key(&root), - scope: scope.clone(), - desired_head: crate::views::assembly::head_tree_fingerprint( - &crate::alias::head_tree_entries(&root).unwrap(), - ), - project_root: root.clone(), - changed_paths: BTreeSet::new(), - semantic_keys: Default::default(), - require_semantic: false, - allow_blob_put: true, - callgraph: true, - }) - .unwrap(); - let view = crate::views::ViewStore::open(storage.path(), &scope).unwrap(); - let initial = view.current_generation().unwrap(); - let derived = view.derived_path(initial.as_deref().unwrap()).unwrap(); - crate::views::wait_for_derived_checkpoint_for_test(view.view_dir()); - let writer = crate::db::file_identity::IdentityConnection::open( - &derived, - "busy_configure_readiness_test", - ) - .unwrap(); - writer - .execute_batch("PRAGMA journal_mode=DELETE; BEGIN EXCLUSIVE;") - .unwrap(); - - assert!( - handle_configure_for_test( - &configure_request_with_session(params.clone(), "while-locked"), - &ctx - ) - .success - ); - super::drain_deferred_configure_maintenance(&ctx); - let pending = ctx.view_runtime_snapshot().unwrap(); - assert!(!pending.pending_paths.is_empty()); - assert!(pending - .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::Ready(_) - )); - let health = ctx.view_health_snapshot().unwrap(); - assert_eq!( - health.callgraph_readiness_deferred, - Some(crate::views::read::CALLGRAPH_READINESS_BUSY) - ); - let failure = health.publication_failure.expect("publication will retry"); - assert_eq!(failure.reason, "publication_failed"); - assert!(failure.next_retry_ms.is_some(), "{failure:?}"); - assert_eq!(view.current_generation().unwrap(), initial); - assert_eq!( - crate::views::assembly::cold_build_counts(view.view_dir()), - Default::default() - ); - writer.execute_batch("ROLLBACK").unwrap(); - drop(writer); - - // Drive the retry without waiting for its one-second backoff. The - // invariant here is reuse of the healthy graph, not timer scheduling. - ctx.view_publication_retry().lock().reset(&root); - crate::executor::view_publication::schedule(&ctx, BTreeSet::new(), true).unwrap(); - - assert!( - handle_configure_for_test( - &configure_request_with_session(params, "after-unlock"), - &ctx - ) - .success - ); - super::drain_deferred_configure_maintenance(&ctx); - assert_eq!(view.current_generation().unwrap(), initial); - assert!(matches!( - ctx.callgraph_store_for_ops(), - CallgraphStoreAccess::Ready(_) - )); - } - /// `lib.rs` of the on-demand call-graph fixtures: one caller, one callee /// and one function nothing calls, so callers, impact and dead code all /// have something to report. @@ -12020,8 +11890,9 @@ mod tests { main: PathBuf, linked: PathBuf, storage: PathBuf, - /// `None` when the main checkout was never bound in this process. - owner: Option>, + /// The main checkout's context, kept alive for the fixture's + /// lifetime; `None` when it was never bound in this process. + _owner: Option>, borrower: Arc, } @@ -12094,7 +11965,7 @@ mod tests { main, linked, storage, - owner, + _owner: owner, borrower, } } @@ -12151,17 +12022,6 @@ mod tests { .join(crate::path_identity::project_scope_key(root)) } - /// Derived call-graph files of a view: databases and ownership references. - fn lazy_derived_files(view_dir: &Path) -> Vec { - let mut names = fs::read_dir(view_dir) - .unwrap() - .filter_map(|entry| entry.ok()?.file_name().into_string().ok()) - .filter(|name| name.starts_with("derived-")) - .collect::>(); - names.sort(); - names - } - fn lazy_ready( access: CallgraphStoreAccess, ) -> Arc { @@ -12275,99 +12135,6 @@ mod tests { assert_eq!(outcome.processed, 1); } - fn blob_removal_generation(storage: &Path, root: &Path) -> Option { - crate::views::ViewStore::open(storage, &crate::path_identity::project_scope_key(root)) - .unwrap() - .current_generation() - .unwrap() - } - - #[test] - fn configure_busy_callgraph_readiness_revalidates_after_unlock() { - 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 temp = tempfile::tempdir().unwrap(); - let root = temp.path().join("main"); - let storage = temp.path().join("storage"); - init_git_fixture(&root); - lazy_commit(&root, LAZY_LIB, "lib"); - let root = root.canonicalize().unwrap(); - let first = lazy_bind(&root, &storage, "before-lock"); - let view_dir = lazy_view_dir(&storage, &root); - crate::views::wait_for_derived_checkpoint_for_test(&view_dir); - let generation = blob_removal_generation(&storage, &root).unwrap(); - drop(first); - let database = crate::views::resolve_derived_path(&view_dir, &generation).unwrap(); - let writer = crate::db::file_identity::IdentityConnection::open( - &database, - "busy configure readiness fixture", - ) - .unwrap(); - writer - .execute( - "UPDATE meta SET v = 'stale-build-output' WHERE k = 'fingerprint'", - [], - ) - .unwrap(); - let journal: String = writer - .query_row("PRAGMA journal_mode=DELETE", [], |row| row.get(0)) - .unwrap(); - assert_eq!(journal, "delete"); - writer.execute_batch("BEGIN EXCLUSIVE;").unwrap(); - let probe = crate::callgraph_store::manifest_view_database_ready( - &view_dir, - &generation, - Instant::now() + crate::db::TOOL_RETRY_BUSY_WAIT, - ) - .unwrap_err(); - assert!(probe.is_transient_lock_contention(), "{probe}"); - - let owner = test_context(); - let params = json!({ - "project_root": root, "storage_dir": storage, "harness": "opencode", - "config": [user_tier(json!({"views":{"enabled":true}, - "indexes":{"trigram":false,"semantic":false,"callgraph":true}}))] - }); - assert!( - handle_configure_for_test( - &configure_request_with_session(params, "after-lock"), - &owner, - ) - .success - ); - let (gate, probe_done, release) = - super::gate_configure_tail_stage_for_test(root.clone(), "view_payload_check"); - std::thread::scope(|scope| { - let tail = scope.spawn(|| super::drain_deferred_configure_maintenance(&owner)); - probe_done.recv_timeout(Duration::from_secs(30)).unwrap(); - writer - .execute_batch("ROLLBACK; PRAGMA journal_mode=WAL;") - .unwrap(); - drop(writer); - drop(gate); - release.send(()).unwrap(); - tail.join().unwrap(); - }); - crate::views::wait_for_derived_checkpoint_for_test(&view_dir); - let store = lazy_ready(owner.callgraph_store_for_ops()); - assert_eq!(store.reader_kind(), "view"); - assert_ne!( - blob_removal_generation(&storage, &root).as_deref(), - Some(generation.as_str()) - ); - assert!(owner - .view_runtime_snapshot() - .unwrap() - .pending_paths - .is_empty()); - assert!(format!( - "{:?}", - store.callers_of(Path::new("lib.rs"), "target", 3).unwrap() - ) - .contains("caller")); - } - /// Measures, on a checkout of about AFT's size with both payload planes /// populated, what the payload checks cost: the bind-time check of a /// healthy store, and a one-file edit's publication split into the @@ -12587,100 +12354,6 @@ mod tests { .is_ok()); } - #[test] - fn linked_worktree_that_never_queries_never_builds_a_call_graph() { - 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(); - let first = view.current_generation().unwrap(); - lazy_edit_through_watcher( - &fixture, - "pub fn caller() { target(); }\npub fn target() {}\nfn unused() { caller(); }\n", - ); - let mut state = fixture - .borrower - .watcher_drain_slice() - .lock() - .take() - .unwrap(); - state.view_publication_due = Some(Instant::now()); - crate::runtime_drain::publish_view_if_quiet(&fixture.borrower, &mut state); - assert!(state.view_publication_due.is_none()); - let edited = view.current_generation().unwrap(); - assert!(edited.is_some() && edited != first, "the edit is published"); - // Rebinding the unchanged worktree neither rebuilds nor republishes. - let rebound = handle_configure_for_test( - &configure_request_with_session( - json!({ - "project_root": fixture.linked, "storage_dir": fixture.storage, - "harness": "opencode", - "config": [user_tier(json!({ "views": { "enabled": true }, - "indexes": { "trigram": false, "semantic": false, "callgraph": true } }))] - }), - "lazy-linked-again", - ), - &fixture.borrower, - ); - assert!(rebound.success, "{}", rebound.data); - super::drain_deferred_configure_maintenance(&fixture.borrower); - assert_eq!(view.current_generation().unwrap(), edited); - - assert_eq!(lazy_derived_files(&view_dir), Vec::::new()); - assert_eq!( - fixture.borrower.lazy_callgraph_builds_scheduled_for_test(), - 0 - ); - // Sweeping removes the superseded generation's manifest even though - // this worktree has no derived call-graph files to delete. - view.sweep_generations().unwrap(); - assert!(!view - .manifest_path(first.as_deref().unwrap()) - .unwrap() - .exists()); - assert_eq!(lazy_derived_files(&view_dir), Vec::::new()); - } - - #[test] - fn main_checkout_still_builds_its_call_graph_at_bind() { - 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(false); - let view_dir = lazy_view_dir(&fixture.storage, &fixture.main); - let generation = crate::views::ViewStore::open( - &fixture.storage, - &crate::path_identity::project_scope_key(&fixture.main), - ) - .unwrap() - .current_generation() - .unwrap() - .unwrap(); - assert!( - crate::views::read::derived_built(&view_dir, &generation), - "the main checkout's graph is built at bind, before any query: {:?}", - lazy_derived_files(&view_dir) - ); - 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()); - // 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] fn concurrent_first_queries_start_one_call_graph_build() { let _artifact_guard = artifact_owner_test_lock(); @@ -12794,40 +12467,6 @@ mod tests { .is_ok()); } - /// The current generation of `root`'s view and whether it has a derived - /// call-graph database. - fn lazy_current_generation(storage: &Path, root: &Path) -> (String, bool) { - let view_dir = lazy_view_dir(storage, root); - let generation = - crate::views::ViewStore::open(storage, &crate::path_identity::project_scope_key(root)) - .unwrap() - .current_generation() - .unwrap() - .expect("published view"); - let built = crate::views::read::derived_built(&view_dir, &generation); - (generation, built) - } - - #[test] - fn linked_worktree_defers_and_builds_on_first_query_when_main_is_not_bound() { - 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"); - // Main is neither bound in this process nor published, so its graph - // cannot answer; the worktree still defers until a query needs it. - let fixture = lazy_worktree_with(false, false, |_, _| {}); - assert!(fixture.owner.is_none()); - let (_, built) = lazy_current_generation(&fixture.storage, &fixture.linked); - assert!(!built); - assert_eq!( - lazy_derived_files(&lazy_view_dir(&fixture.storage, &fixture.linked)), - Vec::::new() - ); - let store = lazy_query_builds_own_graph(&fixture); - let (_cold_storage, cold) = lazy_cold_store(&fixture.linked); - lazy_assert_same_graph(&store, &cold); - } - #[test] fn build_key_memo_does_not_bypass_first_configure_identity_bind() { let _git_env = crate::test_env::hermetic_git_env_guard(); @@ -13485,75 +13124,6 @@ mod tests { ); } - #[test] - fn legacy_semantic_import_runs_once_and_records_its_outcome() { - 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(); - let storage = tempfile::tempdir().unwrap(); - init_git_fixture(project.path()); - let canonical_root = std::fs::canonicalize(project.path()).unwrap(); - let family = crate::search_index::artifact_cache_key(&canonical_root); - let scope = crate::path_identity::project_scope_key(&canonical_root); - let fingerprint = - SemanticIndexFingerprint::for_config_dimension(&Config::default().semantic, 2) - .as_string(); - let semantic_path = storage - .path() - .join("semantic") - .join(&family) - .join("semantic.bin"); - let source = fs::read(canonical_root.join("tracked.rs")).unwrap(); - write_legacy_semantic_fixture(&semantic_path, &fingerprint, &source); - let ctx = AppContext::new( - Box::new(TreeSitterProvider::new()), - Config { - storage_dir: Some(storage.path().to_path_buf()), - ..Config::default() - }, - ); - let params = json!({ - "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": true } - }))] - }); - - let response = - handle_configure_for_test(&configure_request_with_params(params.clone()), &ctx); - assert!(response.success, "{}", response.data); - super::drain_deferred_configure_maintenance(&ctx); - let status = - crate::path_status::PathStatusStore::open(&storage.path().join("views").join(&scope)) - .unwrap(); - let first = status - .maintenance_outcome("legacy_semantic_import") - .unwrap() - .expect("import outcome"); - assert!(first.0.contains("Imported")); - fs::write(&semantic_path, [99]).unwrap(); - - let response = handle_configure_for_test(&configure_request_with_params(params), &ctx); - assert!(response.success, "{}", response.data); - super::drain_deferred_configure_maintenance(&ctx); - let status = - crate::path_status::PathStatusStore::open(&storage.path().join("views").join(&scope)) - .unwrap(); - assert_eq!( - status - .maintenance_outcome("legacy_semantic_import") - .unwrap() - .expect("persisted import outcome"), - first - ); - } - #[test] fn configure_without_storage_dir_defaults_to_shared_storage_root() { let _env_guard = home_env_mutex(); @@ -14432,6 +14002,13 @@ mod tests { } fn lexical_views_public_checkout(kind: OlderCheckoutKind) { + // Holds the process environment lock for the whole test. These views + // resolve their storage root and verify the Git conversion policy on + // every query, and both read process state other tests change while + // holding this lock: AFT_STORAGE_DIR overrides every configured + // storage root, and HOME decides the path `git var GIT_ATTR_GLOBAL` + // reports. + let _git_env = crate::test_env::hermetic_git_env_guard(); let temp = tempfile::tempdir().unwrap(); let (owner, root, storage) = older_checkout_beside_live_owner(temp.path(), kind); let root = std::fs::canonicalize(root).unwrap(); @@ -14526,6 +14103,13 @@ mod tests { #[test] fn lexical_views_public_empty_generation_and_live_only_addition_match_legacy() { + // Holds the process environment lock for the whole test. These views + // resolve their storage root and verify the Git conversion policy on + // every query, and both read process state other tests change while + // holding this lock: AFT_STORAGE_DIR overrides every configured + // storage root, and HOME decides the path `git var GIT_ATTR_GLOBAL` + // reports. + let _git_env = crate::test_env::hermetic_git_env_guard(); let temp = tempfile::tempdir().unwrap(); let root = temp.path().join("project"); let storage = temp.path().join("storage"); @@ -14544,6 +14128,13 @@ mod tests { #[test] fn lexical_views_public_late_legacy_completion_and_views_off_boundary() { + // Holds the process environment lock for the whole test. These views + // resolve their storage root and verify the Git conversion policy on + // every query, and both read process state other tests change while + // holding this lock: AFT_STORAGE_DIR overrides every configured + // storage root, and HOME decides the path `git var GIT_ATTR_GLOBAL` + // reports. + let _git_env = crate::test_env::hermetic_git_env_guard(); let temp = tempfile::tempdir().unwrap(); let root = temp.path().join("project"); init_git_fixture(&root); @@ -14589,6 +14180,13 @@ mod tests { #[test] fn lexical_views_public_acquisition_failure_never_uses_legacy_index() { + // Holds the process environment lock for the whole test. These views + // resolve their storage root and verify the Git conversion policy on + // every query, and both read process state other tests change while + // holding this lock: AFT_STORAGE_DIR overrides every configured + // storage root, and HOME decides the path `git var GIT_ATTR_GLOBAL` + // reports. + let _git_env = crate::test_env::hermetic_git_env_guard(); struct Refused; impl crate::views::trigram::LexicalSnapshotSource for Refused { fn acquire(&self) -> Result { @@ -14646,6 +14244,13 @@ mod tests { #[test] fn lexical_views_public_search_pins_one_snapshot_for_all_lexical_lanes() { + // Holds the process environment lock for the whole test. These views + // resolve their storage root and verify the Git conversion policy on + // every query, and both read process state other tests change while + // holding this lock: AFT_STORAGE_DIR overrides every configured + // storage root, and HOME decides the path `git var GIT_ATTR_GLOBAL` + // reports. + let _git_env = crate::test_env::hermetic_git_env_guard(); struct Counted { source: Arc, calls: std::sync::atomic::AtomicUsize, @@ -14698,6 +14303,13 @@ mod tests { #[test] fn lexical_views_public_kill_child() { + // Holds the process environment lock for the whole test. These views + // resolve their storage root and verify the Git conversion policy on + // every query, and both read process state other tests change while + // holding this lock: AFT_STORAGE_DIR overrides every configured + // storage root, and HOME decides the path `git var GIT_ATTR_GLOBAL` + // reports. + let _git_env = crate::test_env::hermetic_git_env_guard(); let Some(root) = std::env::var_os("AFT_LEXICAL_KILL_ROOT") else { return; }; @@ -14714,6 +14326,13 @@ mod tests { #[test] fn lexical_views_public_forced_kill_reopens_current_tree() { + // Holds the process environment lock for the whole test. These views + // resolve their storage root and verify the Git conversion policy on + // every query, and both read process state other tests change while + // holding this lock: AFT_STORAGE_DIR overrides every configured + // storage root, and HOME decides the path `git var GIT_ATTR_GLOBAL` + // reports. + let _git_env = crate::test_env::hermetic_git_env_guard(); use std::io::BufRead; use std::process::Stdio; let temp = tempfile::tempdir().unwrap(); @@ -14851,6 +14470,13 @@ mod tests { } fn callgraph_views_checkout(kind: OlderCheckoutKind) { + // Holds the process environment lock for the whole test. These views + // resolve their storage root and verify the Git conversion policy on + // every query, and both read process state other tests change while + // holding this lock: AFT_STORAGE_DIR overrides every configured + // storage root, and HOME decides the path `git var GIT_ATTR_GLOBAL` + // reports. + let _git_env = crate::test_env::hermetic_git_env_guard(); let temp = tempfile::tempdir().unwrap(); let live = temp.path().join("live"); init_git_fixture(&live); @@ -14939,6 +14565,13 @@ mod tests { #[test] fn callgraph_views_acquisition_failure_never_uses_the_legacy_store() { + // Holds the process environment lock for the whole test. These views + // resolve their storage root and verify the Git conversion policy on + // every query, and both read process state other tests change while + // holding this lock: AFT_STORAGE_DIR overrides every configured + // storage root, and HOME decides the path `git var GIT_ATTR_GLOBAL` + // reports. + let _git_env = crate::test_env::hermetic_git_env_guard(); struct Refused(std::sync::atomic::AtomicUsize); impl crate::views::callgraph::CallgraphReaderSource for Refused { fn acquire( @@ -15028,6 +14661,13 @@ mod tests { #[test] fn views_routed_root_publishes_no_v1_view_and_puts_no_legacy_blobs() { + // Holds the process environment lock for the whole test. These views + // resolve their storage root and verify the Git conversion policy on + // every query, and both read process state other tests change while + // holding this lock: AFT_STORAGE_DIR overrides every configured + // storage root, and HOME decides the path `git var GIT_ATTR_GLOBAL` + // reports. + let _git_env = crate::test_env::hermetic_git_env_guard(); let temp = tempfile::tempdir().unwrap(); let root = temp.path().join("project"); init_git_fixture(&root); @@ -15117,6 +14757,13 @@ mod tests { #[test] fn views_routed_symbol_prewarm_stores_then_reuses_v3_symbol_rows() { + // Holds the process environment lock for the whole test. These views + // resolve their storage root and verify the Git conversion policy on + // every query, and both read process state other tests change while + // holding this lock: AFT_STORAGE_DIR overrides every configured + // storage root, and HOME decides the path `git var GIT_ATTR_GLOBAL` + // reports. + let _git_env = crate::test_env::hermetic_git_env_guard(); let temp = tempfile::tempdir().unwrap(); let root = temp.path().join("project"); init_git_fixture(&root); @@ -24033,7 +23680,10 @@ mod tests { assert!(ctx.semantic_index_rx().lock().is_some()); super::drain_deferred_configure_maintenance(&ctx); assert!(ctx.watcher_runtime_active()); - assert!(ctx.view_runtime_snapshot().is_some()); + // The root's reads go through its v3 checkout views, so no v1 view + // runtime is opened for it. + assert!(ctx.views_route_root()); + assert!(ctx.view_runtime_snapshot().is_none()); assert!(ctx.callgraph_writer()); ctx.stop_watcher_runtime(); } diff --git a/crates/aft/src/context.rs b/crates/aft/src/context.rs index 595e4e52f5..cf7cb35988 100644 --- a/crates/aft/src/context.rs +++ b/crates/aft/src/context.rs @@ -10861,11 +10861,6 @@ impl AppContext { } } - #[cfg(test)] - pub(crate) fn lazy_callgraph_builds_scheduled_for_test(&self) -> usize { - self.lazy_callgraph.builds_scheduled.load(Ordering::Acquire) - } - /// A root actor or the standalone scope now publishes for this context. pub(crate) fn note_publication_scope(&self) { self.lazy_callgraph diff --git a/crates/aft/src/root_cache_paths_tests.rs b/crates/aft/src/root_cache_paths_tests.rs index 8f3203ac58..81c1d77d9e 100644 --- a/crates/aft/src/root_cache_paths_tests.rs +++ b/crates/aft/src/root_cache_paths_tests.rs @@ -656,6 +656,11 @@ fn cache_root_reader_floor_refusals_cover_external_artifacts() { #[test] fn cache_root_doctor_ignores_harness_redirects_and_keeps_user_base() { + // Cache-root preparation resolves the storage root through the process + // environment, where AFT_STORAGE_DIR overrides the configured root. A test + // that sets it holds this lock; reclaiming meanwhile would prepare and + // clean another test's live storage. + let _env = crate::test_env::process_env_lock(); let (_temp, storage, cache, _project, ctx) = fixture(true); fs::create_dir_all(cache.join("index")).unwrap(); fs::create_dir_all(storage.join("index")).unwrap(); diff --git a/crates/aft/src/subc/scope_workdir_tests.rs b/crates/aft/src/subc/scope_workdir_tests.rs index 3a16c39880..c794a51120 100644 --- a/crates/aft/src/subc/scope_workdir_tests.rs +++ b/crates/aft/src/subc/scope_workdir_tests.rs @@ -70,6 +70,11 @@ fn bind_observed( scope: Option, after_ack: impl FnOnce(&mut Option>), ) -> (Vec, Vec) { + // The bind resolves its storage root through the process environment, + // where AFT_STORAGE_DIR overrides the configured root. A test that sets + // it holds this lock; binding meanwhile would open that test's storage + // (and wait on its live databases) before acknowledging. + let _env = crate::test_env::process_env_lock(); let runtime = tokio::runtime::Builder::new_current_thread() .enable_all() .build() diff --git a/crates/aft/tests/integration/per_checkout_9.rs b/crates/aft/tests/integration/per_checkout_9.rs index 78847cc8b7..2b1bd6fd21 100644 --- a/crates/aft/tests/integration/per_checkout_9.rs +++ b/crates/aft/tests/integration/per_checkout_9.rs @@ -1634,73 +1634,6 @@ fn slow_discovery_never_delays_the_bind() { ); } -/// The standalone (stdin/stdout) runtime keeps answering requests while the -/// first views-on publication of a checkout runs: the publication detaches -/// from the request thread, as it does under the daemon, and still commits -/// while requests keep arriving. Before, a large checkout's first publication -/// ran inline on the request thread and every request waited minutes. -#[test] -fn standalone_view_publication_never_blocks_requests() { - 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, &child_files(0)); - let delay = Duration::from_secs(20); - let delay_ms = delay.as_millis().to_string(); - let mut aft = crate::helpers::AftProcess::spawn_with_env(&[( - "AFT_TEST_VIEW_PUBLICATION_DELAY_MS", - std::ffi::OsStr::new(&delay_ms), - )]); - 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, TRIGRAM_ONLY, None)), - }) - .to_string(), - ); - assert_eq!(configured["success"], true, "{configured:#}"); - let started = Instant::now(); - let grep_request = json!({"id": "grep", "command": "grep", "pattern": COMMON}).to_string(); - let mut slowest = Duration::ZERO; - // Requests during the publication: each must be answered promptly. - while started.elapsed() < Duration::from_secs(6) { - let sent = Instant::now(); - let answer = aft.send_with_timeout(&grep_request, delay / 2); - slowest = slowest.max(sent.elapsed()); - assert_eq!(answer["success"], true, "{answer:#}"); - thread::sleep(Duration::from_millis(100)); - } - eprintln!( - "standalone requests during a {} s view publication: slowest {} ms", - delay.as_secs(), - slowest.as_millis() - ); - assert!( - slowest < Duration::from_secs(5), - "a request waited {} ms for the view publication", - slowest.as_millis() - ); - // The detached publication still commits while requests keep arriving. - let view = aft::views::ViewStore::open(&storage, &aft::path_identity::project_scope_key(&root)) - .unwrap(); - let deadline = Instant::now() + delay + DEADLINE; - while view.current_generation().unwrap().is_none() { - assert!( - Instant::now() < deadline, - "the view publication never committed" - ); - let answer = aft.send(&grep_request); - assert_eq!(answer["success"], true, "{answer:#}"); - thread::sleep(Duration::from_millis(200)); - } - assert!(aft.shutdown().success()); -} - /// Pi uses this stdin/stdout loop, not a daemon actor. Opening the view's /// stores and reading its manifest is startup work too, even when publication /// and the semantic worker are already detached. From 06e2465509d29b7bacf85e5703a081ab828661c7 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Tue, 27 Oct 2026 18:41:30 +0100 Subject: [PATCH 095/100] mason: start the bind-latency clock after taking the environment lock The bind helper waits for the process environment lock (see the previous commit); the held-lookup test measured that wait as bind latency when the AFT_STORAGE_DIR test held it (1.1-1.6 s). It now takes the lock before starting its clock. Co-authored-by: alfonso-aft <289616620+alfonso-aft@users.noreply.github.com> --- crates/aft/src/subc/scope_workdir_tests.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crates/aft/src/subc/scope_workdir_tests.rs b/crates/aft/src/subc/scope_workdir_tests.rs index c794a51120..b70336ecb9 100644 --- a/crates/aft/src/subc/scope_workdir_tests.rs +++ b/crates/aft/src/subc/scope_workdir_tests.rs @@ -274,6 +274,9 @@ fn bind_observed( #[test] fn scope_workdir_held_lookup_does_not_delay_bind_ack() { let _serial = serial(); + // Taken before the clock starts: waiting for another test to release the + // environment is not bind latency. `bind_observed` takes it again. + let _env = crate::test_env::process_env_lock(); let dir = tempfile::tempdir().unwrap(); let root = dir.path().canonicalize().unwrap(); let workdir = root.join("held-workdir"); From ef1976c48835c86cdd6850c01034582eb948ef34 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Tue, 27 Oct 2026 20:34:14 +0100 Subject: [PATCH 096/100] mason: expect the caller's source line in a fresh view's callback-reference impact Impact shows the caller's source line whenever the file still matches the store, for every relation, as the views-off render does (callgraph_regression_tests). The expectation added on main omitted it only because, on main, a v1 view failed that freshness check: its manifest records each file's call-graph blob key (derived from the content hash, language and extractor), and content_matches_store compared that key with the raw content hash, so every view answer was stale and lost its source line. Step G's view_blob_key comparison fixed that, so the view now renders like views off. Co-authored-by: alfonso-aft <289616620+alfonso-aft@users.noreply.github.com> --- crates/aft/src/views/cold_fallback_tests.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/crates/aft/src/views/cold_fallback_tests.rs b/crates/aft/src/views/cold_fallback_tests.rs index 1b16902ed5..7a50c37650 100644 --- a/crates/aft/src/views/cold_fallback_tests.rs +++ b/crates/aft/src/views/cold_fallback_tests.rs @@ -763,7 +763,12 @@ fn callgraph_previous_producer_reextracts_callbacks_and_inline_test_status() { impact_result(&store, Path::new("src/lib.rs"), "transform", 5, true).unwrap(), ) .unwrap(), - "1 affected site (1 by callback reference) · 1 file\nsrc/lib.rs:3\n ↳ checks (callback reference)", + // Impact shows the caller's source line whenever the file still + // matches the store, as the views-off render does + // (callgraph_regression_tests). A v1 view used to fail that check + // because it records each file's call-graph blob key, not its + // content hash, which hid the row. + "1 affected site (1 by callback reference) · 1 file\nsrc/lib.rs:3\n ↳ checks (callback reference)\n fn checks() { spawn(transform); }", ), ( "callers", From ea396b4f7f61efeae7b3b472209bbff042bb843b Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Tue, 27 Oct 2026 20:34:16 +0100 Subject: [PATCH 097/100] mason: release routed views on idle eviction and port the eviction reload test Idle eviction dropped the legacy indexes and the semantic lane but left a views-routed root's lexical and call-graph views loaded, with their installed snapshots and the call graph's open reader. It now releases them; the next query binds a fresh view that verifies the checkout from its published generation. New test views_routed_idle_eviction_releases_views_ and_the_next_query_rebinds (red without the release: the views stayed ready after eviction). views_on_search_after_idle_eviction_reports_the_lane_reloading waited for the legacy trigram index, which a views-on root never builds. Its helper now waits for grep to answer from a ready lexical lane: the v3 lexical view with views on, the legacy index with views off. The test's point stands: after eviction the search reports the semantic lane building with the lexical fallback, never ready. Co-authored-by: alfonso-aft <289616620+alfonso-aft@users.noreply.github.com> --- crates/aft/src/commands/configure.rs | 60 +++++++++++++++++++ crates/aft/src/context.rs | 6 ++ .../per_checkout_semantic_runtime.rs | 50 +++++++++------- 3 files changed, 94 insertions(+), 22 deletions(-) diff --git a/crates/aft/src/commands/configure.rs b/crates/aft/src/commands/configure.rs index 31b311a672..359b2757be 100644 --- a/crates/aft/src/commands/configure.rs +++ b/crates/aft/src/commands/configure.rs @@ -14755,6 +14755,66 @@ mod tests { assert!(!off.views_route_root()); } + /// Idle eviction releases a routed root's lexical and call-graph views, + /// and the next query binds them again and answers. + #[test] + fn views_routed_idle_eviction_releases_views_and_the_next_query_rebinds() { + // Holds the process environment lock: these views resolve storage and + // the Git conversion policy through the process environment. + let _git_env = crate::test_env::hermetic_git_env_guard(); + 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"); + let ctx = test_context(); + assert!( + handle_configure_for_test(&configure_callgraph_views(&root, &storage, true), &ctx) + .success + ); + let grep = || crate::commands::grep::handle_grep(&grep_request("target"), &ctx); + let callers = || { + crate::commands::callers::handle_callers( + &RawRequest { + id: "evicted-callers".into(), + command: "callers".into(), + lsp_hints: None, + session_id: None, + params: json!({"file": root.join("a.ts"), "symbol": "target", "depth": 1}), + }, + &ctx, + ) + }; + assert!(grep().success); + assert!(callers().success); + assert!(ctx.lexical_view_is_ready() && ctx.callgraph_view_is_ready()); + + assert!(ctx.evict_idle_artifacts()); + assert!(!ctx.lexical_view_is_ready() && !ctx.callgraph_view_is_ready()); + let before = ( + ctx.lexical_view_route_counts()[0], + ctx.callgraph_view_route_count(), + ); + let answer = grep(); + assert!(answer.success, "{:?}", answer.data); + assert_eq!(answer.data["index_status"], "Ready", "{:?}", answer.data); + let answer = callers(); + assert!(answer.success, "{:?}", answer.data); + assert_eq!( + ( + ctx.lexical_view_route_counts()[0], + ctx.callgraph_view_route_count() + ), + (before.0 + 1, before.1 + 1) + ); + assert!(ctx.lexical_view_is_ready() && ctx.callgraph_view_is_ready()); + } + #[test] fn views_routed_symbol_prewarm_stores_then_reuses_v3_symbol_rows() { // Holds the process environment lock for the whole test. These views diff --git a/crates/aft/src/context.rs b/crates/aft/src/context.rs index 06cea90691..344f636442 100644 --- a/crates/aft/src/context.rs +++ b/crates/aft/src/context.rs @@ -14312,6 +14312,12 @@ impl AppContext { .write() .unwrap_or_else(std::sync::PoisonError::into_inner) .take(); + // A views-routed root's lexical and call-graph views hold their + // installed snapshots and the call graph's open reader. The next query + // binds a fresh view, which verifies the checkout from its published + // generation. + self.lexical_view.lock().take(); + self.callgraph_view.lock().take(); self.borrowed_index_cache.lock().clear(); self.clear_semantic_refresh_worker(); *self.semantic_embedding_model.lock() = None; diff --git a/crates/aft/tests/integration/per_checkout_semantic_runtime.rs b/crates/aft/tests/integration/per_checkout_semantic_runtime.rs index 385c832d67..3041e373bb 100644 --- a/crates/aft/tests/integration/per_checkout_semantic_runtime.rs +++ b/crates/aft/tests/integration/per_checkout_semantic_runtime.rs @@ -818,37 +818,43 @@ fn evict_until_lane_released(ctx: &AppContext) { thread::sleep(Duration::from_millis(20)); } } -/// Idle eviction drops the trigram index too, and both lanes reload by -/// design. Reloads the trigram index through a grep, which reloads only that -/// index, and waits until it is ready, so the next search has a ready lexical -/// lane however long the reload takes on this machine while the semantic lane -/// is still released. +/// Idle eviction drops the lexical lane too, and both lanes reload by design. +/// Reloads the lexical lane through a grep, which reloads only that lane, and +/// waits until grep answers from a ready index, so the next search has a +/// ready lexical lane however long the reload takes on this machine while the +/// semantic lane is still released. With views on, that lane is the +/// checkout's v3 lexical view, which the grep binds again; with views off it +/// is the legacy trigram index. fn reload_trigram_after_eviction(ctx: &AppContext) { - let grep = aft::commands::grep::handle_grep( - &request(json!({ - "id": "views-semantic-grep", - "command": "grep", - "pattern": "parse_config_file", - "max_results": 1 - })), - ctx, - ); - assert!(grep.success, "grep failed: {grep:?}"); let deadline = Instant::now() + DEADLINE; loop { + let grep = aft::commands::grep::handle_grep( + &request(json!({ + "id": "views-semantic-grep", + "command": "grep", + "pattern": "parse_config_file", + "max_results": 1 + })), + ctx, + ); + assert!(grep.success, "grep failed: {grep:?}"); aft::runtime_drain::drain_search_index_events(ctx); - let ready = ctx - .search_index() - .read() - .unwrap() - .as_ref() - .is_some_and(|index| index.ready); + let ready = if ctx.config().views.enabled { + grep.data["index_status"] == "Ready" + } else { + ctx.search_index() + .read() + .unwrap() + .as_ref() + .is_some_and(|index| index.ready) + }; if ready { return; } assert!( Instant::now() < deadline, - "the trigram index never reloaded after eviction" + "the lexical lane never reloaded after eviction: {:?}", + grep.data ); thread::sleep(Duration::from_millis(20)); } From d0f7d32e5c1e2022a73ab722e7f4a2fe5ee89d20 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Tue, 27 Oct 2026 23:28:07 +0100 Subject: [PATCH 098/100] Fill the read marker's nonce in the retention test main added #1060 added a retention test that builds ReadMarkerMetadata without the nonce field this batch introduced for base-reference markers; ordinary readers leave it out. Co-authored-by: alfonso-aft <289616620+alfonso-aft@users.noreply.github.com> --- crates/aft/src/storage_retention.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/aft/src/storage_retention.rs b/crates/aft/src/storage_retention.rs index 606450c61a..0d25e394a8 100644 --- a/crates/aft/src/storage_retention.rs +++ b/crates/aft/src/storage_retention.rs @@ -6138,6 +6138,7 @@ mod storage_retention_tests { pid: 4242, hostname: "other-host".into(), created_at_ms: 1_761_300_000_000, + nonce: None, }) .unwrap(), ) From 0b84ae44a7b294966e12fb200d74912fd648f788 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Wed, 28 Oct 2026 03:12:18 +0100 Subject: [PATCH 099/100] mason: keep a views-on query's filesystem deadline for its reads, not the view bind it waits on A request's filesystem deadline (grep: 10 s) starts when the request arrives. With views on, the first lexical query binds the checkout's view synchronously (verified walk, base bootstrap, install), and other queries wait behind that bind on the view's mutexes. None of that work checks the deadline, so on a loaded Windows runner the bind outlasted it and the query's first bounded read afterwards (the search root's canonicalization) was refused with read_deadline_exceeded "filesystem deadline elapsed before this call started", before anything was read. A real agent's first grep after configure on a slow machine got the same refusal. bounded_io::excluding_wait runs such work with the request's deadline held off (bounded calls inside keep only their per-access cap and still record failures and permission observations on the request), then moves the request's deadline later by the time the work took. lexical_view_snapshot wraps view construction, the pending watcher gap and the bind in it. Tests: excluding_wait_leaves_the_request_deadline_for_the_reads_after_it (with a control showing the same wait outside it refuses the read), and lexical_views_grep_after_a_bind_longer_than_its_deadline_still_reads (a 1.5 s bind under a 1 s grep deadline answers with the same matches). Co-authored-by: alfonso-aft <289616620+alfonso-aft@users.noreply.github.com> --- crates/aft/src/bounded_io.rs | 35 +++++++++++++ crates/aft/src/bounded_io_tests.rs | 34 +++++++++++++ crates/aft/src/commands/configure.rs | 73 ++++++++++++++++++++++++++++ crates/aft/src/context.rs | 36 +++++++++----- 4 files changed, 165 insertions(+), 13 deletions(-) diff --git a/crates/aft/src/bounded_io.rs b/crates/aft/src/bounded_io.rs index 6b5e1dbc14..e8ef3ac5eb 100644 --- a/crates/aft/src/bounded_io.rs +++ b/crates/aft/src/bounded_io.rs @@ -377,6 +377,41 @@ pub(crate) fn with_budget(budget: Option, work: impl FnOnce() -> T) - pub(crate) fn enter(budget: Option) -> Scope { Scope(CURRENT.with(|current| current.replace(budget))) } +/// How long the request's filesystem deadline is held off while +/// [`excluding_wait`] runs. Each bounded call inside still has its own +/// per-access cap, so a stalled filesystem fails within that cap. +const WAIT_DEADLINE: Duration = Duration::from_secs(24 * 60 * 60); +/// Runs `wait`, work that waits for or brings up shared state the request then +/// reads (binding the checkout's lexical view, for example), without spending +/// the request's filesystem deadline on it. +/// +/// A request's deadline starts when the request arrives. A request that first +/// waits behind such work on a loaded machine could otherwise find its +/// deadline already spent when its first read starts, and fail with +/// `read_deadline_exceeded` before reading anything. Inside `wait`, bounded +/// calls keep the request's failure, permission and probe records but are +/// limited only by their per-access cap; afterwards the request's deadline +/// moves later by however long `wait` took, so its reads get the time the +/// request had left when the wait began. +pub(crate) fn excluding_wait(wait: impl FnOnce() -> T) -> T { + let Some(budget) = current() else { + return wait(); + }; + let started = Instant::now(); + let result = { + let mut held = budget; + held.deadline = started + WAIT_DEADLINE; + let _scope = enter(Some(held)); + wait() + }; + let waited = started.elapsed(); + CURRENT.with(|current| { + if let Some(budget) = current.borrow_mut().as_mut() { + budget.deadline += waited; + } + }); + result +} /// Keep the last `margin` of the current request's budget for its reply: /// move the filesystem deadline `margin` earlier for the rest of the /// command. A request calls this before its last filesystem work, so a diff --git a/crates/aft/src/bounded_io_tests.rs b/crates/aft/src/bounded_io_tests.rs index afa9cff53e..f28b610afe 100644 --- a/crates/aft/src/bounded_io_tests.rs +++ b/crates/aft/src/bounded_io_tests.rs @@ -1062,3 +1062,37 @@ fn probe_pool_workload() { let probes: usize = totals.iter().map(|row| row[3]).sum(); println!("PROBE_LOAD calls/directory/traversal/dispatch={totals:?} wall_s={wall:.6} cpu_s={cpu:.6} spawns={spawns} threads_per_s={:.3} cpu_us_per_probe={:.3}", spawns as f64 / wall, cpu * 1_000_000.0 / probes as f64); } + +/// A request that waits on shared work (such as a view's first bind) longer +/// than its whole filesystem deadline still reads afterwards: the wait does +/// not spend the deadline, and a bounded call inside the wait is not refused +/// by it either. The control shows the same wait outside `excluding_wait` +/// refuses the read before it starts. +#[test] +fn excluding_wait_leaves_the_request_deadline_for_the_reads_after_it() { + let dir = tempfile::tempdir().unwrap(); + let root = std::fs::canonicalize(dir.path()).unwrap(); + let stat = |root: &Path| run(root, None, |path| std::fs::metadata(path).map(|_| ())); + let budget = Duration::from_secs(1); + let wait = Duration::from_millis(1500); + + let control = with_budget(Some(Budget::after(budget)), || { + std::thread::sleep(wait); + stat(&root) + }) + .unwrap_err(); + assert!( + control.to_string().contains("read_deadline_exceeded"), + "{control}" + ); + + with_budget(Some(Budget::after(budget)), || { + let inside = excluding_wait(|| { + std::thread::sleep(wait); + stat(&root) + }); + assert!(inside.is_ok(), "{inside:?}"); + assert!(stat(&root).is_ok()); + assert!(current().unwrap().failure.lock().unwrap().is_none()); + }); +} diff --git a/crates/aft/src/commands/configure.rs b/crates/aft/src/commands/configure.rs index 359b2757be..a3fea54e42 100644 --- a/crates/aft/src/commands/configure.rs +++ b/crates/aft/src/commands/configure.rs @@ -14301,6 +14301,79 @@ mod tests { assert_eq!(ctx.lexical_view_route_counts(), [0, 0, 1]); } + /// A grep whose lexical view takes longer to bind than the request's whole + /// filesystem deadline still reads and answers: the bind is shared view + /// work, not the request's read. On a loaded Windows runner the first grep + /// after configure spent its deadline waiting for the bind and was refused + /// with `read_deadline_exceeded` before reading anything. + #[test] + fn lexical_views_grep_after_a_bind_longer_than_its_deadline_still_reads() { + // Holds the process environment lock for the whole test, as the other + // lexical views tests do. + let _git_env = crate::test_env::hermetic_git_env_guard(); + struct Slow { + source: Arc, + delay: Duration, + calls: std::sync::atomic::AtomicUsize, + } + impl crate::views::trigram::LexicalSnapshotSource for Slow { + fn acquire(&self) -> Result { + self.calls.fetch_add(1, Ordering::SeqCst); + std::thread::sleep(self.delay); + 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;\nneedle alpha\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 request = RawRequest { + id: "lexical-views-slow-bind".into(), + command: "grep".into(), + lsp_hints: None, + session_id: None, + params: json!({"pattern": "TOKEN|needle", "include": "**/*.ts"}), + }; + let expected = crate::commands::grep::handle_grep(&request, &ctx); + assert!(expected.success, "{:?}", expected.data); + assert!(!expected.data["matches"].as_array().unwrap().is_empty()); + let slow = Arc::new(Slow { + source: ctx.lexical_source_for_test(), + delay: Duration::from_millis(1500), + calls: Default::default(), + }); + ctx.install_lexical_source_for_test(slow.clone()); + // The request's filesystem deadline (1 s) is shorter than the bind. + let actual = crate::bounded_io::with_budget( + Some(crate::bounded_io::Budget::after(Duration::from_secs(1))), + || crate::commands::grep::handle_grep(&request, &ctx), + ); + assert_eq!(slow.calls.load(Ordering::SeqCst), 1); + assert!(actual.success, "{:?}", actual.data); + assert_eq!(actual.data["matches"], expected.data["matches"]); + assert_eq!(actual.data["text"], expected.data["text"]); + } + #[test] fn lexical_views_public_kill_child() { // Holds the process environment lock for the whole test. These views diff --git a/crates/aft/src/context.rs b/crates/aft/src/context.rs index 344f636442..f58f71dcb1 100644 --- a/crates/aft/src/context.rs +++ b/crates/aft/src/context.rs @@ -12126,21 +12126,31 @@ impl AppContext { 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)), + // Building the view, recording a watcher gap and binding the checkout + // can take long on a cold or loaded machine, and other queries may + // wait behind them. None of it is this request's read, so it does not + // spend the request's filesystem deadline. + let acquired = crate::bounded_io::excluding_wait(|| { + 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 Err(error), + } } - } - slot.as_ref().unwrap().clone() + slot.as_ref().unwrap().clone() + }; + self.record_checkout_pending_watcher_gap(); + Ok(view.snapshot()) + }); + let result = match acquired { + Ok(result) => result, + Err(error) => return Some(Err(error)), }; - 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 From 11842552f239898fa9768b921b620d989512914d Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Wed, 28 Oct 2026 03:49:40 +0100 Subject: [PATCH 100/100] mason: say what the held-off deadline inside excluding_wait is for Co-authored-by: alfonso-aft <289616620+alfonso-aft@users.noreply.github.com> --- crates/aft/src/bounded_io.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/crates/aft/src/bounded_io.rs b/crates/aft/src/bounded_io.rs index e8ef3ac5eb..a34034677a 100644 --- a/crates/aft/src/bounded_io.rs +++ b/crates/aft/src/bounded_io.rs @@ -377,9 +377,10 @@ pub(crate) fn with_budget(budget: Option, work: impl FnOnce() -> T) - pub(crate) fn enter(budget: Option) -> Scope { Scope(CURRENT.with(|current| current.replace(budget))) } -/// How long the request's filesystem deadline is held off while -/// [`excluding_wait`] runs. Each bounded call inside still has its own -/// per-access cap, so a stalled filesystem fails within that cap. +/// The deadline bounded calls see while [`excluding_wait`] runs, far enough +/// out that only their per-access cap limits them, so a stalled filesystem +/// still fails within that cap. Afterwards the request's own deadline moves +/// later by the time the wait actually took, not by this amount. const WAIT_DEADLINE: Duration = Duration::from_secs(24 * 60 * 60); /// Runs `wait`, work that waits for or brings up shared state the request then /// reads (binding the checkout's lexical view, for example), without spending