From 55f27248679e53e2961a7e280cf28f0e43debd3c Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Sun, 25 Oct 2026 17:41:29 +0100 Subject: [PATCH 01/19] mason: compose view generations and replay installation cuts Add depth-one v3 manifest projection, independent readiness patches and leased plane readers. Consume all publication outcomes without inheriting a losing job's verification. Keep real base/referrer publication fail-closed behind the ordered PublicationBinding handoff; existing v2 routing stays unchanged. Replace the raw-revision refusal regression with epoch refusal and sustained-write replay regressions: ordinary delivered edits must no longer starve installation. Legacy drivers without a replay cut still strictly rewalk. Add differential no-marker and guard fixtures. Narrow suites, doctest and native typecheck passed; full gates and compiled mutation controls remain pending at this checkpoint. Co-authored-by: alfonso-aft <289616620+alfonso-aft@users.noreply.github.com> --- .../src/executor/view_publication_tests.rs | 7 +- crates/aft/src/pins/live.rs | 31 + crates/aft/src/storage_retention.rs | 8 + crates/aft/src/views/callgraph.rs | 37 +- crates/aft/src/views/contracts.rs | 14 +- crates/aft/src/views/first_load.rs | 930 ++++++++++-- crates/aft/src/views/manifest_v2.rs | 202 ++- crates/aft/src/views/mod.rs | 2 +- .../aft/src/views/per_checkout_core_tests.rs | 29 +- crates/aft/src/views/read.rs | 12 +- crates/aft/src/views/readiness.rs | 69 +- crates/aft/src/views/segment_store.rs | 21 + crates/aft/src/views/semantic.rs | 192 ++- crates/aft/src/views/semantic_runtime.rs | 28 +- .../aft/src/views/semantic_runtime_tests.rs | 180 ++- crates/aft/src/views/snapshot.rs | 1257 ++++++++++++++++- crates/aft/src/views/trigram.rs | 39 +- .../tests/integration/branch_switch_test.rs | 6 +- .../integration/per_checkout_callgraph.rs | 24 +- .../tests/integration/per_checkout_trigram.rs | 17 +- 20 files changed, 2798 insertions(+), 307 deletions(-) diff --git a/crates/aft/src/executor/view_publication_tests.rs b/crates/aft/src/executor/view_publication_tests.rs index 0c6f89558f..5402a580d2 100644 --- a/crates/aft/src/executor/view_publication_tests.rs +++ b/crates/aft/src/executor/view_publication_tests.rs @@ -2088,7 +2088,7 @@ fn callgraph_producer_change_republishes_an_existing_generation() { let (_, built) = load(); let views = owner.view_store().unwrap(); let built_name = built.snapshot.generation().name().to_string(); - let mut previous = built.snapshot.generation().manifest().clone(); + 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(); @@ -2117,7 +2117,7 @@ fn callgraph_producer_change_republishes_an_existing_generation() { ); let generation = current.snapshot.generation(); assert_ne!(generation.name(), previous_name); - assert_eq!(generation.manifest().header().producers.callgraph, PRODUCER); + assert_eq!(generation.header().producers.callgraph, PRODUCER); assert_eq!( views.current_generation().unwrap().as_deref(), Some(generation.name()) @@ -2134,8 +2134,7 @@ fn callgraph_producer_change_republishes_an_existing_generation() { // 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.manifest(), &BlobReader(store.reader())) - .unwrap(); + crate::views::callgraph::materialize(&cold, generation, &BlobReader(store.reader())).unwrap(); crate::views::materialization::parity::assert_logical_parity( &views.derived_path(generation.name()).unwrap(), &cold, diff --git a/crates/aft/src/pins/live.rs b/crates/aft/src/pins/live.rs index 85b8754ead..1b713dca95 100644 --- a/crates/aft/src/pins/live.rs +++ b/crates/aft/src/pins/live.rs @@ -129,6 +129,17 @@ impl LivePin { Ok(()) } + /// A completed fill may leave its live pin only after durable publication + /// protects it. Unpublished override keys survive even if content equality + /// removed their corresponding live entry. + pub fn trim_published( + &mut self, + published: &BTreeSet, + keep: impl Fn(&str) -> bool, + ) -> Result<(), PinError> { + self.trim(|key| keep(key) || !published.contains(key)) + } + pub fn len(&self) -> usize { self.keys.len() } @@ -214,6 +225,26 @@ mod tests { .collect() } + #[test] + fn override_fill_stays_live_until_durable_publication_protects_it() { + let view = tempfile::tempdir().unwrap(); + let mut pin = LivePin::create_in(view.path(), "family".into(), "worker".into()).unwrap(); + let key = keys(1)[0]; + pin.protect(&[key]).unwrap(); + pin.trim_published(&BTreeSet::new(), |_| false).unwrap(); + assert_eq!( + pin.len(), + 1, + "content equality cannot retire an unpublished override key" + ); + assert!(fs::read_to_string(pin.keys_path()) + .unwrap() + .contains(&key.to_hex())); + pin.trim_published(&BTreeSet::from([key.to_hex()]), |_| false) + .unwrap(); + assert!(pin.is_empty()); + } + #[test] fn protecting_a_batch_writes_the_key_file_once_without_sync() { let view = tempfile::tempdir().unwrap(); diff --git a/crates/aft/src/storage_retention.rs b/crates/aft/src/storage_retention.rs index 0bce613f19..d5349d8cd3 100644 --- a/crates/aft/src/storage_retention.rs +++ b/crates/aft/src/storage_retention.rs @@ -4267,6 +4267,14 @@ fn orphan_first( /// One pass with an explicit time budget. Production always passes /// PASS_BUDGET; tests that check the entry bound pass a budget no runner can /// exhaust, so a slow machine cannot stop the pass by wall clock first. +#[cfg(test)] +pub(crate) fn run_pass_with_budget_for_test( + storage: &StoragePaths, + budget: Duration, +) -> SweepReport { + run_pass_with_budget(storage, &|| false, &Instant::now, budget) +} + fn run_pass_with_budget( storage: &StoragePaths, cancelled: &dyn Fn() -> bool, diff --git a/crates/aft/src/views/callgraph.rs b/crates/aft/src/views/callgraph.rs index b794bd2846..83b69b2fd8 100644 --- a/crates/aft/src/views/callgraph.rs +++ b/crates/aft/src/views/callgraph.rs @@ -23,9 +23,9 @@ use crate::callgraph_store::join::{CallgraphBlob, ManifestBlobReader, ManifestJo use crate::callgraph_store::ReadonlyCallGraphStore; use super::contracts::{PlaneAdapter, PlaneError, ViewAccess}; -use super::manifest_v2::{EntryV2, ManifestV2}; +use super::manifest_v2::EntryV2; use super::readiness::{plane_readiness, FillMap, PlaneReadiness, PlaneState}; -use super::snapshot::{DiskState, LiveEntry, OpenGeneration, Snapshot}; +use super::snapshot::{ComposedGeneration, DiskState, LiveEntry, Snapshot}; use super::{Manifest, ManifestEntry, RegularPlanes, RelPath}; /// Bump when extracted evidence, its encoding or interpretation changes. Version @@ -92,7 +92,9 @@ impl ManifestBlobReader for BlobReader { /// Project membership and compatible ready keys, not checkout paths/configuration. /// Missing/failed callgraph members refuse construction rather than falsely ready. -pub fn project_manifest(manifest: &ManifestV2) -> Result { +pub fn project_manifest( + manifest: &super::snapshot::ComposedGeneration, +) -> Result { if manifest.header().producers.callgraph != PRODUCER { return Err(error("incompatible callgraph producer")); } @@ -102,10 +104,9 @@ pub fn project_manifest(manifest: &ManifestV2) -> Result { EntryV2::Regular { mode, resolution_input, - planes, .. } => { - let key = match &planes.callgraph { + let key = match manifest.plane_readiness(path, FamilyPlane::Callgraph) { Some(PlaneState::Ready { key }) => Some(key.clone()), None => None, Some(_) => { @@ -138,7 +139,7 @@ pub fn project_manifest(manifest: &ManifestV2) -> Result { pub fn materialize( database: &Path, - manifest: &ManifestV2, + manifest: &super::snapshot::ComposedGeneration, reader: &impl ManifestBlobReader, ) -> Result<(), PlaneError> { let projected = project_manifest(manifest)?; @@ -149,7 +150,7 @@ pub fn materialize( /// Holding a reader keeps exactly its generation pinned, including after a swap. pub struct PinnedReader { pub store: Arc, - pub generation: Arc, + pub generation: Arc, } #[derive(Default)] pub struct CallgraphPlane { @@ -167,7 +168,7 @@ impl CallgraphPlane { .unwrap_or_else(std::sync::PoisonError::into_inner) .get(&( access.scope().to_string(), - snapshot.generation().name().to_string(), + snapshot.generation().resident_key(), )) .cloned(); match reader { @@ -175,7 +176,7 @@ impl CallgraphPlane { None => { // A refused keyless generation will never have a resident reader. // Preserve that refusal instead of making it look like a build. - queryable_manifest(snapshot.generation().manifest())?; + queryable_manifest(snapshot.generation())?; Err(error("pinned callgraph reader is not resident")) } } @@ -197,9 +198,9 @@ impl PlaneAdapter for CallgraphPlane { fn open_generation( &self, access: &ViewAccess, - generation: &Arc, + generation: &Arc, ) -> Result<(), PlaneError> { - queryable_manifest(generation.manifest())?; + 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 { @@ -231,7 +232,7 @@ impl PlaneAdapter for CallgraphPlane { .write() .unwrap_or_else(std::sync::PoisonError::into_inner) .insert( - (access.scope().into(), generation.name().into()), + (access.scope().into(), generation.resident_key()), Arc::new(PinnedReader { store: Arc::new(store.retain_generation(generation.clone())), generation: generation.clone(), @@ -252,15 +253,17 @@ impl PlaneAdapter for CallgraphPlane { .unwrap_or_else(std::sync::PoisonError::into_inner) .get(&( access.scope().to_owned(), - snapshot.generation().name().to_owned(), + snapshot.generation().resident_key(), )) - .is_some_and(|reader| Arc::ptr_eq(&reader.generation, snapshot.generation())) + .is_some_and(|reader| { + reader.generation.resident_key() == snapshot.generation().resident_key() + }) { return PlaneReadiness::Building; } let readiness = plane_readiness( FamilyPlane::Callgraph, - Some(snapshot.generation().manifest()), + Some(snapshot.generation()), snapshot, &FillMap::default(), &|path| self.applies_to(path), @@ -272,7 +275,9 @@ impl PlaneAdapter for CallgraphPlane { } } } -fn queryable_manifest(manifest: &ManifestV2) -> Result { +fn queryable_manifest( + manifest: &super::snapshot::ComposedGeneration, +) -> Result { let projected = project_manifest(manifest)?; // A disabled plane still has a valid empty derived database. It must not // answer navigation or reachability questions as though a graph was built. diff --git a/crates/aft/src/views/contracts.rs b/crates/aft/src/views/contracts.rs index e84ca2f733..cc25ea1ef0 100644 --- a/crates/aft/src/views/contracts.rs +++ b/crates/aft/src/views/contracts.rs @@ -29,7 +29,7 @@ use crate::blob_store::v2::FamilyPlane; use super::readiness::PlaneReadiness; use super::registry::{ReaderRegistration, ViewRegistration}; -use super::snapshot::{OpenGeneration, Snapshot}; +use super::snapshot::{ComposedGeneration, Snapshot}; use super::RelPath; /// Durability boundaries of view construction. V3-only boundaries are appended @@ -208,12 +208,22 @@ pub trait PlaneAdapter: Send + Sync { fn open_generation( &self, access: &ViewAccess, - generation: &Arc, + generation: &Arc, ) -> Result<(), PlaneError>; /// Drops resident state for a generation that no snapshot holds. fn release_generation(&self, access: &ViewAccess, generation: &str); + /// Called only after durable pointer publication for this generation succeeded. + /// Opening draft readers must not drop live protection for unpublished fills. + fn generation_published( + &self, + _access: &ViewAccess, + _generation: &Arc, + ) -> Result<(), PlaneError> { + Ok(()) + } + /// This plane's readiness for the view `access` names, as of `snapshot`. /// /// The view must be named because two views can hold the same generation diff --git a/crates/aft/src/views/first_load.rs b/crates/aft/src/views/first_load.rs index 1fd0d34193..3fffe5c1d1 100644 --- a/crates/aft/src/views/first_load.rs +++ b/crates/aft/src/views/first_load.rs @@ -8,7 +8,9 @@ use crate::blob_store::v2::FamilyPlane; use super::contracts::{LoaderStage, PlaneError, ViewAccess}; use super::manifest_v2::Producers; -use super::snapshot::{LiveEntry, OpenGeneration, Snapshot}; +use super::snapshot::{ + ComposedGeneration, InstallCut, LiveDelta, LiveEntry, PlaneReaders, Snapshot, +}; use super::RelPath; /// A complete strict content walk under the checkout's current ignore rules. @@ -18,11 +20,30 @@ use super::RelPath; pub struct ReconciledCheckout { pub revision: u64, pub entries: BTreeMap, + pub cut: Option, } /// The runtime owner supplies source reads and generation construction. Plane /// implementations attach their data here rather than writing shared runtime code. pub trait FirstLoadDriver: Send + Sync { + fn validate_cut( + &self, + access: &ViewAccess, + _snapshot: &Snapshot, + revision: u64, + ) -> Result<(), PlaneError> { + if self.revision(access) == revision { + Ok(()) + } else { + Err(PlaneError::loader( + LoaderStage::Installation, + CHECKOUT_CHANGED_BEFORE_INSTALL, + )) + } + } + fn installed_after(&self, fallback: &Snapshot) -> Snapshot { + fallback.clone() + } /// The loader calls this before choosing a seed. It must not write or block /// on model work; every selected manifest must match these producer identities. fn producers(&self, access: &ViewAccess) -> Producers; @@ -49,12 +70,12 @@ pub trait FirstLoadDriver: Send + Sync { access: &ViewAccess, snapshot: &Snapshot, seed_derived: bool, - ) -> Result, PlaneError>; + ) -> Result, PlaneError>; /// Called with a reconciled seed snapshot, then with the rebased own snapshot. - /// It must atomically install resident data and snapshot under the root lock, - /// rejecting an obsolete revision. It may block on that lock, never build, - /// publish, repair another checkout's state, or report success before data is installed. + /// It atomically installs resident data and snapshot under the root lock, + /// replaying delivered edits and refusing a changed epoch or bundle identity. + /// It may block on that lock, never build, publish or repair foreign state. fn install( &self, access: &ViewAccess, @@ -87,6 +108,258 @@ pub const CHECKOUT_CHANGED_BEFORE_INSTALL: &str = "checkout changed before snaps /// The reason a [`PublishFence`] gives when it refuses a publication. pub const SUPERSEDED_PUBLISH: &str = "checkout view owner superseded; publication refused"; +/// Operations supplied by the durable base/referrer owner. The defaults refuse +/// every operation: a legacy view registration does not protect a shared base. +/// The registry implementation must supply markers that pin the exact base, +/// intent rows that protect the pending worker-to-base reference, and flushes +/// that make every referenced artifact durable before publishing the pointer. +pub trait PublicationBinding { + fn cut(&mut self) -> Result<(), PlaneError> { + Err(binding_unavailable()) + } + fn acquire(&mut self) -> Result<(), PlaneError> { + Err(binding_unavailable()) + } + fn build_and_flush(&mut self) -> Result<(), PlaneError> { + Err(binding_unavailable()) + } + fn open_readers(&mut self) -> Result<(), PlaneError> { + Err(binding_unavailable()) + } + fn verify_held(&mut self) -> Result<(), PlaneError> { + Err(binding_unavailable()) + } + fn publish(&mut self) -> Result { + Err(binding_unavailable()) + } + /// Hold the ownership PublishFence across final marker verification and + /// pointer CAS, and return only after the pointer is synced. Keeping both + /// operations in one callback prevents a validation-to-CAS ownership race. + fn publish_verified(&mut self) -> Result { + Err(binding_unavailable()) + } + fn referrer_published(&mut self) -> Result<(), PlaneError> { + Err(binding_unavailable()) + } + fn install_replayed(&mut self) -> Result<(), PlaneError> { + Err(binding_unavailable()) + } + fn schedule_revalidated(&mut self) -> Result<(), PlaneError> { + Err(binding_unavailable()) + } + /// Under the registry barrier, remove any unfinished reference intent and + /// release the temporary base marker acquired by `acquire`. After a + /// successful install, the bundle retains separate query-residency markers. + fn terminal_cleanup(&mut self) -> Result<(), PlaneError> { + Err(binding_unavailable()) + } +} + +fn binding_unavailable() -> PlaneError { + PlaneError::loader( + LoaderStage::Driver, + "durable base publication binding is not wired", + ) +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum BindingOutcome { + Installed, + Revalidate, +} + +/// The sole shared-base publication/install ordering seam. `publish_verified` +/// holds the ownership fence across verification and the synced pointer CAS; `install_replayed` must hold the root write lock through journal +/// replay, epoch/base checks and the exact installed-bundle identity check. +pub fn publish_and_install( + binding: &mut dyn PublicationBinding, +) -> Result { + let result: Result = (|| { + binding.cut()?; + binding.acquire()?; + binding.build_and_flush()?; + binding.open_readers()?; + match binding.publish_verified()? { + super::manifest_v2::PublishV2::Published => {} + super::manifest_v2::PublishV2::Conflict { .. } + | super::manifest_v2::PublishV2::EquivalentWinner { .. } => { + return Ok(BindingOutcome::Revalidate); + } + } + binding.referrer_published()?; + binding.install_replayed()?; + Ok(BindingOutcome::Installed) + })(); + let cleanup = binding.terminal_cleanup(); + match (result, cleanup) { + (Err(error), Err(cleanup)) => Err(SiblingLoader::error(format!( + "publication failed: {}; terminal cleanup failed: {}", + error.reason, cleanup.reason + ))), + (Err(error), Ok(())) => Err(error), + (Ok(_), Err(error)) => Err(error), + (Ok(outcome), Ok(())) => { + if outcome == BindingOutcome::Revalidate { + binding.schedule_revalidated()?; + } + Ok(outcome) + } + } +} + +#[cfg(test)] +mod publication_binding_tests { + use super::super::manifest_v2::PublishV2; + use super::*; + + struct DefaultBinding; + impl PublicationBinding for DefaultBinding {} + + #[test] + fn unwired_publication_binding_refuses_every_step() { + let mut binding = DefaultBinding; + assert!(binding.cut().is_err()); + assert!(binding.acquire().is_err()); + assert!(binding.build_and_flush().is_err()); + assert!(binding.open_readers().is_err()); + assert!(binding.verify_held().is_err()); + assert!(binding.publish().is_err()); + assert!(binding.publish_verified().is_err()); + assert!(binding.referrer_published().is_err()); + assert!(binding.install_replayed().is_err()); + assert!(binding.schedule_revalidated().is_err()); + assert!(binding.terminal_cleanup().is_err()); + let error = publish_and_install(&mut binding).unwrap_err(); + assert!(error.reason.contains("terminal cleanup failed")); + } + + struct Binding { + steps: Vec<&'static str>, + fail: Option<&'static str>, + outcome: PublishV2, + } + impl Binding { + fn step(&mut self, name: &'static str) -> Result<(), PlaneError> { + self.steps.push(name); + if self.fail == Some(name) { + Err(SiblingLoader::error(name)) + } else { + Ok(()) + } + } + } + impl PublicationBinding for Binding { + fn cut(&mut self) -> Result<(), PlaneError> { + self.step("cut") + } + fn acquire(&mut self) -> Result<(), PlaneError> { + self.step("acquire") + } + fn build_and_flush(&mut self) -> Result<(), PlaneError> { + self.step("closure-flushed") + } + fn open_readers(&mut self) -> Result<(), PlaneError> { + self.step("readers-opened") + } + fn verify_held(&mut self) -> Result<(), PlaneError> { + self.step("verify-held") + } + fn publish(&mut self) -> Result { + self.step("pointer-synced")?; + Ok(self.outcome.clone()) + } + fn publish_verified(&mut self) -> Result { + self.verify_held()?; + self.publish() + } + fn referrer_published(&mut self) -> Result<(), PlaneError> { + self.step("referrer-published") + } + fn install_replayed(&mut self) -> Result<(), PlaneError> { + self.step("installed") + } + fn schedule_revalidated(&mut self) -> Result<(), PlaneError> { + self.step("revalidated-job") + } + fn terminal_cleanup(&mut self) -> Result<(), PlaneError> { + self.step("terminal-cleanup") + } + } + + #[test] + fn publication_binding_nine_steps_and_cleanup_on_each_failure() { + let expected = [ + "cut", + "acquire", + "closure-flushed", + "readers-opened", + "verify-held", + "pointer-synced", + "referrer-published", + "installed", + "terminal-cleanup", + ]; + let mut binding = Binding { + steps: Vec::new(), + fail: None, + outcome: PublishV2::Published, + }; + assert_eq!( + publish_and_install(&mut binding).unwrap(), + BindingOutcome::Installed + ); + assert_eq!(binding.steps, expected); + for (index, step) in expected.iter().enumerate() { + let mut binding = Binding { + steps: Vec::new(), + fail: Some(step), + outcome: PublishV2::Published, + }; + assert!(publish_and_install(&mut binding).is_err(), "{step}"); + let mut prefix = expected[..=index].to_vec(); + if *step != "terminal-cleanup" { + prefix.push("terminal-cleanup"); + } + assert_eq!(binding.steps, prefix, "{step}"); + } + } + + #[test] + fn losing_and_equivalent_jobs_cleanup_then_revalidate_never_install() { + for outcome in [ + PublishV2::Conflict { + current: Some("foreign".into()), + }, + PublishV2::EquivalentWinner { + current: "equivalent".into(), + }, + ] { + let mut binding = Binding { + steps: Vec::new(), + fail: None, + outcome, + }; + assert_eq!( + publish_and_install(&mut binding).unwrap(), + BindingOutcome::Revalidate + ); + assert_eq!( + binding.steps, + [ + "cut", + "acquire", + "closure-flushed", + "readers-opened", + "verify-held", + "pointer-synced", + "terminal-cleanup", + "revalidated-job" + ] + ); + } + } +} + /// Decides, atomically with the pointer publication itself, whether the /// driver may still publish its checkout's view. A runtime that can be /// replaced by another owner of the same view (a lane restarted with other @@ -126,7 +399,7 @@ impl SiblingLoader { PlaneError::loader(stage, reason) } - fn seed(&self, access: &ViewAccess) -> Result, PlaneError> { + fn seed(&self, access: &ViewAccess) -> Result, PlaneError> { let producers = self.driver.producers(access); if let ViewAccess::Reader { registration, @@ -159,7 +432,7 @@ impl SiblingLoader { if let Ok(Some(generation)) = super::read::open_foreign_generation(&reader, &member.scope, &producers) { - let exact_head = head.is_some() && generation.manifest().header().head_tree == head; + let exact_head = head.is_some() && generation.header().head_tree == head; candidates.push(( member.scope == owner.scope(), exact_head, @@ -173,7 +446,7 @@ impl SiblingLoader { if let Some((_, _, _, _, generation)) = candidates.into_iter().next() { return Ok(generation); } - Ok(Arc::new(OpenGeneration::new( + Ok(Arc::new(ComposedGeneration::new( "empty", super::manifest_v2::ManifestV2::new(super::manifest_v2::ManifestHeader { producers, @@ -188,7 +461,7 @@ impl SiblingLoader { fn reconciled( &self, access: &ViewAccess, - base: Arc, + base: Arc, ) -> Result<(Snapshot, u64), PlaneError> { for _ in 0..3 { // Read before the walk, so an ignore edit racing the walk shows up @@ -201,7 +474,7 @@ impl SiblingLoader { _ => None, }; let checkout = self.driver.reconcile(access)?; - if self.driver.revision(access) != checkout.revision { + if checkout.cut.is_none() && self.driver.revision(access) != checkout.revision { continue; } let mut delta = super::snapshot::LiveDelta::new(Arc::clone(&base)); @@ -209,7 +482,7 @@ impl SiblingLoader { delta.record_root_ignore_files(ignore_files); // Full walker membership replaces seed membership, including ignored // sibling paths and files deleted in this checkout. - for (path, _) in base.manifest().entries() { + for (path, _) in base.entries() { if !checkout.entries.contains_key(path) { delta.apply( path.clone(), @@ -220,7 +493,11 @@ impl SiblingLoader { for (path, entry) in checkout.entries { delta.apply(path, entry); } - return Ok((delta.snapshot(), checkout.revision)); + let mut snapshot = delta.snapshot(); + if let Some(cut) = checkout.cut { + snapshot = snapshot.with_install_cut(cut); + } + return Ok((snapshot, checkout.revision)); } Err(Self::stage_error( LoaderStage::Reconciliation, @@ -231,7 +508,11 @@ impl SiblingLoader { fn open_planes(&self, access: &ViewAccess, snapshot: &Snapshot) -> Vec { let mut pending = Vec::new(); for adapter in &self.adapters { - if let Err(error) = adapter.open_generation(access, snapshot.generation()) { + if let Err(error) = if snapshot.readers().is_none() { + adapter.open_generation(access, snapshot.generation()) + } else { + Ok(()) + } { pending.push(error); continue; } @@ -255,67 +536,57 @@ impl SiblingLoader { pub(crate) fn refresh( &self, access: &ViewAccess, - base: Arc, + base: Arc, ) -> Result { let (snapshot, revision) = self.reconciled(access, base)?; self.driver.install(access, &snapshot, revision)?; + let snapshot = self.driver.installed_after(&snapshot); self.fold(access, &snapshot, revision) } - /// Fold plane completions from a revision-fenced cut. Publication is not a - /// source edit: only a delivered edit during the build requires a new walk. + /// Fold plane completions from an installed cut. Ordinary delivered edits + /// replay during installation; watcher or membership gaps invalidate its epoch. pub(crate) fn fold( &self, access: &ViewAccess, snapshot: &Snapshot, revision: u64, ) -> Result { - if self.driver.revision(access) != revision - || snapshot.pending_intent().next().is_some() - || matches!(access, ViewAccess::Owner(owner) if super::intent::active(owner.root())) - { - return Err(Self::stage_error( - LoaderStage::Installation, - CHECKOUT_CHANGED_BEFORE_INSTALL, - )); - } + self.driver.validate_cut(access, snapshot, revision)?; let own = self .driver .build_own_generation(access, snapshot, DERIVED_CALLGRAPH_SEEDING)?; - own.manifest() - .ensure_producers(&self.driver.producers(access)) + own.ensure_producers(&self.driver.producers(access)) .map_err(|error| { Self::stage_error(LoaderStage::ProducerValidation, error.to_string()) })?; - let (snapshot, revision) = if self.driver.revision(access) == revision { + if snapshot.install_cut().is_none() { + // Legacy drivers do not supply a replayable cut. They must obtain + // new disk facts instead of treating a builder's claims as a walk. + let (fresh, revision) = self.reconciled(access, own)?; + self.driver.install(access, &fresh, revision)?; + return Ok(self.driver.installed_after(&fresh)); + } + let snapshot = { // Rebase the reconciled disk facts, not the new manifest's claims. // A builder can leave pending data without changing source bytes. - let members = snapshot.membership(); - let mut delta = super::snapshot::LiveDelta::new(Arc::clone(&own)); - delta.bump_epoch(); - // The membership is the reconciled one, so are its ignore rules. - delta.record_root_ignore_files(snapshot.root_ignore_files().cloned()); - for (path, _) in own.manifest().entries() { - if !members.contains_key(path) { - delta.apply( - path.clone(), - LiveEntry::new(super::snapshot::DiskState::Absent, revision), - ); - } - } - for (path, disk) in members { - let entry = match snapshot.source(&path) { - super::snapshot::Source::Live(entry) => entry.clone(), - _ => LiveEntry::new(disk, revision), - }; - delta.apply(path, entry); + 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| Self::error(format!("{error:?}")))?; + let next = delta.snapshot(); + match snapshot.install_cut() { + Some(cut) => next.with_install_cut(cut.clone()), + None => next, } - (delta.snapshot(), revision) - } else { - self.reconciled(access, own)? }; self.driver.install(access, &snapshot, revision)?; - Ok(snapshot) + Ok(self.driver.installed_after(&snapshot)) } } @@ -324,7 +595,14 @@ impl super::contracts::PlaneLoader for SiblingLoader { let seed = self.seed(access)?; if access.is_read_only() { let snapshot = super::snapshot::LiveDelta::new(seed).snapshot(); - let pending_planes = self.open_planes(access, &snapshot); + let (readers, mut pending_planes) = + PlaneReaders::open_available(access, snapshot.generation().clone(), &self.adapters); + let snapshot = snapshot.with_readers(readers); + for gap in self.open_planes(access, &snapshot) { + if !pending_planes.iter().any(|error| error.plane == gap.plane) { + pending_planes.push(gap); + } + } return Ok(super::contracts::LoadOutcome { snapshot, pending_planes, @@ -332,6 +610,7 @@ impl super::contracts::PlaneLoader for SiblingLoader { } let (snapshot, revision) = self.reconciled(access, seed)?; self.driver.install(access, &snapshot, revision)?; + let snapshot = self.driver.installed_after(&snapshot); let snapshot = self.fold(access, &snapshot, revision)?; let pending_planes = self.open_planes(access, &snapshot); Ok(super::contracts::LoadOutcome { @@ -426,14 +705,18 @@ mod tests { if self.walks.fetch_add(1, Ordering::SeqCst) == 0 { self.revision.fetch_add(1, Ordering::SeqCst); } - Ok(ReconciledCheckout { revision, entries }) + Ok(ReconciledCheckout { + revision, + entries, + cut: None, + }) } fn build_own_generation( &self, _: &ViewAccess, _: &Snapshot, seed_derived: bool, - ) -> Result, PlaneError> { + ) -> Result, PlaneError> { assert!(!seed_derived, "derived callgraph reuse must stay disabled"); if std::env::var("AFT_FIRST_LOAD_PAUSE").as_deref() == Ok("before") { park_child(self.owner.registry().storage()); @@ -558,7 +841,7 @@ mod tests { super::super::read::open_foreign_generation(&reader, "foreign", &producers()) .unwrap() .unwrap(); - assert_eq!(generation.manifest().len(), 1); + assert_eq!(generation.len(), 1); assert_eq!(std::fs::read(store.pointer_path()).unwrap(), before); assert_eq!(inventory(temp.path()), inventory_before); assert_eq!(registry.gc_epoch().unwrap(), epoch_before); @@ -778,8 +1061,12 @@ mod tests { .register_view_with_storage(registry.storage(), "local", temp.path()) .unwrap(); let access = ViewAccess::Owner(owner); - let snapshot = - LiveDelta::new(Arc::new(OpenGeneration::new("test", manifest(&[]), None))).snapshot(); + let snapshot = LiveDelta::new(Arc::new(ComposedGeneration::new( + "test", + manifest(&[]), + None, + ))) + .snapshot(); let state = Arc::new(State { polls: AtomicU64::new(0), snapshot: snapshot.clone(), @@ -867,10 +1154,17 @@ pub trait MembershipWalker: Send + Sync { fn files(&self, root: &std::path::Path) -> Result, PlaneError>; } -struct InstalledCheckout { +#[derive(Clone)] +/// One installed checkout authority. Cloned query handles keep the composed +/// generation, journal cut, coverage and resident readers from the same install. +pub struct InstalledBundle { + pub seq: u64, + pub composed: Arc, + pub coverage: BTreeMap, + pub readers: Arc, + pub published: super::manifest_v2::PublishedFrontier, revision: u64, snapshot: Snapshot, - prepared_generation: Option, uncertain_paths: std::collections::BTreeSet, own_installed: bool, } @@ -1037,7 +1331,10 @@ pub struct CheckoutDriver { adapters: Vec>, publish_fence: std::sync::RwLock>>, observed: std::sync::Mutex, - installed: std::sync::Mutex, + installed: std::sync::Mutex>, + live: std::sync::Mutex, + prepared_readers: std::sync::Mutex>>, + retry: std::sync::atomic::AtomicBool, #[cfg(test)] work: std::sync::Mutex, } @@ -1054,6 +1351,16 @@ pub(crate) struct ReconcileWork { } impl CheckoutDriver { + pub fn take_revalidated_job(&self) -> bool { + self.retry.swap(false, std::sync::atomic::Ordering::AcqRel) + } + + pub fn installed_bundle(&self) -> Arc { + self.installed + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clone() + } pub fn new( owner: super::registry::ViewRegistration, producers: Producers, @@ -1067,9 +1374,23 @@ impl CheckoutDriver { ignore_fingerprint: None, segment: None, }); - let mut delta = - super::snapshot::LiveDelta::new(Arc::new(OpenGeneration::new("empty", empty, None))); + let mut delta = super::snapshot::LiveDelta::new(Arc::new(ComposedGeneration::new( + "empty", empty, None, + ))); delta.set_watcher(super::snapshot::WatcherState::Reconciling); + let composed = delta.base().clone(); + let readers = PlaneReaders::open(&ViewAccess::Owner(owner.clone()), composed.clone(), &[]) + .expect("empty reader lease"); + let snapshot = delta + .snapshot() + .with_readers(readers.clone()) + .with_install_cut(InstallCut { + seq: 0, + resident: composed.clone(), + epoch: delta.epoch(), + version: delta.version(), + intent_version: delta.snapshot().intent_version(), + }); Self { owner, producers, @@ -1079,13 +1400,20 @@ impl CheckoutDriver { adapters: Vec::new(), publish_fence: std::sync::RwLock::new(None), observed: Default::default(), - installed: std::sync::Mutex::new(InstalledCheckout { + installed: std::sync::Mutex::new(Arc::new(InstalledBundle { + seq: 0, + composed, + readers, + coverage: BTreeMap::new(), + published: Default::default(), revision: 0, - snapshot: delta.snapshot(), - prepared_generation: None, + snapshot, uncertain_paths: std::collections::BTreeSet::new(), own_installed: false, - }), + })), + live: std::sync::Mutex::new(delta), + prepared_readers: Default::default(), + retry: Default::default(), #[cfg(test)] work: Default::default(), } @@ -1107,28 +1435,42 @@ impl CheckoutDriver { self } - /// Invalidates in-flight walks/builds and records an unreflected path under - /// the same lock as installation. This method performs no filesystem writes. + /// Journals an unreflected write under the same root lock as installation. + /// Ordinary edits replay onto a draft instead of invalidating its whole build. + /// This method performs no filesystem writes. pub fn record_change(&self, path: RelPath) { + 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; // Keep prior membership until reconciliation, but mark the edited path // as unreflected so queries cannot report stale data as installed. - 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 pending in installed.snapshot.pending_intent() { - delta.record_intent(pending.clone()); + for plane in &self.planes { + if plane.applies_to(&path) { + installed + .coverage + .insert(plane.plane(), super::readiness::PlaneReadiness::Building); + } } 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()); - 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(), + }); } fn check_owner(&self, access: &ViewAccess) -> Result<(), PlaneError> { @@ -1146,6 +1488,21 @@ impl CheckoutDriver { } impl FirstLoadDriver for CheckoutDriver { + fn installed_after(&self, _: &Snapshot) -> Snapshot { + self.installed_snapshot() + } + fn validate_cut(&self, _: &ViewAccess, snapshot: &Snapshot, _: u64) -> Result<(), PlaneError> { + let cut = snapshot + .install_cut() + .ok_or_else(|| SiblingLoader::error("missing installed bundle identity"))?; + let live = self + .live + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + live.replay_onto(snapshot, cut) + .map(|_| ()) + .map_err(|error| SiblingLoader::error(format!("{error:?}"))) + } fn producers(&self, _: &ViewAccess) -> Producers { self.producers.clone() } @@ -1249,40 +1606,119 @@ impl FirstLoadDriver for CheckoutDriver { entries: entries.clone(), stats, }; - Ok(ReconciledCheckout { revision, entries }) + Ok(ReconciledCheckout { + revision, + entries, + cut: installed.install_cut().cloned(), + }) } fn install( &self, access: &ViewAccess, snapshot: &Snapshot, - revision: u64, + _revision: u64, ) -> Result<(), PlaneError> { self.check_owner(access)?; + let cut = snapshot + .install_cut() + .ok_or_else(|| SiblingLoader::error("missing installed bundle identity"))?; + let old_bundle = self.installed_bundle(); + let previously_owned = old_bundle.own_installed + && old_bundle.composed.resident_key() == snapshot.generation().resident_key(); + drop(old_bundle); + let prepared = self + .prepared_readers + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .remove(snapshot.generation().name()); + let own_installed = prepared.is_some() || previously_owned; + let readers = match prepared { + Some(readers) => readers, + None => { + let old = self + .installed + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if Arc::ptr_eq(&old.composed, snapshot.generation()) { + old.readers.clone() + } else { + drop(old); + let adapters: Vec<_> = self + .adapters + .iter() + .filter(|adapter| { + snapshot.generation().name() != "empty" + && (adapter.plane() != FamilyPlane::Callgraph || own_installed) + }) + .cloned() + .collect(); + PlaneReaders::open(access, snapshot.generation().clone(), &adapters)? + } + } + }; + let mut live = self + .live + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let mut successor = live + .replay_onto(snapshot, cut) + .map_err(|error| SiblingLoader::error(format!("{error:?}")))?; let mut installed = self .installed .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); - if revision != installed.revision { - return Err(SiblingLoader::error(CHECKOUT_CHANGED_BEFORE_INSTALL)); - } - let own_installed = - installed.prepared_generation.as_deref() == Some(snapshot.generation().name()); - for adapter in &self.adapters { - if snapshot.generation().name() != "empty" - && (adapter.plane() != FamilyPlane::Callgraph || own_installed) - { - adapter.open_generation(access, snapshot.generation())?; - } + let installed = Arc::make_mut(&mut installed); + if cut.seq != installed.seq || !Arc::ptr_eq(&cut.resident, &installed.composed) { + return Err(SiblingLoader::error( + "BaseChanged: installed bundle superseded", + )); } - let old_name = installed.snapshot.generation().name().to_owned(); - installed.snapshot = snapshot.clone(); + installed.seq += 1; + successor.set_watcher(super::snapshot::WatcherState::Healthy); + installed.composed = snapshot.generation().clone(); + installed.published = snapshot + .generation() + .published() + .cloned() + .unwrap_or_default(); + installed.readers = readers.clone(); + installed.snapshot = + successor + .snapshot() + .with_readers(readers) + .with_install_cut(InstallCut { + seq: installed.seq, + resident: installed.composed.clone(), + epoch: successor.epoch(), + version: successor.version(), + intent_version: successor.snapshot().intent_version(), + }); + installed.coverage = FamilyPlane::ALL + .into_iter() + .map(|plane| { + ( + plane, + if installed.snapshot.pending_intent().any(|path| { + self.planes + .iter() + .any(|driver| driver.plane() == plane && driver.applies_to(path)) + }) { + super::readiness::PlaneReadiness::Building + } else { + super::readiness::plane_readiness( + plane, + Some(&installed.composed), + &installed.snapshot, + &Default::default(), + &|_| true, + ) + }, + ) + }) + .collect(); installed.uncertain_paths.clear(); installed.own_installed = own_installed; - if old_name != snapshot.generation().name() { - for adapter in &self.adapters { - adapter.release_generation(access, &old_name); - } - } + *live = successor; Ok(()) } fn build_own_generation( @@ -1290,8 +1726,11 @@ impl FirstLoadDriver for CheckoutDriver { access: &ViewAccess, snapshot: &Snapshot, seed_derived: bool, - ) -> Result, PlaneError> { + ) -> Result, PlaneError> { self.check_owner(access)?; + if snapshot.generation().base_header().is_some() { + return Err(binding_unavailable()); + } super::io::WORK .own_generation_folds .fetch_add(1, std::sync::atomic::Ordering::Relaxed); @@ -1302,7 +1741,10 @@ impl FirstLoadDriver for CheckoutDriver { let base = store .current_generation() .map_err(|error| SiblingLoader::error(error.to_string()))?; - let mut manifest = snapshot.generation().manifest().clone(); + let mut manifest = snapshot + .generation() + .flatten() + .map_err(|error| SiblingLoader::error(error.to_string()))?; for (path, entry) in snapshot.live_entries() { let value = match entry.disk { super::snapshot::DiskState::Absent => None, @@ -1376,18 +1818,32 @@ impl FirstLoadDriver for CheckoutDriver { .prepare_v2(&name, base.as_deref(), &manifest, None) .map_err(|error| SiblingLoader::error(error.to_string()))?, ); + let marker = + crate::root_cache::ReadMarker::create(self.owner.view_dir(), &name.to_string()) + .map_err(|error| SiblingLoader::error(error.to_string()))?; + let generation = Arc::new(ComposedGeneration::new( + name.to_string(), + manifest, + Some(super::snapshot::Residency::Marker(marker)), + )); + // Reader construction is fallible and may open SQLite. It must finish + // before publication and never run under the installed-bundle mutex. + let readers = PlaneReaders::open(access, generation.clone(), &self.adapters)?; + let mut published = false; let mut commit = || { let prepared = prepared .take() .ok_or_else(|| SiblingLoader::error("publication committed twice"))?; store .commit_v2(prepared, None) - .map(|outcome| { - if outcome == super::manifest_v2::PublishV2::Published { - unpublished.complete(); + .map_err(|error| SiblingLoader::error(error.to_string())) + .and_then(|outcome| match outcome { + super::manifest_v2::PublishV2::Published => { unpublished.complete(); published = true; Ok(()) }, + super::manifest_v2::PublishV2::Conflict { .. } | super::manifest_v2::PublishV2::EquivalentWinner { .. } => { + self.retry.store(true, std::sync::atomic::Ordering::Release); + Err(SiblingLoader::error(format!("{CHECKOUT_CHANGED_BEFORE_INSTALL}: publication lost; revalidate a fresh job"))) } }) - .map_err(|error| SiblingLoader::error(error.to_string())) }; let fence = self .publish_fence @@ -1398,25 +1854,20 @@ impl FirstLoadDriver for CheckoutDriver { Some(fence) => fence.publish(&mut commit)?, None => commit()?, } + if !published { + return Err(SiblingLoader::error("publication fence did not commit")); + } self.owner .registry() .note_publish(self.owner.scope()) .map_err(|error| SiblingLoader::error(error.to_string()))?; - let reader = self - .owner - .registry() - .register_reader("installed-owner") - .map_err(|error| SiblingLoader::error(error.to_string()))?; - let generation = - super::read::open_foreign_generation(&reader, self.owner.scope(), &self.producers) - .map_err(|error| SiblingLoader::error(error.to_string()))? - .ok_or_else(|| { - SiblingLoader::error("own publication has no installed generation") - })?; - self.installed + for adapter in &self.adapters { + adapter.generation_published(access, &generation)?; + } + self.prepared_readers .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) - .prepared_generation = Some(generation.name().to_owned()); + .insert(generation.name().to_owned(), readers); Ok(generation) } } @@ -1465,11 +1916,11 @@ impl QueryState for CheckoutDriver { .filter(|(path, _)| applicable(path)) .map(|(path, _)| path.clone()), ); - for (path, entry) in snapshot.generation().manifest().entries() { + for (path, _entry) in snapshot.generation().entries() { if applicable(path) && ((plane == FamilyPlane::Callgraph && !installed.own_installed) || !matches!( - entry.plane_state(plane), + snapshot.plane_readiness(path, plane), Some(super::readiness::PlaneState::Ready { .. }) )) { @@ -1729,24 +2180,194 @@ mod composite_tests { .is_none()); } #[test] - fn composite_install_losing_revision_race_refuses_obsolete_snapshot() { + fn composite_install_epoch_race_refuses_obsolete_snapshot() { let root = tempfile::tempdir().unwrap(); let storage = tempfile::tempdir().unwrap(); let (driver, _, _, access) = fixture(root.path(), storage.path()); let checkout = driver.reconcile(&access).unwrap(); let snapshot = driver.installed_state(&access, FamilyPlane::Trigram).0; driver.record_change(RelPath::new(b"file.rs".to_vec()).unwrap()); + driver.live.lock().unwrap().bump_epoch(); let error = driver .install(&access, &snapshot, checkout.revision) .unwrap_err(); - assert!(error - .reason - .contains("changed before snapshot installation")); + assert!(error.reason.contains("EpochChanged")); assert_eq!( driver.installed_state(&access, FamilyPlane::Trigram).1, vec![root.path().join("file.rs")] ); } + + struct EditingFence { + driver: std::sync::Weak, + } + impl PublishFence for EditingFence { + fn publish( + &self, + commit: &mut dyn FnMut() -> Result<(), PlaneError>, + ) -> Result<(), PlaneError> { + let driver = self.driver.upgrade().unwrap(); + std::fs::write( + driver.owner.root().join("file.rs"), + b"delivered during publication", + ) + .unwrap(); + driver.record_change(RelPath::new(b"file.rs".to_vec()).unwrap()); + commit() + } + } + + #[test] + fn sustained_writes_install_without_a_quiet_period_and_keep_gaps() { + let root = tempfile::tempdir().unwrap(); + let storage = tempfile::tempdir().unwrap(); + let (driver, _, _, access) = fixture(root.path(), storage.path()); + let loader = SiblingLoader::new(driver.clone(), vec![]); + loader.load(&access).unwrap(); + driver.set_publish_fence(Arc::new(EditingFence { + driver: Arc::downgrade(&driver), + })); + for sequence in 0..32 { + let (snapshot, revision) = driver.installed_cut(); + let seq = driver.installed_bundle().seq; + std::fs::write( + root.path().join("file.rs"), + format!("edit before fold {sequence}"), + ) + .unwrap(); + driver.record_change(RelPath::new(b"file.rs".to_vec()).unwrap()); + let installed = loader.fold(&access, &snapshot, revision).unwrap(); + assert_eq!(driver.installed_bundle().seq, seq + 1); + assert_eq!( + driver.installed_bundle().coverage[&FamilyPlane::Trigram], + super::super::readiness::PlaneReadiness::Building + ); + assert_eq!(installed.pending_intent().count(), 1); + assert_eq!( + driver.installed_state(&access, FamilyPlane::Trigram).1, + vec![root.path().join("file.rs")] + ); + } + eprintln!("32/32 installs: edits after every cut, before fold, and inside every publication fence; no quiet-period retry"); + } + + #[test] + fn install_refuses_a_superseded_bundle_even_with_equal_base_bytes() { + let root = tempfile::tempdir().unwrap(); + let storage = tempfile::tempdir().unwrap(); + let (driver, _, _, access) = fixture(root.path(), storage.path()); + let loader = SiblingLoader::new(driver.clone(), vec![]); + loader.load(&access).unwrap(); + let (snapshot, revision) = driver.installed_cut(); + driver.install(&access, &snapshot, revision).unwrap(); + let current = driver.installed_bundle().seq; + let error = driver.install(&access, &snapshot, revision).unwrap_err(); + assert!(error.reason.contains("BaseChanged")); + assert_eq!(driver.installed_bundle().seq, current); + } + + struct RacingPublisher { + driver: std::sync::Weak, + equivalent: bool, + } + impl PublishFence for RacingPublisher { + fn publish( + &self, + commit: &mut dyn FnMut() -> Result<(), PlaneError>, + ) -> Result<(), PlaneError> { + let driver = self.driver.upgrade().unwrap(); + let store = driver.owner.view_store().unwrap(); + let current = store.current_generation().unwrap(); + let candidate = std::fs::read_dir(driver.owner.view_dir()) + .unwrap() + .filter_map(|entry| { + let name = entry.unwrap().file_name().to_string_lossy().into_owned(); + name.strip_prefix("manifest-") + .and_then(|name| name.strip_suffix(".json")) + .map(str::to_owned) + }) + .find(|name| Some(name) != current.as_ref()) + .unwrap(); + let mut manifest = store.load_manifest_v2(&candidate).unwrap(); + if !self.equivalent { + manifest.header_mut().head_tree = Some("foreign proof".into()); + } + let name = super::super::manifest_v2::GenerationName::for_manifest(&manifest).unwrap(); + let prepared = store + .prepare_v2(&name, current.as_deref(), &manifest, None) + .unwrap(); + assert_eq!( + store.commit_v2(prepared, None).unwrap(), + super::super::manifest_v2::PublishV2::Published + ); + commit() + } + } + + #[test] + fn unwired_worker_publication_refuses_before_any_legacy_write() { + let root = tempfile::tempdir().unwrap(); + let storage = tempfile::tempdir().unwrap(); + let (driver, _, _, access) = fixture(root.path(), storage.path()); + let base = driver.installed_snapshot().generation().clone(); + let worker = super::super::manifest_v2::ManifestV2::new_v3( + base.header().clone(), + Some(super::super::manifest_v2::BaseHeader { + scope: "base".into(), + membership_digest: "11".repeat(32), + identity_digest: "22".repeat(32), + bound_generation: base.name().into(), + }), + Default::default(), + ); + let composed = Arc::new(ComposedGeneration::compose("worker", worker, base, None).unwrap()); + let snapshot = LiveDelta::new(composed).snapshot(); + let store = driver.owner.view_store().unwrap(); + 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!(store.current_generation().unwrap().is_none()); + assert!(!storage.path().join("blobs/v3").exists()); + } + + #[test] + fn actual_cas_losers_never_install_foreign_verification_and_request_fresh_jobs() { + for equivalent in [false, true] { + let root = tempfile::tempdir().unwrap(); + let storage = tempfile::tempdir().unwrap(); + let (driver, _, _, access) = fixture(root.path(), storage.path()); + let loader = SiblingLoader::new(driver.clone(), vec![]); + loader.load(&access).unwrap(); + let before = driver.installed_bundle(); + driver.set_publish_fence(Arc::new(RacingPublisher { + driver: Arc::downgrade(&driver), + equivalent, + })); + let (snapshot, revision) = driver.installed_cut(); + let error = loader.fold(&access, &snapshot, revision).unwrap_err(); + assert!(error.reason.contains("publication lost")); + assert!(driver.take_revalidated_job()); + assert_eq!(driver.installed_bundle().seq, before.seq); + assert_eq!( + driver.installed_snapshot().generation().name(), + before.composed.name() + ); + assert_ne!( + driver + .owner + .view_store() + .unwrap() + .current_generation() + .unwrap() + .as_deref(), + Some(before.composed.name()) + ); + } + } } /// Bridges the ruled callgraph extractor/materializer into the composite loader. @@ -1862,7 +2483,11 @@ impl CompositePlane for CallgraphBridge { ); let database = super::resolve_derived_path(owner.view_dir(), generation) .map_err(|e| error(e.to_string()))?; - super::callgraph::materialize(&database, manifest, &reader) + super::callgraph::materialize( + &database, + &ComposedGeneration::new(generation, manifest.clone(), None), + &reader, + ) } fn finish_generation( &self, @@ -1907,7 +2532,7 @@ impl CompositePlane for CallgraphBridge { #[cfg(test)] mod callgraph_bridge_tests { - use super::super::contracts::PlaneAdapter; + use super::super::contracts::{PlaneAdapter, PlaneLoader}; use super::*; struct Walker; @@ -2097,6 +2722,48 @@ mod callgraph_bridge_tests { .installed_state(&access, FamilyPlane::Callgraph) .1 .is_empty()); + drop(reader); + let old_bundle = driver.installed_bundle(); + let old_snapshot = old_bundle.snapshot.clone(); + let old_readers = Arc::downgrade(&old_bundle.readers); + std::fs::write(root.path().join("file.rs"), b"fn replacement() {}\n").unwrap(); + driver.record_change(RelPath::new(b"file.rs".to_vec()).unwrap()); + loader.load(&access).unwrap(); + let new_snapshot = driver.installed_snapshot(); + assert_eq!( + bridge + .adapter + .reader(&access, &new_snapshot) + .unwrap() + .store + .direct_callers_of(std::path::Path::new("file.rs"), "target") + .unwrap() + .len(), + 0 + ); + drop(old_bundle); + assert!( + old_readers.upgrade().is_some(), + "an old query still owns its readers" + ); + // Open lazily after replacement; retaining a reader opened before the + // swap would not exercise residency in the adapter's generation map. + assert_eq!( + bridge + .adapter + .reader(&access, &old_snapshot) + .unwrap() + .store + .direct_callers_of(std::path::Path::new("file.rs"), "target") + .unwrap() + .len(), + 1 + ); + drop(old_snapshot); + assert!( + old_readers.upgrade().is_none(), + "the last old query releases the reader lease" + ); } } @@ -2310,12 +2977,28 @@ impl super::intent::WriteIntentListener for CheckoutDriver { return; } { + let mut live = self + .live + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + live.bump_epoch(); let mut installed = self .installed .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); + let installed = Arc::make_mut(&mut installed); installed.revision += 1; installed.uncertain_paths.insert(absolute.clone()); + installed.snapshot = live + .snapshot() + .with_readers(installed.readers.clone()) + .with_install_cut(InstallCut { + seq: installed.seq, + resident: installed.composed.clone(), + epoch: live.epoch(), + version: live.version(), + intent_version: live.snapshot().intent_version(), + }); } let snapshot = self .installed @@ -2336,6 +3019,7 @@ impl super::intent::WriteIntentListener for CheckoutDriver { .installed .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); + let installed = Arc::make_mut(&mut installed); installed.revision += 1; } } diff --git a/crates/aft/src/views/manifest_v2.rs b/crates/aft/src/views/manifest_v2.rs index 1db5714382..ac84e324f9 100644 --- a/crates/aft/src/views/manifest_v2.rs +++ b/crates/aft/src/views/manifest_v2.rs @@ -73,6 +73,33 @@ pub struct ManifestHeader { pub segment: Option, } +/// A depth-one reference to a fixed-membership base. The generation is +/// diagnostic: readiness successors keep the same identity and membership. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct BaseHeader { + pub scope: String, + pub membership_digest: String, + pub identity_digest: String, + pub bound_generation: String, +} + +#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +pub struct PublishedFrontier { + pub delta_cut_version: u64, + pub watcher_epoch: u64, + pub published_at_ms: u64, + pub verified_frontier: Option, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct VerifiedFrontier { + #[serde(rename = "E0")] + pub e0: u64, + #[serde(rename = "F")] + pub f: u64, + pub delivery_seq: u64, +} + /// Per-plane states of one regular entry. `None` means the plane does not /// apply to the file (for example, no semantic chunks for an image). #[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] @@ -169,6 +196,11 @@ impl EntryV2 { pub struct ManifestV2 { header: ManifestHeader, entries: BTreeMap, + v3: bool, + base: Option, + published: Option, + tombstones: BTreeSet, + readiness_overrides: BTreeMap, } impl ManifestV2 { @@ -176,6 +208,64 @@ impl ManifestV2 { Self { header, entries: BTreeMap::new(), + v3: false, + base: None, + published: None, + tombstones: BTreeSet::new(), + readiness_overrides: BTreeMap::new(), + } + } + + pub fn new_v3( + header: ManifestHeader, + base: Option, + published: PublishedFrontier, + ) -> Self { + Self { + v3: true, + base, + published: Some(published), + ..Self::new(header) + } + } + + pub fn base_header(&self) -> Option<&BaseHeader> { + self.base.as_ref() + } + pub fn published(&self) -> Option<&PublishedFrontier> { + self.published.as_ref() + } + pub fn tombstones(&self) -> &BTreeSet { + &self.tombstones + } + pub fn readiness_overrides(&self) -> &BTreeMap { + &self.readiness_overrides + } + + pub fn set_readiness_override(&mut self, path: RelPath, patch: EntryPlanes) -> Result<()> { + if !self.v3 + || self.base.is_none() + || self.entries.contains_key(&path) + || self.tombstones.contains(&path) + { + return Err(ViewError::InvalidManifest( + "readiness overrides require an inherited path in a v3 worker".into(), + )); + } + self.readiness_overrides.insert(path, patch); + Ok(()) + } + + pub(super) fn inherit(&mut self, path: RelPath, patch: Option) { + self.entries.remove(&path); + self.tombstones.remove(&path); + match patch { + Some(patch) => { + self.readiness_overrides.insert(path, patch); + } + None => { + self.readiness_overrides.remove(&path); + } } } @@ -189,6 +279,11 @@ impl ManifestV2 { pub fn insert(&mut self, rel_path: RelPath, entry: EntryV2) -> Result<()> { validate_entry(&rel_path, &entry)?; + if self.tombstones.contains(&rel_path) || self.readiness_overrides.contains_key(&rel_path) { + return Err(ViewError::InvalidManifest( + "manifest sections must be disjoint".into(), + )); + } if self.entries.insert(rel_path, entry).is_some() { return Err(ViewError::InvalidManifest( "a manifest cannot contain the same rel_path twice".to_string(), @@ -202,10 +297,16 @@ impl ManifestV2 { match entry { Some(entry) => { validate_entry(&rel_path, &entry)?; + self.tombstones.remove(&rel_path); + self.readiness_overrides.remove(&rel_path); self.entries.insert(rel_path, entry); } None => { self.entries.remove(&rel_path); + self.readiness_overrides.remove(&rel_path); + if self.base.is_some() { + self.tombstones.insert(rel_path); + } } } Ok(()) @@ -251,9 +352,10 @@ impl ManifestV2 { changed } - /// Every `Ready` key, with its plane. + /// Deduplicated Ready keys in entries and overrides. Deduplication is lazy + /// so a bounded marking walk can stop without scanning the remaining rows. pub fn ready_keys(&self) -> impl Iterator + '_ { - self.entries.values().flat_map(|entry| { + let entries = self.entries.values().flat_map(|entry| { FamilyPlane::ALL .into_iter() .filter_map(move |plane| match entry.plane_state(plane) { @@ -261,7 +363,20 @@ impl ManifestV2 { .map(|bytes| FamilyKey::new(plane, bytes)), _ => None, }) - }) + }); + let overrides = self.readiness_overrides.values().flat_map(|patch| { + FamilyPlane::ALL + .into_iter() + .filter_map(move |plane| match patch.get(plane) { + Some(PlaneState::Ready { key }) => crate::blob_store::v2::parse_hex32(key) + .map(|bytes| FamilyKey::new(plane, bytes)), + _ => None, + }) + }); + let mut seen = BTreeSet::new(); + entries + .chain(overrides) + .filter(move |key| seen.insert(*key)) } /// The segment this generation references, if any. @@ -332,6 +447,25 @@ struct BorrowedManifest<'a> { #[serde(flatten)] header: &'a ManifestHeader, entries: BorrowedEntries<'a>, + #[serde(skip_serializing_if = "Option::is_none")] + base: Option<&'a BaseHeader>, + #[serde(skip_serializing_if = "Option::is_none")] + published: Option<&'a PublishedFrontier>, + #[serde(skip_serializing_if = "Option::is_none")] + tombstones: Option<&'a BTreeSet>, + #[serde(skip_serializing_if = "Option::is_none")] + readiness_overrides: Option>>, +} + +#[derive(Serialize)] +struct OverrideRow<'a> { + rel_path: &'a RelPath, + planes: &'a EntryPlanes, +} +#[derive(Deserialize, Serialize)] +struct OwnedOverrideRow { + rel_path: RelPath, + planes: EntryPlanes, } #[derive(Deserialize, Serialize)] @@ -348,6 +482,14 @@ struct JsonManifestV2 { #[serde(flatten)] header: ManifestHeader, entries: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + base: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + published: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + tombstones: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + readiness_overrides: Vec, } impl Serialize for ManifestV2 { @@ -355,10 +497,19 @@ impl Serialize for ManifestV2 { #[cfg(test)] SERIALIZATIONS.with(|count| count.set(count.get() + 1)); BorrowedManifest { - format_version: MANIFEST_FORMAT_VERSION, + format_version: if self.v3 { 3 } else { MANIFEST_FORMAT_VERSION }, path_identity_version: PATH_IDENTITY_VERSION, header: &self.header, entries: BorrowedEntries(&self.entries), + base: self.base.as_ref(), + published: self.published.as_ref(), + tombstones: self.v3.then_some(&self.tombstones), + readiness_overrides: self.v3.then(|| { + self.readiness_overrides + .iter() + .map(|(rel_path, planes)| OverrideRow { rel_path, planes }) + .collect() + }), } .serialize(serializer) } @@ -372,7 +523,7 @@ thread_local! { impl<'de> Deserialize<'de> for ManifestV2 { fn deserialize>(deserializer: D) -> std::result::Result { let json = JsonManifestV2::deserialize(deserializer)?; - if json.format_version != MANIFEST_FORMAT_VERSION { + if !matches!(json.format_version, 2 | 3) { return Err(D::Error::custom(format!( "unsupported manifest format_version {}", json.format_version @@ -384,12 +535,47 @@ impl<'de> Deserialize<'de> for ManifestV2 { json.path_identity_version ))); } - let mut manifest = Self::new(json.header); + if json.format_version == 2 + && (json.base.is_some() + || json.published.is_some() + || !json.tombstones.is_empty() + || !json.readiness_overrides.is_empty()) + { + return Err(D::Error::custom("v2 cannot contain composition sections")); + } + let mut manifest = if json.format_version == 3 { + Self::new_v3( + json.header, + json.base, + json.published + .ok_or_else(|| D::Error::custom("v3 requires a published frontier"))?, + ) + } else { + Self::new(json.header) + }; for member in json.entries { manifest .insert(member.rel_path, member.entry) .map_err(D::Error::custom)?; } + for path in json.tombstones { + if manifest.base.is_none() + || manifest.entries.contains_key(&path) + || !manifest.tombstones.insert(path) + { + return Err(D::Error::custom( + "tombstones must be unique inherited paths, disjoint from entries", + )); + } + } + for row in json.readiness_overrides { + if manifest.readiness_overrides.contains_key(&row.rel_path) { + return Err(D::Error::custom("duplicate readiness override")); + } + manifest + .set_readiness_override(row.rel_path, row.planes) + .map_err(D::Error::custom)?; + } Ok(manifest) } } @@ -943,6 +1129,10 @@ mod tests { format_version: MANIFEST_FORMAT_VERSION, path_identity_version: PATH_IDENTITY_VERSION, header: manifest.header.clone(), + base: None, + published: None, + tombstones: Vec::new(), + readiness_overrides: Vec::new(), entries: manifest .entries .iter() diff --git a/crates/aft/src/views/mod.rs b/crates/aft/src/views/mod.rs index 37f0826c29..70b21a2af5 100644 --- a/crates/aft/src/views/mod.rs +++ b/crates/aft/src/views/mod.rs @@ -107,7 +107,7 @@ pub(crate) fn generation_matches_head(generation: &str, head_fingerprint: &str) } pub fn callgraph_paths_match_v2( - manifest: &manifest_v2::ManifestV2, + manifest: &snapshot::ComposedGeneration, root: &Path, paths: &[PathBuf], ) -> Result { diff --git a/crates/aft/src/views/per_checkout_core_tests.rs b/crates/aft/src/views/per_checkout_core_tests.rs index 5f513f0ccb..e3288c8db3 100644 --- a/crates/aft/src/views/per_checkout_core_tests.rs +++ b/crates/aft/src/views/per_checkout_core_tests.rs @@ -96,7 +96,8 @@ fn assert_matches_disk(snapshot: &Snapshot, disk: &Disk, context: &str) { paths.extend( snapshot .generation() - .manifest() + .flatten() + .unwrap() .entries() .map(|(rel_path, _)| rel_path.clone()), ); @@ -365,7 +366,13 @@ fn pending_work_survives_a_fold_that_publishes_before_its_fill() { write(&mut disk, &mut delta, "a.txt", b"edited"); let fill = FillMap::default(); assert_eq!( - plane_readiness(FamilyPlane::Semantic, Some(&ready), &delta, &fill, &applies), + plane_readiness( + FamilyPlane::Semantic, + Some(delta.base()), + &delta, + &fill, + &applies + ), PlaneReadiness::Ready { pending: 1, failed: 0 @@ -373,7 +380,7 @@ fn pending_work_survives_a_fold_that_publishes_before_its_fill() { ); let folded = fold( - &ready, + delta.base(), &producers(), [( path("a.txt"), @@ -391,7 +398,7 @@ fn pending_work_survives_a_fold_that_publishes_before_its_fill() { assert_eq!( plane_readiness( FamilyPlane::Semantic, - Some(&folded), + Some(delta.base()), &delta, &fill, &applies @@ -406,7 +413,7 @@ fn pending_work_survives_a_fold_that_publishes_before_its_fill() { let queue = work_queue( FamilyPlane::Semantic, "model-a", - Some(&folded), + Some(delta.base()), &delta, &fill, &applies, @@ -426,13 +433,13 @@ fn pending_work_survives_a_fold_that_publishes_before_its_fill() { key: semantic_key("a.txt", b"edited", "model-a"), }; assert_eq!( - fill.admit(&completion, "model-a", None, Some(&folded)), + fill.admit(&completion, "model-a", None, Some(delta.base())), Admission::Installed ); assert_eq!( plane_readiness( FamilyPlane::Semantic, - Some(&folded), + Some(delta.base()), &delta, &fill, &applies @@ -442,7 +449,7 @@ fn pending_work_survives_a_fold_that_publishes_before_its_fill() { failed: 0 } ); - let filled = fold(&folded, &producers(), [], &fill).unwrap(); + let filled = fold(delta.base(), &producers(), [], &fill).unwrap(); assert_eq!( filled .get(&path("a.txt")) @@ -450,7 +457,7 @@ fn pending_work_survives_a_fold_that_publishes_before_its_fill() { .plane_state(FamilyPlane::Semantic), Some(&PlaneState::ready(&completion.key)) ); - fill.trim_folded(&filled); + fill.trim_folded(&OpenGeneration::new("filled", filled, None)); assert!(fill.is_empty()); } @@ -495,7 +502,7 @@ fn pending_and_failed_states_survive_publication_and_reopen() { assert_eq!( plane_readiness( FamilyPlane::Semantic, - Some(&loaded), + Some(delta.base()), &delta, &FillMap::default(), &applies @@ -511,7 +518,7 @@ fn pending_and_failed_states_survive_publication_and_reopen() { fn stale_completions_are_dropped() { let mut disk = base_disk(); let generation = generation_of(&disk, "g"); - let manifest = generation.manifest().clone(); + let manifest = generation.clone(); let mut delta = LiveDelta::new(generation); let item = WorkItem { plane: FamilyPlane::Semantic, diff --git a/crates/aft/src/views/read.rs b/crates/aft/src/views/read.rs index 3c06c19800..a49ef05fb2 100644 --- a/crates/aft/src/views/read.rs +++ b/crates/aft/src/views/read.rs @@ -519,7 +519,7 @@ pub(crate) fn callgraph_paths_match( /// Content verification for an opted-in ruled callgraph generation. Legacy /// payload keys are intentionally incompatible with its dispatch-hint producer. pub fn callgraph_paths_match_v2( - manifest: &super::manifest_v2::ManifestV2, + manifest: &super::snapshot::ComposedGeneration, root: &std::path::Path, paths: &[PathBuf], ) -> super::Result { @@ -574,7 +574,7 @@ pub fn open_foreign_generation( reader: &super::registry::ReaderRegistration, scope: &str, producers: &super::manifest_v2::Producers, -) -> super::Result>> { +) -> super::Result>> { let protected = reader.protect_current(scope).map_err(|error| { super::ViewError::InvalidManifest(format!("foreign view {scope} unavailable: {error}")) })?; @@ -589,8 +589,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(), + )); + } Ok(Some(std::sync::Arc::new( - super::snapshot::OpenGeneration::new( + super::snapshot::ComposedGeneration::new( protected.generation().to_owned(), manifest, Some(super::snapshot::Residency::Protected(protected)), @@ -649,6 +654,7 @@ mod producer_tests { ), ) .unwrap(); + let manifest = super::super::snapshot::ComposedGeneration::new("test", manifest, None); assert!(callgraph_paths_match_v2(&manifest, root.path(), &[absolute.clone()]).unwrap()); std::fs::write(&absolute, b"fn changed() {}").unwrap(); assert!(!callgraph_paths_match_v2(&manifest, root.path(), &[absolute]).unwrap()); diff --git a/crates/aft/src/views/readiness.rs b/crates/aft/src/views/readiness.rs index 6685774dc6..acbe97cc11 100644 --- a/crates/aft/src/views/readiness.rs +++ b/crates/aft/src/views/readiness.rs @@ -15,7 +15,7 @@ use serde::{Deserialize, Serialize}; use crate::blob_store::v2::{ContentHash, FamilyKey, FamilyPlane}; use super::manifest_v2::{EntryV2, ManifestV2, Producers}; -use super::snapshot::DiskState; +use super::snapshot::{ComposedGeneration, DiskState}; use super::{RelPath, Result, ViewError}; /// The state of one plane for one manifest entry. @@ -120,12 +120,12 @@ impl FillMap { } /// Drops fills the given manifest already records as `Ready`. - pub fn trim_folded(&mut self, manifest: &ManifestV2) { + pub fn trim_folded(&mut self, manifest: &ComposedGeneration) { self.keys.retain(|(path, content, plane), key| { !matches!( - manifest.get(path), + manifest.entry(path), Some(entry) if entry.content() == Some(*content) - && entry.plane_state(*plane) == Some(&PlaneState::ready(key)) + && manifest.plane_readiness(path, *plane) == Some(&PlaneState::ready(key)) ) }); } @@ -139,7 +139,7 @@ impl FillMap { completion: &Completion, current_producer: &str, live: Option<&DiskState>, - generation: Option<&ManifestV2>, + generation: Option<&ComposedGeneration>, ) -> Admission { let item = &completion.item; if item.producer != current_producer { @@ -149,7 +149,7 @@ impl FillMap { Some(DiskState::Present { content, .. }) => Some(*content), Some(DiskState::Absent) => None, None => generation - .and_then(|manifest| manifest.get(&item.rel_path)) + .and_then(|manifest| manifest.entry(&item.rel_path)) .and_then(EntryV2::content), }; if current != Some(item.content) { @@ -168,6 +168,9 @@ pub trait LiveEntries { /// The last observed disk state of every path with a live entry. fn live_states(&self) -> Box + '_>; fn live_state(&self, rel_path: &RelPath) -> Option<&DiskState>; + fn readiness_patch(&self, _path: &RelPath, _plane: FamilyPlane) -> Option<&PlaneState> { + None + } } /// Counts outstanding work for `plane` in one snapshot. @@ -180,7 +183,7 @@ pub trait LiveEntries { /// `applies_to` says whether the plane has work for a path. pub fn plane_readiness( plane: FamilyPlane, - generation: Option<&ManifestV2>, + generation: Option<&ComposedGeneration>, live: &dyn LiveEntries, fill: &FillMap, applies_to: &dyn Fn(&RelPath) -> bool, @@ -197,7 +200,10 @@ pub fn plane_readiness( let Some(content) = entry.content() else { continue; }; - match entry.plane_state(plane) { + match live + .readiness_patch(path, plane) + .or_else(|| generation.plane_readiness(path, plane)) + { Some(PlaneState::Pending { .. }) if fill.get(path, &content, plane).is_none() => { pending += 1; } @@ -208,7 +214,11 @@ pub fn plane_readiness( for (path, state) in live.live_states() { if let DiskState::Present { content, .. } = state { if applies_to(path) && fill.get(path, content, plane).is_none() { - pending += 1; + match live.readiness_patch(path, plane) { + Some(PlaneState::Ready { .. }) => {} + Some(PlaneState::Failed { .. }) => failed += 1, + _ => pending += 1, + } } } } @@ -220,7 +230,7 @@ pub fn plane_readiness( pub fn work_queue( plane: FamilyPlane, producer: &str, - generation: Option<&ManifestV2>, + generation: Option<&ComposedGeneration>, live: &dyn LiveEntries, fill: &FillMap, applies_to: &dyn Fn(&RelPath) -> bool, @@ -234,7 +244,12 @@ pub fn work_queue( let Some(content) = entry.content() else { continue; }; - if matches!(entry.plane_state(plane), Some(PlaneState::Pending { .. })) + if (generation.owns_work(path, plane) || live.readiness_patch(path, plane).is_some()) + && matches!( + live.readiness_patch(path, plane) + .or_else(|| generation.plane_readiness(path, plane)), + Some(PlaneState::Pending { .. }) + ) && fill.get(path, &content, plane).is_none() { items.push(WorkItem { @@ -248,7 +263,13 @@ pub fn work_queue( } for (path, state) in live.live_states() { if let DiskState::Present { content, .. } = state { - if applies_to(path) && fill.get(path, content, plane).is_none() { + if applies_to(path) + && fill.get(path, content, plane).is_none() + && !matches!( + live.readiness_patch(path, plane), + Some(PlaneState::Ready { .. } | PlaneState::Failed { .. }) + ) + { items.push(WorkItem { plane, rel_path: path.clone(), @@ -268,7 +289,7 @@ pub fn work_queue( /// states; any `Pending` state whose key is in the fill map becomes `Ready`. /// A producer change is not a fold: it needs a rebuild, so it is refused. pub fn fold( - base: &ManifestV2, + base: &ComposedGeneration, producers: &Producers, updates: impl IntoIterator)>, fill: &FillMap, @@ -278,25 +299,5 @@ pub fn fold( "a fold cannot change producers; rebuild the generation instead".to_string(), ) })?; - let mut next = base.clone(); - for (path, entry) in updates { - next.set(path, entry)?; - } - for (path, entry) in next.entries_mut() { - let EntryV2::Regular { - content, planes, .. - } = entry - else { - continue; - }; - for plane in FamilyPlane::ALL { - let state = planes.get_mut(plane); - if matches!(state, Some(PlaneState::Pending { .. })) { - if let Some(key) = fill.get(path, content, plane) { - *state = Some(PlaneState::ready(key)); - } - } - } - } - Ok(next) + base.fold(producers, updates, fill) } diff --git a/crates/aft/src/views/segment_store.rs b/crates/aft/src/views/segment_store.rs index 9ff0c74732..923eabca83 100644 --- a/crates/aft/src/views/segment_store.rs +++ b/crates/aft/src/views/segment_store.rs @@ -377,6 +377,27 @@ pub fn build_from_blobs( assemble(policy, inputs) } +/// Materialize the logical membership, including inherited files and excluding +/// worker tombstones. The payload source retains ownership of its SQLite handle. +pub fn build_from_generation( + source: &impl PayloadSource, + generation: &super::snapshot::ComposedGeneration, + policy: &TrigramPolicy, +) -> SegmentResult { + let members: Vec<_> = generation + .entries() + .filter_map(|(path, entry)| match entry { + super::manifest_v2::EntryV2::Regular { content, size, .. } => Some(SegmentMember { + rel_path: path.clone(), + content: *content, + size: *size, + }), + _ => None, + }) + .collect(); + build_from_blobs(source, &members, policy) +} + static TEMP_SEQ: AtomicU64 = AtomicU64::new(0); /// Writes `segment` into the family directory under the protocol above. The diff --git a/crates/aft/src/views/semantic.rs b/crates/aft/src/views/semantic.rs index 1c7f627d32..9fa69d86b3 100644 --- a/crates/aft/src/views/semantic.rs +++ b/crates/aft/src/views/semantic.rs @@ -49,7 +49,7 @@ use super::readiness::{ }; use super::registry::ViewRegistration; use super::segment_store::rel_path_to_os; -use super::snapshot::{DiskState, LiveEntry, OpenGeneration, Snapshot, Source, WatcherState}; +use super::snapshot::{ComposedGeneration, DiskState, LiveEntry, Snapshot, Source, WatcherState}; use super::RelPath; fn lock(mutex: &Mutex) -> MutexGuard<'_, T> { @@ -498,9 +498,23 @@ struct ViewState { failed: BTreeMap<(RelPath, ContentHash), String>, tracker: FailureTracker, live: Option, + active_fills: usize, cache: Option<(CacheKey, Arc)>, } +struct ActiveFill(Arc>); +impl ActiveFill { + fn new(view: &Arc>) -> Self { + lock(view).active_fills += 1; + Self(view.clone()) + } +} +impl Drop for ActiveFill { + fn drop(&mut self) { + lock(&self.0).active_fills -= 1; + } +} + type ViewKey = (String, String); type ResidentKey = (String, String, String); @@ -653,9 +667,9 @@ impl SemanticPlane { fn admit( &self, access: &ViewAccess, - generation: &OpenGeneration, + generation: &ComposedGeneration, ) -> Result { - let manifest = generation.manifest(); + let manifest = generation; let id = self.producer.id(); let compatible = manifest.header().producers.semantic.as_deref() == Some(id.as_str()); let mut runs = Vec::new(); @@ -668,9 +682,10 @@ impl SemanticPlane { // Markers of every run loaded here are probed together afterwards. let mut seeds = Vec::new(); for (path, entry) in manifest.entries() { - let (Some(content), Some(state)) = - (entry.content(), entry.plane_state(FamilyPlane::Semantic)) - else { + let (Some(content), Some(state)) = ( + entry.content(), + generation.plane_readiness(path, FamilyPlane::Semantic), + ) else { continue; }; match state { @@ -734,6 +749,27 @@ impl SemanticPlane { content: ContentHash, repair_summaries: bool, ) -> VectorSource { + if let Some(patch) = snapshot + .readiness_patches() + .get(path) + .filter(|patch| patch.content == content) + .and_then(|patch| patch.planes.semantic.as_ref()) + { + if snapshot + .generation() + .plane_readiness(path, FamilyPlane::Semantic) + != Some(patch) + { + if let Some(key) = state.fill.get(path, &content, FamilyPlane::Semantic) { + return VectorSource::Fill(*key); + } + return if patch.is_failed() { + VectorSource::Failed + } else { + VectorSource::Pending + }; + } + } // A live entry supersedes the generation: its base run, if any, holds // other content and must never be scored. let live = matches!(snapshot.source(path), Source::Live(_)); @@ -793,6 +829,11 @@ impl SemanticPlane { return None; }; (applies_to(&path) + && (matches!(snapshot.source(&path), Source::Live(_)) + || snapshot.readiness_patches().contains_key(&path) + || snapshot + .generation() + .owns_work(&path, FamilyPlane::Semantic)) && (Self::summary_repair_pending( state, resident, @@ -872,7 +913,7 @@ impl SemanticPlane { return Err(plane_error("view belongs to another storage root")); } let resident = self - .resident(&access, snapshot.generation().name()) + .resident(&access, &snapshot.generation().resident_key()) .ok_or_else(|| plane_error("semantic generation not resident"))?; let view = self.view(&access); let mut report = FillReport::default(); @@ -1097,6 +1138,7 @@ impl SemanticPlane { ) .collect::>(); let store_started = Instant::now(); + let _active_fill = ActiveFill::new(&view); protect(&view, &keys)?; report.timing.store_write += store_started.elapsed(); let mut pending = to_embed.into_iter().zip(chunks).peekable(); @@ -1555,7 +1597,7 @@ fn admit_ready( &Completion { item, key }, producer, live.as_ref(), - Some(now.generation().manifest()), + Some(now.generation()), ) { Admission::Installed => report.installed += 1, Admission::DroppedContent | Admission::DroppedProducer => report.dropped += 1, @@ -1731,7 +1773,7 @@ impl SemanticPlane { snapshot: &Snapshot, ) -> Result { let resident = self - .resident(access, snapshot.generation().name()) + .resident(access, &snapshot.generation().resident_key()) .ok_or_else(|| plane_error("semantic generation not resident"))?; let arena = self.arena(access.family()); let view = self.view(access); @@ -1818,7 +1860,7 @@ impl SemanticPlane { shape.update(&[0]); } let cache_key = ( - snapshot.generation().name().to_owned(), + snapshot.generation().resident_key(), *shape.finalize().as_bytes(), state.fill_version, unvouched, @@ -1901,7 +1943,7 @@ impl SemanticPlane { /// Outstanding semantic work for the checkout, as a fill would see it. pub fn pending_work(&self, access: &ViewAccess, snapshot: &Snapshot) -> Vec { - let Some(resident) = self.resident(access, snapshot.generation().name()) else { + let Some(resident) = self.resident(access, &snapshot.generation().resident_key()) else { return Vec::new(); }; let view = self.view(access); @@ -1942,17 +1984,17 @@ impl PlaneAdapter for SemanticPlane { } /// Admits the generation's runs into the family arena (this is the only - /// place, besides a fill's own puts, that decodes store rows) and, for the - /// owner, drops fills and live-pin keys the generation now records. + /// place, besides a fill's own puts, that decodes store rows). Live fills + /// remain protected until the separate durable-publication callback. fn open_generation( &self, access: &ViewAccess, - generation: &Arc, + generation: &Arc, ) -> Result<(), PlaneError> { let key = ( access.family().to_owned(), access.scope().to_owned(), - generation.name().to_owned(), + generation.resident_key(), ); let existing = lock(&self.residents).get(&key).cloned(); let resident = if let Some(resident) = existing { @@ -1964,11 +2006,19 @@ impl PlaneAdapter for SemanticPlane { if matches!(access, ViewAccess::Reader { .. }) && !resident.legacy_summaries.is_empty() { log::info!("semantic borrower loaded {} legacy small-budget summary runs; serving published vectors until owner repair", resident.legacy_summaries.len()); } + Ok(()) + } + + fn generation_published( + &self, + access: &ViewAccess, + generation: &Arc, + ) -> Result<(), PlaneError> { if let ViewAccess::Owner(_) = access { let view = self.view(access); let mut state = lock(&view); let before = state.fill.len(); - state.fill.trim_folded(generation.manifest()); + state.fill.trim_folded(generation); if state.fill.len() != before { state.fill_version += 1; } @@ -1977,8 +2027,19 @@ impl PlaneAdapter for SemanticPlane { .keys() .map(FamilyKey::to_hex) .collect::>(); + if keep.is_empty() && state.active_fills == 0 { + // Chunk-cache keys have no manifest edge. Once no fill needs + // this pin, release its files instead of rewriting an empty list. + state.live.take(); + return Ok(()); + } if let Some(pin) = state.live.as_mut() { - pin.trim(|key| keep.contains(key)) + let published = generation + .ready_keys() + .iter() + .map(FamilyKey::to_hex) + .collect(); + pin.trim_published(&published, |key| keep.contains(key)) .map_err(|error| plane_error(error.to_string()))?; } } @@ -2015,7 +2076,7 @@ impl SemanticPlane { let key = ( family.to_owned(), scope.to_owned(), - snapshot.generation().name().to_owned(), + snapshot.generation().resident_key(), ); let Some(resident) = lock(&self.residents).get(&key).cloned() else { return PlaneReadiness::Building; @@ -2274,6 +2335,96 @@ mod tests { )) } + #[test] + fn opening_draft_readers_keeps_unpublished_fill_protection() { + let storage = tempfile::tempdir().unwrap(); + let root = tempfile::tempdir().unwrap(); + let plane = new_plane(storage.path(), "model-a"); + let checkout = Checkout::open(storage.path(), "draft-fill", root.path(), &plane); + let snapshot = checkout.load(); + let view = plane.view(&checkout.access); + let path = RelPath::new(b"file.rs".to_vec()).unwrap(); + let content = ContentHash::of(b"file content"); + let key = plane.producer.key(b"file content", &path); + let completion = super::super::readiness::Completion { + item: WorkItem { + plane: FamilyPlane::Semantic, + rel_path: path.clone(), + content, + producer: plane.producer.id(), + }, + key, + }; + let disk = DiskState::Present { content, size: 12 }; + { + let mut state = lock(&view); + assert_eq!( + state + .fill + .admit(&completion, &plane.producer.id(), Some(&disk), None), + Admission::Installed + ); + state.live = Some(crate::pins::LivePin::create(&checkout.owner).unwrap()); + state.live.as_mut().unwrap().protect(&[key]).unwrap(); + } + let mut manifest = + super::super::manifest_v2::ManifestV2::new(snapshot.generation().header().clone()); + manifest + .insert( + path, + super::super::manifest_v2::EntryV2::regular( + content, + 12, + super::super::manifest_v2::EntryPlanes { + semantic: Some(PlaneState::ready(&key)), + ..Default::default() + }, + ), + ) + .unwrap(); + let draft = Arc::new(ComposedGeneration::new("unpublished-draft", manifest, None)); + plane.open_generation(&checkout.access, &draft).unwrap(); + assert_eq!(lock(&view).fill.len(), 1); + assert!(lock(&view).live.as_ref().unwrap().keys_path().exists()); + plane + .generation_published(&checkout.access, &draft) + .unwrap(); + assert!(lock(&view).fill.is_empty()); + assert!(lock(&view).live.is_none()); + } + + #[test] + fn publication_cleanup_keeps_keys_of_an_active_fill() { + let storage = tempfile::tempdir().unwrap(); + let root = tempfile::tempdir().unwrap(); + let plane = new_plane(storage.path(), "model-a"); + let checkout = Checkout::open(storage.path(), "active-fill", root.path(), &plane); + let snapshot = checkout.load(); + let view = plane.view(&checkout.access); + let active = ActiveFill::new(&view); + lock(&view).live = Some(crate::pins::LivePin::create(&checkout.owner).unwrap()); + let key = plane + .producer + .key(b"pending bytes", &RelPath::new(b"new.rs".to_vec()).unwrap()); + protect(&view, &[key]).unwrap(); + let path = lock(&view).live.as_ref().unwrap().keys_path().to_path_buf(); + plane + .generation_published(&checkout.access, snapshot.generation()) + .unwrap(); + assert!( + path.exists(), + "publication must not release a pin still needed by a fill" + ); + drop(active); + plane + .generation_published(&checkout.access, snapshot.generation()) + .unwrap(); + assert!( + !path.exists(), + "the idle pin may be released after publication" + ); + } + #[test] fn admission_propagates_semantic_payload_decode_errors() { let storage = tempfile::tempdir().unwrap(); @@ -2311,7 +2462,7 @@ mod tests { ), ) .unwrap(); - let generation = OpenGeneration::new("bad", manifest, None); + let generation = ComposedGeneration::new("bad", manifest, None); assert!(plane.admit(&checkout.access, &generation).is_err()); } @@ -3844,8 +3995,7 @@ mod tests { assert!(matches!( folded .generation() - .manifest() - .get(&RelPath::new(b"src/broken.rs".to_vec()).unwrap()) + .entry(&RelPath::new(b"src/broken.rs".to_vec()).unwrap()) .and_then(|entry| entry.plane_state(FamilyPlane::Semantic)), Some(PlaneState::Failed { .. }) )); diff --git a/crates/aft/src/views/semantic_runtime.rs b/crates/aft/src/views/semantic_runtime.rs index 2aa6f29099..c1a0cb37c8 100644 --- a/crates/aft/src/views/semantic_runtime.rs +++ b/crates/aft/src/views/semantic_runtime.rs @@ -34,7 +34,7 @@ use super::intent::{WriteIntent, WriteIntentListener, WritePhase}; use super::manifest_v2::Producers; use super::registry::{FamilyRegistry, ViewRegistration}; use super::semantic::{FillBudget, FillReport, SemanticPlane, SemanticProducer, SemanticQuery}; -use super::snapshot::{DiskState, LiveDelta, OpenGeneration, Snapshot}; +use super::snapshot::{ComposedGeneration, DiskState, LiveDelta, Snapshot}; /// Producer written into the manifest header for the trigram and callgraph /// planes, which this semantic-only runtime does not register. @@ -62,7 +62,7 @@ struct PrototypeBase { storage: PathBuf, family: String, producer: String, - generation: Arc, + generation: Arc, } impl PrototypeBase { @@ -82,15 +82,11 @@ fn base_ref_ready(snapshot: &Snapshot) -> bool { snapshot.generation().name() != "empty" && snapshot.live_entries().next().is_none() && snapshot.pending_intent().next().is_none() - && snapshot - .generation() - .manifest() - .entries() - .all(|(path, entry)| { - matches!(entry, EntryV2::Regular { planes, .. } + && snapshot.generation().entries().all(|(path, entry)| { + matches!(entry, EntryV2::Regular { planes, .. } if !super::semantic::applies_to(path) || matches!(planes.semantic, Some(PlaneState::Ready { .. }))) - }) + }) } fn base_ref_stable(before: &std::fs::Metadata, after: &std::fs::Metadata) -> bool { @@ -315,7 +311,10 @@ impl CheckoutSemantic { { return; } - let mut manifest = snapshot.generation().manifest().clone(); + let mut manifest = snapshot + .generation() + .flatten() + .expect("valid composed generation"); // A worktree's .git file names its private administrative directory; // unlike source or ignore files, those bytes never enter semantic keys. manifest @@ -328,7 +327,7 @@ impl CheckoutSemantic { storage: self.owner.registry().storage().to_path_buf(), family: self.owner.family().to_owned(), producer: self.plane.semantic_producer().id(), - generation: Arc::new(OpenGeneration::new(name, manifest, None)), + generation: Arc::new(ComposedGeneration::new(name, manifest, None)), }); let mut bases = lock(prototype_bases()); bases.retain(|base| base.strong_count() > 0); @@ -352,7 +351,10 @@ impl CheckoutSemantic { &producer, ) })?; - let snapshot = LiveDelta::new(Arc::clone(&base.generation)).snapshot(); + let cut = self.driver.installed_snapshot().install_cut()?.clone(); + let snapshot = LiveDelta::new(Arc::clone(&base.generation)) + .snapshot() + .with_install_cut(cut); if !base_ref_ready(&snapshot) { return None; } @@ -365,7 +367,7 @@ impl CheckoutSemantic { // but do not derive attachments or fold inherited-ready entries. Any // delta or unsupported observation sends the whole bind strict. let files = ConfiguredMembershipWalker.files(&self.root).ok()?; - let manifest = base.generation.manifest(); + let manifest = &base.generation; let mut members = std::collections::BTreeSet::new(); for absolute in files { let path = diff --git a/crates/aft/src/views/semantic_runtime_tests.rs b/crates/aft/src/views/semantic_runtime_tests.rs index 807c907b01..c050d68109 100644 --- a/crates/aft/src/views/semantic_runtime_tests.rs +++ b/crates/aft/src/views/semantic_runtime_tests.rs @@ -12,6 +12,143 @@ 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; +/// 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. +#[test] +fn composition_no_marker_boundary_record() { + use crate::migration::prune_legacy::{ + prune_legacy, CensusFinding, ProcessCensus, PruneExit, PruneOptions, + }; + use crate::root_cache::StoragePaths; + struct NoProcesses; + impl ProcessCensus for NoProcesses { + fn take(&self) -> Result, String> { + Ok(Vec::new()) + } + } + // Inventory names and sizes without opening another descriptor on a live + // SQLite file set. The reader floor is a separately encoded JSON file. + fn inventory(root: &Path, path: &Path, files: &mut std::collections::BTreeMap) { + if !path.exists() { + return; + } + for entry in std::fs::read_dir(path).unwrap() { + let path = entry.unwrap().path(); + if path.is_dir() { + inventory(root, &path, files); + } else { + files.insert( + path.strip_prefix(root) + .unwrap() + .to_string_lossy() + .into_owned(), + std::fs::metadata(&path).unwrap().len(), + ); + } + } + } + let mut answers = Vec::new(); + for views in [false, true] { + let temp = tempfile::tempdir().unwrap(); + let temp = std::fs::canonicalize(temp.path()).unwrap(); + let storage = temp.join("storage"); + let root = temp.join("project"); + write_tree(&root, FILES); + std::fs::create_dir_all(&storage).unwrap(); + let ctx = crate::context::AppContext::new( + Box::new(crate::parser::TreeSitterProvider::new()), + crate::config::Config { + storage_dir: Some(storage.clone()), + ..Default::default() + }, + ); + let request = serde_json::from_value(serde_json::json!({ + "id": "composition-boundary", "command": "configure", "project_root": root, "storage_dir": storage, + "harness": "runner", "session_id": "alfonso:sidekick-00000000-0000-4001-98dd-383a2dbeca78", + "config": [{ "tier": "user", "source": "/u/aft.jsonc", "doc": serde_json::json!({ + "views": { "enabled": views }, "indexes": { "trigram": false, "semantic": true, "callgraph": false }, + "semantic": { "backend": "openai_compatible", "model": "boundary-no-network", "base_url": "http://127.0.0.1:1", "timeout_ms": 50 } + }).to_string() }] + })).unwrap(); + let response = crate::commands::configure::handle_configure(&request, &ctx); + assert!(response.success, "{}", response.data); + crate::commands::configure::drain_deferred_configure_maintenance(&ctx); + assert!( + ctx.semantic_refresh_sender().is_none(), + "the sidekick fixture must not start an embedding worker" + ); + let answer = if views { + let family = crate::search_index::artifact_cache_key(&root); + let scope = crate::path_identity::project_scope_key(&root); + let runtime = CheckoutSemantic::new( + &StoragePaths::same(&storage), + &family, + &scope, + &root, + producer(), + Weak::new(), + ) + .unwrap(); + runtime.load().unwrap(); + refresh(&runtime, &Model::default()); + let result = query(&runtime, "alpha cache"); + assert!(result.complete(), "{result:?}"); + let rows = rows(&root, &result.results); + let registry = super::super::registry::FamilyRegistry::open(&storage, &family).unwrap(); + super::super::eviction::enforce(®istry, Default::default()).unwrap(); + rows + } else { + cold(&root, "alpha cache").0 + }; + assert!(!answer.is_empty()); + for _ in 0..2 { + let report = crate::storage_retention::run_pass_with_budget_for_test( + &StoragePaths::same(&storage), + Duration::from_secs(120), + ); + assert!(report.timings_ms.contains_key("blobs_v3"), "{report:?}"); + } + let floor_before = std::fs::read(storage.join("reader-floor.json")).unwrap(); + let mut before = Default::default(); + inventory(&storage, &storage.join("blobs/v3"), &mut before); + let mut out = Vec::new(); + let exit = prune_legacy( + &storage, + &PruneOptions { + yes: false, + now: std::time::SystemTime::now(), + }, + &NoProcesses, + None, + &mut out, + ) + .unwrap(); + assert_eq!(exit, PruneExit::Clean); + assert!(String::from_utf8(out).unwrap().contains("Dry run")); + let mut v3 = Default::default(); + inventory(&storage, &storage.join("blobs/v3"), &mut v3); + assert_eq!(before, v3, "dry-run must not mutate the v3 namespace"); + assert_eq!( + std::fs::read(storage.join("reader-floor.json")).unwrap(), + floor_before + ); + assert!(!storage.join("views-harness.marker").exists()); + assert!( + !storage.join("blobs/v3").exists(), + "an unrouted entrypoint wrote v3 without the marker" + ); + let floor = std::fs::read_to_string(storage.join("reader-floor.json")).unwrap(); + assert!(!floor.contains("views_v3")); + eprintln!( + "COMPOSITION_BOUNDARY_RECORD {}", + serde_json::json!({ "views": views, "answers": answer, "v3_files": v3, "floor": floor }) + ); + answers.push(answer); + } + assert_eq!(answers[0], answers[1]); +} + #[test] fn lane_paused_view_fill_waits_without_rechunking_and_resumes() { use crate::semantic_index::{lane_pause_chunk_calls, with_lane_pause_clock, PauseRetryScope}; @@ -1075,7 +1212,7 @@ fn base_ref_does_not_inherit_private_git_administration() { write_tree(root.path(), FILES); let runtime = base_ref_runtime(storage.path(), "builder", root.path()); refresh(&runtime, &Model::default()); - let mut manifest = runtime.installed().generation().manifest().clone(); + let mut manifest = runtime.installed().generation().flatten().unwrap().clone(); let git = super::super::RelPath::from_os_path(Path::new(".git")).unwrap(); let bytes = b"gitdir: /private/checkout/administration\n"; manifest @@ -1088,12 +1225,13 @@ fn base_ref_does_not_inherit_private_git_administration() { ), ) .unwrap(); - let snapshot = LiveDelta::new(Arc::new(OpenGeneration::new( + let snapshot = LiveDelta::new(Arc::new(ComposedGeneration::new( "git-file-builder", manifest, None, ))) - .snapshot(); + .snapshot() + .with_install_cut(runtime.installed().install_cut().unwrap().clone()); runtime .driver() .install( @@ -1104,10 +1242,11 @@ fn base_ref_does_not_inherit_private_git_administration() { .unwrap(); runtime.remember_base(); let pin = lock(&runtime.base_ref_pin).as_ref().cloned().unwrap(); - assert!(pin.generation.manifest().get(&git).is_none()); + assert!(pin.generation.flatten().unwrap().get(&git).is_none()); assert!(pin .generation - .manifest() + .flatten() + .unwrap() .get(&super::super::RelPath::from_os_path(Path::new("src/alpha.rs")).unwrap()) .is_some()); } @@ -1794,12 +1933,14 @@ fn family_fill_rechecks_completed_work_after_acquiring_claim() { assert_eq!( a.installed() .generation() - .manifest() + .flatten() + .unwrap() .to_json_bytes() .unwrap(), b.installed() .generation() - .manifest() + .flatten() + .unwrap() .to_json_bytes() .unwrap() ); @@ -2208,7 +2349,8 @@ fn pending_semantic(runtime: &CheckoutSemantic) -> usize { runtime .installed() .generation() - .manifest() + .flatten() + .unwrap() .entries() .filter(|(_, entry)| { entry @@ -2312,7 +2454,8 @@ fn large_semantic_materialization_batches_pins_and_touches() { let manifest = runtime .installed() .generation() - .manifest() + .flatten() + .unwrap() .to_json_bytes() .unwrap(); let store = runtime @@ -2322,7 +2465,8 @@ fn large_semantic_materialization_batches_pins_and_touches() { let blobs: Vec<_> = runtime .installed() .generation() - .manifest() + .flatten() + .unwrap() .ready_keys() .map(|key| (key, store.get(&key).unwrap().unwrap())) .collect(); @@ -2331,7 +2475,8 @@ fn large_semantic_materialization_batches_pins_and_touches() { runtime .installed() .generation() - .manifest() + .flatten() + .unwrap() .to_json_bytes() .unwrap(), manifest @@ -2405,14 +2550,20 @@ fn large_checkout_edit_counts_source_work_and_preserves_outputs() { cold(&root, "rewritten item").0 ); let snapshot = runtime.installed(); - let manifest = snapshot.generation().manifest().to_json_bytes().unwrap(); + let manifest = snapshot + .generation() + .flatten() + .unwrap() + .to_json_bytes() + .unwrap(); let store = runtime .owner .open_store(crate::blob_store::v2::FamilyPlane::Semantic) .unwrap(); let blobs: Vec<_> = snapshot .generation() - .manifest() + .flatten() + .unwrap() .ready_keys() .map(|key| (key, store.get(&key).unwrap().unwrap())) .collect(); @@ -2421,7 +2572,8 @@ fn large_checkout_edit_counts_source_work_and_preserves_outputs() { runtime .installed() .generation() - .manifest() + .flatten() + .unwrap() .to_json_bytes() .unwrap(), manifest diff --git a/crates/aft/src/views/snapshot.rs b/crates/aft/src/views/snapshot.rs index ad61da9380..417ef6ee22 100644 --- a/crates/aft/src/views/snapshot.rs +++ b/crates/aft/src/views/snapshot.rs @@ -13,7 +13,7 @@ //! //! When a new generation `G+1` is published (by this daemon or by another //! one), the successor delta is derived over -//! `K = keys(delta@v) ∪ paths(diff(G, G+1))`: for each path in `K` the last +//! `K = keys(delta@v) ∪ keys(readiness@v) ∪ paths(diff(G, G+1))`: for each path in `K` the last //! observed disk state is the live entry's if there is one, else `G`'s entry //! (exact, by the invariant), and the successor has an entry exactly when that //! state differs from `G+1`. The rule never reads what the fold itself read, @@ -29,8 +29,10 @@ use std::sync::Arc; use crate::blob_store::v2::{ContentHash, FamilyPlane}; -use super::manifest_v2::{EntryV2, ManifestV2}; -use super::readiness::LiveEntries; +use super::manifest_v2::{ + EntryPlanes, EntryV2, ManifestHeader, ManifestV2, Producers, PublishedFrontier, +}; +use super::readiness::{LiveEntries, PlaneState}; use super::registry::ProtectedGeneration; use super::RelPath; @@ -74,36 +76,336 @@ pub enum Residency { /// One opened, immutable generation. Dropping the last `Arc` releases its /// residency pin. +/// +/// A plane cannot bypass composition through a raw manifest: +/// ```compile_fail +/// fn bypass(generation: &aft::views::snapshot::ComposedGeneration) { +/// let _ = generation.manifest(); +/// } +/// ``` #[derive(Debug)] -pub struct OpenGeneration { +pub struct ComposedGeneration { name: String, - manifest: Arc, + delta: Arc, + base: Option>, + overrides: BTreeMap, _residency: Option, } -impl OpenGeneration { +/// 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; + +impl ComposedGeneration { pub fn new( name: impl Into, manifest: ManifestV2, residency: Option, ) -> Self { + assert!( + manifest.base_header().is_none(), + "a worker must be opened with ComposedGeneration::compose" + ); Self { name: name.into(), - manifest: Arc::new(manifest), + delta: Arc::new(manifest), + base: None, + overrides: BTreeMap::new(), _residency: residency, } } + pub fn compose( + name: impl Into, + delta: ManifestV2, + base: Arc, + residency: Option, + ) -> super::Result { + if delta.base_header().is_none() + || base.base.is_some() + || base.delta.base_header().is_some() + { + return Err(super::ViewError::InvalidManifest( + "composition requires a worker and a depth-one base".into(), + )); + } + delta.ensure_producers(&base.header().producers)?; + let mut overrides = BTreeMap::new(); + for (path, patch) in delta.readiness_overrides() { + let Some(EntryV2::Regular { .. }) = base.entry(path) else { + return Err(super::ViewError::InvalidManifest( + "readiness override must name an inherited regular file".into(), + )); + }; + let mut entry = base.entry(path).unwrap().clone(); + if let EntryV2::Regular { planes, .. } = &mut entry { + for plane in FamilyPlane::ALL { + if let Some(state) = patch.get(plane) { + *planes.get_mut(plane) = Some(state.clone()); + } + } + } + overrides.insert(path.clone(), entry); + } + Ok(Self { + name: name.into(), + delta: Arc::new(delta), + base: Some(base), + overrides, + _residency: residency, + }) + } + pub fn name(&self) -> &str { &self.name } - pub fn manifest(&self) -> &ManifestV2 { - &self.manifest + pub fn resident_key(&self) -> String { + match &self.base { + Some(base) => format!( + "{}:{}:{}", + self.name, + self.base_header().unwrap().scope, + base.name() + ), + None => self.name.clone(), + } + } + + pub fn header(&self) -> &ManifestHeader { + self.delta.header() + } + pub fn base_header(&self) -> Option<&super::manifest_v2::BaseHeader> { + self.delta.base_header() + } + pub fn published(&self) -> Option<&PublishedFrontier> { + self.delta.published() + } + pub fn base(&self) -> Option<&Arc> { + self.base.as_ref() + } + pub fn ensure_producers(&self, producers: &Producers) -> super::Result<()> { + self.delta.ensure_producers(producers) + } + + pub fn entry(&self, path: &RelPath) -> Option<&EntryV2> { + if self.delta.tombstones().contains(path) { + return None; + } + self.delta + .get(path) + .or_else(|| self.overrides.get(path)) + .or_else(|| self.base.as_ref().and_then(|base| base.entry(path))) + } + + /// Sorted, lazy merge: a caller's existing walk budget stops the producing + /// iterator without first collecting the inherited membership. + pub fn entries(&self) -> impl Iterator { + let mut delta = self.delta.entries().peekable(); + let mut base = self + .base + .as_ref() + .into_iter() + .flat_map(|base| base.delta.entries()) + .peekable(); + std::iter::from_fn(move || loop { + let next = match (delta.peek(), base.peek()) { + (Some((d, _)), Some((b, _))) => match d.cmp(b) { + std::cmp::Ordering::Less => delta.next(), + std::cmp::Ordering::Greater => base.next(), + std::cmp::Ordering::Equal => { + base.next(); + delta.next() + } + }, + (Some(_), None) => delta.next(), + (None, Some(_)) => base.next(), + (None, None) => return None, + }; + if let Some((path, entry)) = next { + if !self.delta.tombstones().contains(path) { + return Some((path, self.overrides.get(path).unwrap_or(entry))); + } + } + }) + } + + pub fn plane_readiness(&self, path: &RelPath, plane: FamilyPlane) -> Option<&PlaneState> { + self.entry(path).and_then(|entry| entry.plane_state(plane)) + } + + pub fn owns_work(&self, path: &RelPath, plane: FamilyPlane) -> bool { + self.base.is_none() + || self.delta.get(path).is_some() + || self + .delta + .readiness_overrides() + .get(path) + .and_then(|patch| patch.get(plane)) + .is_some() + } + + pub fn segment_ids(&self) -> BTreeSet<[u8; 32]> { + self.delta + .segment_id() + .into_iter() + .chain(self.base.as_ref().and_then(|base| base.delta.segment_id())) + .collect() + } + + pub fn segment_id(&self) -> Option<[u8; 32]> { + self.delta + .segment_id() + .or_else(|| self.base.as_ref().and_then(|base| base.delta.segment_id())) + } + + pub fn owned_ready_keys(&self) -> impl Iterator + '_ { + self.delta.ready_keys() + } + pub fn ready_keys(&self) -> BTreeSet { + self.owned_ready_keys() + .chain( + self.base + .as_ref() + .into_iter() + .flat_map(|base| base.owned_ready_keys()), + ) + .collect() + } + + pub fn membership(&self) -> BTreeMap { + self.entries() + .filter_map(|(path, entry)| match entry { + EntryV2::Regular { content, size, .. } => Some(( + path.clone(), + DiskState::Present { + content: *content, + size: *size, + }, + )), + _ => None, + }) + .collect() + } + + pub fn len(&self) -> usize { + self.entries().count() + } + pub fn is_empty(&self) -> bool { + self.entries().next().is_none() + } + + pub fn diff_paths(&self, other: &Self) -> BTreeSet { + let mut changed = BTreeSet::new(); + for (path, entry) in self.entries() { + if other.entry(path) != Some(entry) + || FamilyPlane::ALL.into_iter().any(|plane| { + self.plane_readiness(path, plane) != other.plane_readiness(path, plane) + }) + { + changed.insert(path.clone()); + } + } + for (path, _) in other.entries() { + if self.entry(path).is_none() { + changed.insert(path.clone()); + } + } + changed + } + + /// Explicit export for legacy materializers and parity tests. Query consumers + /// use the lazy projection, rather than allocating a full worker copy. + pub fn flatten(&self) -> super::Result { + let mut manifest = ManifestV2::new(self.header().clone()); + if let Some(id) = self.segment_id() { + manifest.header_mut().segment = Some(crate::blob_store::v2::to_hex(&id)); + } + for (path, entry) in self.entries() { + let mut entry = entry.clone(); + if let EntryV2::Regular { planes, .. } = &mut entry { + for plane in FamilyPlane::ALL { + *planes.get_mut(plane) = self.plane_readiness(path, plane).cloned(); + } + } + manifest.insert(path.clone(), entry)?; + } + Ok(manifest) + } + + pub fn fold( + &self, + producers: &Producers, + updates: impl IntoIterator)>, + fill: &super::readiness::FillMap, + ) -> super::Result { + self.ensure_producers(producers)?; + let mut next = self.delta.as_ref().clone(); + for (path, entry) in updates { + if let (Some(base), Some(entry)) = (&self.base, &entry) { + if let Some(inherited) = base.entry(&path) { + let mut metadata = inherited.clone(); + if let (EntryV2::Regular { planes, .. }, Some(new_planes)) = + (&mut metadata, entry.planes()) + { + *planes = new_planes.clone(); + } + if &metadata == entry { + let patch = (entry != inherited) + .then(|| entry.planes().cloned()) + .flatten(); + next.inherit(path, patch); + continue; + } + } + } + next.set(path, entry)?; + } + for (path, entry) in next.entries_mut() { + if let EntryV2::Regular { + content, planes, .. + } = entry + { + apply_fills(path, content, planes, fill); + } + } + let overrides: Vec<_> = next + .readiness_overrides() + .iter() + .map(|(path, patch)| (path.clone(), patch.clone())) + .collect(); + for (path, mut patch) in overrides { + if let Some(content) = self.entry(&path).and_then(EntryV2::content) { + apply_fills(&path, &content, &mut patch, fill); + } + next.set_readiness_override(path, patch)?; + } + Ok(next) } pub fn disk_state(&self, rel_path: &RelPath) -> DiskState { - DiskState::in_generation(&self.manifest, rel_path) + match self.entry(rel_path) { + Some(EntryV2::Regular { content, size, .. }) => DiskState::Present { + content: *content, + size: *size, + }, + _ => DiskState::Absent, + } + } +} + +fn apply_fills( + path: &RelPath, + content: &ContentHash, + planes: &mut EntryPlanes, + fill: &super::readiness::FillMap, +) { + for plane in FamilyPlane::ALL { + if matches!(planes.get(plane), Some(PlaneState::Pending { .. })) { + if let Some(key) = fill.get(path, content, plane) { + *planes.get_mut(plane) = Some(PlaneState::ready(key)); + } + } } } @@ -159,18 +461,27 @@ pub enum WatcherState { /// Supplies plane attachments for an entry the switch creates because disk /// went back to the old generation's bytes. The old generation is pinned for /// the whole switch, so its keys are protected and nothing is recomputed. -pub type CarryFromGeneration<'a> = dyn Fn(&RelPath, &OpenGeneration) -> LiveEntry + 'a; +pub type CarryFromGeneration<'a> = dyn Fn(&RelPath, &ComposedGeneration) -> LiveEntry + 'a; type Entries = BTreeMap; +/// Checkout-specific readiness overrides for the recorded content hash. +/// Equal disk bytes do not erase the override; changed bytes cannot use it. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PlanePatch { + pub content: ContentHash, + pub planes: EntryPlanes, +} + /// The per-root live delta. Owned by the root actor; callers serialize access /// with the root's index write lock. #[derive(Debug)] pub struct LiveDelta { - base: Arc, + base: Arc, epoch: u64, version: u64, entries: Arc, + readiness: Arc>, journal: Vec, intent: Arc>, intent_version: u64, @@ -186,10 +497,11 @@ pub type RootIgnoreFiles = BTreeMap; /// The delta as a builder read it at version `v`. #[derive(Clone, Debug)] pub struct DeltaCut { - pub base: Arc, + pub base: Arc, pub epoch: u64, pub version: u64, entries: Arc, + readiness: Arc>, } impl DeltaCut { @@ -201,10 +513,12 @@ impl DeltaCut { /// A successor delta derived against a new generation, waiting for its swap. #[derive(Debug)] pub struct SuccessorDraft { - successor: Arc, + expected_base: Arc, + successor: Arc, epoch: u64, cut_version: u64, entries: Entries, + readiness: BTreeMap, } impl SuccessorDraft { @@ -223,12 +537,13 @@ pub enum SwitchError { } impl LiveDelta { - pub fn new(base: Arc) -> Self { + pub fn new(base: Arc) -> Self { Self { base, epoch: 0, version: 0, entries: Arc::new(Entries::new()), + readiness: Arc::new(BTreeMap::new()), journal: Vec::new(), intent: Arc::new(BTreeMap::new()), intent_version: 0, @@ -244,7 +559,7 @@ impl LiveDelta { self.root_ignore_files = files.map(Arc::new); } - pub fn base(&self) -> &Arc { + pub fn base(&self) -> &Arc { &self.base } @@ -263,6 +578,9 @@ impl LiveDelta { pub fn set_watcher(&mut self, watcher: WatcherState) { if self.watcher != watcher { self.version += 1; + if watcher != WatcherState::Healthy { + self.bump_epoch(); + } } self.watcher = watcher; } @@ -271,6 +589,25 @@ impl LiveDelta { self.entries.get(rel_path) } + pub fn set_readiness(&mut self, path: RelPath, patch: PlanePatch) { + self.version += 1; + let disk = self + .entries + .get(&path) + .map_or_else(|| self.base.disk_state(&path), |entry| entry.disk); + self.journal.retain(|record| record.rel_path != path); + self.journal.push(JournalRecord { + version: self.version, + rel_path: path.clone(), + disk, + }); + Arc::make_mut(&mut self.readiness).insert(path, patch); + } + + pub fn readiness(&self) -> &BTreeMap { + &self.readiness + } + pub fn journal(&self) -> &[JournalRecord] { &self.journal } @@ -314,6 +651,17 @@ impl LiveDelta { /// applied. Queries match such paths on their current bytes. pub fn record_intent(&mut self, rel_path: RelPath) -> u64 { self.intent_version += 1; + self.version += 1; + let disk = self + .entries + .get(&rel_path) + .map_or_else(|| self.base.disk_state(&rel_path), |entry| entry.disk); + self.journal.retain(|record| record.rel_path != rel_path); + self.journal.push(JournalRecord { + version: self.version, + rel_path: rel_path.clone(), + disk, + }); Arc::make_mut(&mut self.intent).insert(rel_path, self.intent_version); self.intent_version } @@ -345,6 +693,7 @@ impl LiveDelta { epoch: self.epoch, version: self.version, entries: Arc::clone(&self.entries), + readiness: Arc::clone(&self.readiness), } } @@ -357,9 +706,12 @@ impl LiveDelta { intent_version: self.intent_version, epoch: self.epoch, entries: Arc::clone(&self.entries), + readiness: Arc::clone(&self.readiness), intent: Arc::clone(&self.intent), watcher: self.watcher, root_ignore_files: self.root_ignore_files.clone(), + install_cut: None, + readers: None, }), } } @@ -378,6 +730,9 @@ impl LiveDelta { current: self.epoch, }); } + if !Arc::ptr_eq(&draft.expected_base, &self.base) { + return Err(SwitchError::BaseChanged); + } let replay = self .journal .iter() @@ -394,14 +749,81 @@ impl LiveDelta { carry, &mut draft.entries, ); + rederive_readiness( + &path, + self.readiness.get(&path), + &draft.successor, + &mut draft.readiness, + ); } self.base = draft.successor; self.entries = Arc::new(draft.entries); + self.readiness = Arc::new(draft.readiness); // One successor is in flight at a time per root, so nothing older than // the swap is needed again. self.journal.clear(); Ok(()) } + + pub fn from_snapshot(snapshot: &Snapshot) -> Self { + let inner = &snapshot.inner; + Self { + base: inner.generation.clone(), + epoch: inner.epoch, + version: inner.delta_version, + entries: inner.entries.clone(), + readiness: inner.readiness.clone(), + journal: Vec::new(), + intent: inner.intent.clone(), + intent_version: inner.intent_version, + watcher: inner.watcher, + root_ignore_files: inner.root_ignore_files.clone(), + } + } + + /// Replay paths delivered since a strict walk began. A path's latest intent + /// remains pending even if it has not yet supplied a stable content read. + pub fn replay_onto(&self, snapshot: &Snapshot, cut: &InstallCut) -> Result { + if self.epoch != cut.epoch { + return Err(SwitchError::EpochChanged { + cut: cut.epoch, + current: self.epoch, + }); + } + if !Arc::ptr_eq(&self.base, &cut.resident) { + return Err(SwitchError::BaseChanged); + } + let mut next = Self::from_snapshot(snapshot); + for record in self + .journal + .iter() + .filter(|record| record.version > cut.version) + { + let entry = self + .entries + .get(&record.rel_path) + .cloned() + .unwrap_or_else(|| carry_disk_state_only(&record.rel_path, &self.base)); + next.apply(record.rel_path.clone(), entry); + if let Some(patch) = self.readiness.get(&record.rel_path) { + next.set_readiness(record.rel_path.clone(), patch.clone()); + } + } + let intent = Arc::make_mut(&mut next.intent); + intent.retain(|path, _| self.intent.contains_key(path)); + intent.extend( + self.intent + .iter() + .filter(|(_, version)| **version > cut.intent_version) + .map(|(path, version)| (path.clone(), *version)), + ); + next.intent_version = self.intent_version; + next.epoch = self.epoch; + next.version = self.version; + next.watcher = self.watcher; + next.journal.clear(); + Ok(next) + } } impl LiveEntries for LiveDelta { @@ -412,6 +834,18 @@ impl LiveEntries for LiveDelta { fn live_state(&self, rel_path: &RelPath) -> Option<&DiskState> { self.entries.get(rel_path).map(|entry| &entry.disk) } + fn readiness_patch(&self, path: &RelPath, plane: FamilyPlane) -> Option<&PlaneState> { + let patch = self.readiness.get(path)?; + let disk = self + .entries + .get(path) + .map_or_else(|| self.base.disk_state(path), |entry| entry.disk); + if matches!(disk, DiskState::Present { content, .. } if content == patch.content) { + patch.planes.get(plane) + } else { + None + } + } } #[cfg(test)] @@ -420,7 +854,7 @@ mod mutation_tests { use crate::views::manifest_v2::{ManifestHeader, Producers}; fn delta() -> LiveDelta { - LiveDelta::new(Arc::new(OpenGeneration::new( + LiveDelta::new(Arc::new(ComposedGeneration::new( "base", ManifestV2::new(ManifestHeader { producers: Producers { @@ -436,6 +870,186 @@ mod mutation_tests { ))) } + struct LeaseAdapter { + resident: std::sync::atomic::AtomicBool, + releases: std::sync::atomic::AtomicUsize, + } + impl super::super::contracts::PlaneAdapter for LeaseAdapter { + fn plane(&self) -> FamilyPlane { + FamilyPlane::Trigram + } + fn producer(&self) -> String { + "test".into() + } + fn applies_to(&self, _: &RelPath) -> bool { + true + } + fn open_generation( + &self, + _: &super::super::contracts::ViewAccess, + _: &Arc, + ) -> Result<(), super::super::contracts::PlaneError> { + self.resident + .store(true, std::sync::atomic::Ordering::SeqCst); + Ok(()) + } + fn release_generation(&self, _: &super::super::contracts::ViewAccess, _: &str) { + self.releases + .fetch_add(1, std::sync::atomic::Ordering::SeqCst); + self.resident + .store(false, std::sync::atomic::Ordering::SeqCst); + } + fn readiness( + &self, + _: &super::super::contracts::ViewAccess, + _: &Snapshot, + ) -> super::super::readiness::PlaneReadiness { + super::super::readiness::PlaneReadiness::Ready { + pending: 0, + failed: 0, + } + } + } + + #[test] + fn retiring_reader_lease_cannot_remove_its_replacement() { + use std::sync::atomic::Ordering; + let root = tempfile::tempdir().unwrap(); + let storage = tempfile::tempdir().unwrap(); + let registry = + super::super::registry::FamilyRegistry::open(storage.path(), "leases").unwrap(); + let owner = registry + .register_view_with_storage(storage.path(), "worker", root.path()) + .unwrap(); + let access = super::super::contracts::ViewAccess::Owner(owner); + let concrete = Arc::new(LeaseAdapter { + resident: false.into(), + releases: 0.into(), + }); + let adapter: Arc = concrete.clone(); + let generation = delta().base().clone(); + let old = PlaneReaders::open(&access, generation.clone(), std::slice::from_ref(&adapter)) + .unwrap(); + let key = old.adapters[0].key.clone(); + let weak = Arc::downgrade(&old.adapters[0]); + let mut cache = reader_cache().lock().unwrap(); + let (done, completed) = std::sync::mpsc::channel(); + let retiring = std::thread::spawn(move || { + drop(old); + done.send(()).unwrap(); + }); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); + while weak.strong_count() != 0 && std::time::Instant::now() < deadline { + std::thread::sleep(std::time::Duration::from_millis(1)); + } + assert_eq!( + weak.strong_count(), + 0, + "the old destructor must be waiting for the cache lock" + ); + adapter.open_generation(&access, &generation).unwrap(); + let replacement = Arc::new(AdapterLease { + key: key.clone(), + adapter, + access, + generation, + }); + cache.insert(key, Arc::downgrade(&replacement)); + drop(cache); + completed + .recv_timeout(std::time::Duration::from_secs(5)) + .unwrap(); + retiring.join().unwrap(); + assert_eq!(concrete.releases.load(Ordering::SeqCst), 0); + assert!(concrete.resident.load(Ordering::SeqCst)); + drop(replacement); + assert_eq!(concrete.releases.load(Ordering::SeqCst), 1); + assert!(!concrete.resident.load(Ordering::SeqCst)); + } + + #[test] + fn locked_install_replays_observed_bytes_deletions_attachments_and_readiness() { + let mut live = delta(); + let cut = InstallCut { + seq: 0, + resident: live.base().clone(), + epoch: live.epoch(), + version: live.version(), + intent_version: 0, + }; + let changed = RelPath::new(b"changed.rs".to_vec()).unwrap(); + let transient = RelPath::new(b"transient.rs".to_vec()).unwrap(); + let mut manifest = live.base().flatten().unwrap(); + for path in [&changed, &transient] { + manifest + .insert( + path.clone(), + EntryV2::regular( + ContentHash::of(b"builder bytes"), + 13, + EntryPlanes::default(), + ), + ) + .unwrap(); + } + let draft = + LiveDelta::new(Arc::new(ComposedGeneration::new("draft", manifest, None))).snapshot(); + let disk = DiskState::of_bytes(b"latest delivered bytes"); + let mut latest = LiveEntry::new(disk, 2); + latest + .attachments + .insert(FamilyPlane::Trigram, Arc::new(42_usize)); + live.apply(changed.clone(), latest); + live.set_readiness( + changed.clone(), + PlanePatch { + content: ContentHash::of(b"latest delivered bytes"), + planes: EntryPlanes { + semantic: Some(PlaneState::pending("dependency changed")), + ..Default::default() + }, + }, + ); + live.apply( + transient.clone(), + LiveEntry::new(DiskState::of_bytes(b"temporary"), 3), + ); + live.apply(transient.clone(), LiveEntry::new(DiskState::Absent, 4)); + let next = live.replay_onto(&draft, &cut).unwrap(); + assert_eq!(next.snapshot().disk_state(&changed), disk); + assert_eq!(next.snapshot().disk_state(&transient), DiskState::Absent); + assert_eq!( + next.entry(&changed).unwrap().attachments[&FamilyPlane::Trigram] + .downcast_ref::(), + Some(&42) + ); + assert_eq!(next.readiness()[&changed], live.readiness()[&changed]); + assert_eq!(next.version(), live.version()); + } + + #[test] + fn draft_refuses_a_different_base_at_the_same_epoch() { + let mut delta = delta(); + let cut = delta.cut(); + let stale = derive_successor(&cut, cut.base.clone(), &carry_disk_state_only); + let other = Arc::new(ComposedGeneration::new( + "other", + cut.base.flatten().unwrap(), + None, + )); + delta + .replay_and_swap( + derive_successor(&cut, other, &carry_disk_state_only), + &carry_disk_state_only, + ) + .unwrap(); + assert_eq!( + delta.replay_and_swap(stale, &carry_disk_state_only), + Err(SwitchError::BaseChanged) + ); + assert_eq!(delta.base().name(), "other"); + } + #[test] fn unchanged_observations_do_not_grow_the_journal() { let mut delta = delta(); @@ -572,36 +1186,65 @@ mod mutation_tests { /// whose entry differs between the two generations. Paths outside this set /// are equal in both generations and had no live entry, so disk equals the /// successor there. -pub fn rebase_paths(cut: &DeltaCut, successor: &OpenGeneration) -> BTreeSet { +pub fn rebase_paths(cut: &DeltaCut, successor: &ComposedGeneration) -> BTreeSet { let mut paths = cut.keys().cloned().collect::>(); - paths.extend(cut.base.manifest().diff_paths(successor.manifest())); + paths.extend(cut.readiness.keys().cloned()); + paths.extend(cut.base.diff_paths(successor)); paths } /// Derives the successor delta of `cut` against `successor`. pub fn derive_successor( cut: &DeltaCut, - successor: Arc, + successor: Arc, carry: &CarryFromGeneration<'_>, ) -> SuccessorDraft { let mut entries = Entries::new(); + let mut readiness = BTreeMap::new(); for path in rebase_paths(cut, &successor) { let observed = cut.entries.get(&path).cloned(); rederive_path(&path, observed, &cut.base, &successor, carry, &mut entries); + rederive_readiness(&path, cut.readiness.get(&path), &successor, &mut readiness); } SuccessorDraft { + expected_base: cut.base.clone(), successor, epoch: cut.epoch, cut_version: cut.version, entries, + readiness, + } +} + +fn rederive_readiness( + path: &RelPath, + patch: Option<&PlanePatch>, + successor: &ComposedGeneration, + readiness: &mut BTreeMap, +) { + match patch { + Some(patch) + if successor.entry(path).and_then(EntryV2::content) != Some(patch.content) + || FamilyPlane::ALL.into_iter().any(|plane| { + patch + .planes + .get(plane) + .is_some_and(|state| successor.plane_readiness(path, plane) != Some(state)) + }) => + { + readiness.insert(path.clone(), patch.clone()); + } + _ => { + readiness.remove(path); + } } } fn rederive_path( path: &RelPath, observed: Option, - old: &Arc, - successor: &Arc, + old: &Arc, + successor: &Arc, carry: &CarryFromGeneration<'_>, entries: &mut Entries, ) { @@ -619,20 +1262,141 @@ fn rederive_path( /// A carry function that attaches nothing: the entry records only the old /// generation's disk state. -pub fn carry_disk_state_only(path: &RelPath, generation: &OpenGeneration) -> LiveEntry { +pub fn carry_disk_state_only(path: &RelPath, generation: &ComposedGeneration) -> LiveEntry { LiveEntry::new(generation.disk_state(path), 0) } -#[derive(Debug)] +#[derive(Clone, Debug)] struct SnapshotInner { - generation: Arc, + generation: Arc, delta_version: u64, intent_version: u64, epoch: u64, entries: Arc, + readiness: Arc>, intent: Arc>, watcher: WatcherState, root_ignore_files: Option>, + install_cut: Option, + readers: Option>, +} + +#[derive(Clone, Debug)] +pub struct InstallCut { + pub seq: u64, + pub resident: Arc, + pub epoch: u64, + pub version: u64, + pub intent_version: u64, +} + +/// A lease on resident adapters. Query snapshots retain this lease, so removing +/// the driver's old bundle cannot invalidate a lazy open by an old query. +pub struct PlaneReaders { + generation: Arc, + adapters: Vec>, +} + +struct AdapterLease { + key: ReaderCacheKey, + adapter: Arc, + access: super::contracts::ViewAccess, + generation: Arc, +} + +impl Drop for AdapterLease { + fn drop(&mut self) { + let mut cache = reader_cache() + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + // A new lease can replace a zero-strong-count weak entry while this + // destructor waits for the lock. Never remove that new resident. + if !cache + .get(&self.key) + .is_some_and(|lease| lease.strong_count() != 0) + { + cache.remove(&self.key); + self.adapter + .release_generation(&self.access, &self.generation.resident_key()); + } + } +} + +type ReaderCacheKey = (usize, String, String, String); +type ReaderCache = std::sync::Mutex>>; + +fn reader_cache() -> &'static ReaderCache { + static CACHE: std::sync::OnceLock = std::sync::OnceLock::new(); + CACHE.get_or_init(Default::default) +} + +impl fmt::Debug for PlaneReaders { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("PlaneReaders") + .field("generation", &self.generation.name()) + .finish() + } +} + +impl PlaneReaders { + pub fn open_available( + access: &super::contracts::ViewAccess, + generation: Arc, + adapters: &[Arc], + ) -> (Arc, Vec) { + let mut readers = Self { + generation: generation.clone(), + adapters: Vec::new(), + }; + let mut errors = Vec::new(); + for adapter in adapters { + match Self::open(access, generation.clone(), std::slice::from_ref(adapter)) { + Ok(open) => readers.adapters.extend(open.adapters.iter().cloned()), + Err(error) => errors.push(error), + } + } + (Arc::new(readers), errors) + } + + pub fn open( + access: &super::contracts::ViewAccess, + generation: Arc, + adapters: &[Arc], + ) -> Result, super::contracts::PlaneError> { + let mut readers = Self { + generation, + adapters: Vec::new(), + }; + for adapter in adapters { + let key = ( + Arc::as_ptr(adapter) as *const () as usize, + access.family().to_owned(), + access.scope().to_owned(), + readers.generation.resident_key(), + ); + let mut cache = reader_cache() + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + cache.retain(|_, lease| lease.strong_count() != 0); + let lease = match cache.get(&key).and_then(std::sync::Weak::upgrade) { + Some(lease) => lease, + None => { + adapter.open_generation(access, &readers.generation)?; + let lease = Arc::new(AdapterLease { + key: key.clone(), + adapter: adapter.clone(), + access: access.clone(), + generation: readers.generation.clone(), + }); + cache.insert(key, Arc::downgrade(&lease)); + lease + } + }; + drop(cache); + readers.adapters.push(lease); + } + Ok(Arc::new(readers)) + } } /// Where a path's answer comes from in one snapshot. Exactly one source per @@ -651,7 +1415,21 @@ pub struct Snapshot { } impl Snapshot { - pub fn generation(&self) -> &Arc { + pub fn install_cut(&self) -> Option<&InstallCut> { + self.inner.install_cut.as_ref() + } + pub fn with_install_cut(mut self, cut: InstallCut) -> Self { + Arc::make_mut(&mut self.inner).install_cut = Some(cut); + self + } + pub fn with_readers(mut self, readers: Arc) -> Self { + Arc::make_mut(&mut self.inner).readers = Some(readers); + self + } + pub fn readers(&self) -> Option<&Arc> { + self.inner.readers.as_ref() + } + pub fn generation(&self) -> &Arc { &self.inner.generation } @@ -689,11 +1467,30 @@ impl Snapshot { self.inner.entries.iter() } + pub fn readiness_patches(&self) -> &BTreeMap { + &self.inner.readiness + } + + pub fn plane_readiness(&self, path: &RelPath, plane: FamilyPlane) -> Option<&PlaneState> { + if let Some(patch) = self.inner.readiness.get(path) { + if matches!(self.disk_state(path), DiskState::Present { content, .. } if content == patch.content) + { + if let Some(state) = patch.planes.get(plane) { + return Some(state); + } + } + } + if self.inner.entries.contains_key(path) { + return None; + } + self.generation().plane_readiness(path, plane) + } + pub fn source(&self, rel_path: &RelPath) -> Source<'_> { if let Some(entry) = self.inner.entries.get(rel_path) { return Source::Live(entry); } - match self.inner.generation.manifest().get(rel_path) { + match self.inner.generation.entry(rel_path) { Some(entry) => Source::Generation(entry), None => Source::Absent, } @@ -710,7 +1507,7 @@ impl Snapshot { /// Every path the snapshot considers present, with its content. pub fn membership(&self) -> BTreeMap { let mut members = BTreeMap::new(); - for (path, entry) in self.inner.generation.manifest().entries() { + for (path, entry) in self.inner.generation.entries() { if let EntryV2::Regular { content, size, .. } = entry { members.insert( path.clone(), @@ -748,4 +1545,408 @@ impl LiveEntries for Snapshot { fn live_state(&self, rel_path: &RelPath) -> Option<&DiskState> { self.inner.entries.get(rel_path).map(|entry| &entry.disk) } + fn readiness_patch(&self, path: &RelPath, plane: FamilyPlane) -> Option<&PlaneState> { + let patch = self.inner.readiness.get(path)?; + if matches!(self.disk_state(path), DiskState::Present { content, .. } if content == patch.content) + { + patch.planes.get(plane) + } else { + None + } + } +} + +#[cfg(test)] +mod composition_tests { + use super::super::manifest_v2::{BaseHeader, PublishedFrontier}; + use super::super::readiness::{self, FillMap, PlaneReadiness}; + use super::*; + + fn path(name: &str) -> RelPath { + RelPath::new(name.as_bytes().to_vec()).unwrap() + } + fn header() -> ManifestHeader { + ManifestHeader { + producers: Producers { + trigram: "tri".into(), + semantic: Some("sem".into()), + callgraph: super::super::callgraph::PRODUCER.into(), + }, + head_tree: None, + ignore_fingerprint: None, + segment: Some("11".repeat(32)), + } + } + fn planes(state: PlaneState) -> EntryPlanes { + EntryPlanes { + trigram: Some(state.clone()), + semantic: Some(state.clone()), + callgraph: Some(state), + } + } + fn entry(bytes: &[u8], state: PlaneState) -> EntryV2 { + EntryV2::regular(ContentHash::of(bytes), bytes.len() as u64, planes(state)) + } + fn ready() -> PlaneState { + PlaneState::Ready { + key: "22".repeat(32), + } + } + fn worker() -> ManifestV2 { + let mut header = header(); + header.segment = None; + ManifestV2::new_v3( + header, + Some(BaseHeader { + scope: "base".into(), + membership_digest: "33".repeat(32), + identity_digest: "44".repeat(32), + bound_generation: "base-generation".into(), + }), + PublishedFrontier { + delta_cut_version: 7, + watcher_epoch: 2, + published_at_ms: 50, + verified_frontier: None, + }, + ) + } + fn base() -> Arc { + let mut manifest = ManifestV2::new_v3(header(), None, PublishedFrontier::default()); + for name in ["inherited.rs", "deleted.rs", "replaced.rs", "exception.rs"] { + manifest + .insert( + path(name), + entry(name.as_bytes(), PlaneState::pending("base fill")), + ) + .unwrap(); + } + Arc::new(ComposedGeneration::new("base-generation", manifest, None)) + } + + #[test] + fn composed_answers_equal_independently_flattened_manifest_for_every_plane() { + let base = base(); + let mut delta = worker(); + delta.set(path("deleted.rs"), None).unwrap(); + delta + .set(path("replaced.rs"), Some(entry(b"replacement", ready()))) + .unwrap(); + delta + .set(path("added.rs"), Some(entry(b"addition", ready()))) + .unwrap(); + delta + .set_readiness_override(path("exception.rs"), planes(ready())) + .unwrap(); + let composed = ComposedGeneration::compose("worker", delta, base, None).unwrap(); + let mut flat = ManifestV2::new(header()); + flat.insert( + path("inherited.rs"), + entry(b"inherited.rs", PlaneState::pending("base fill")), + ) + .unwrap(); + flat.insert(path("exception.rs"), entry(b"exception.rs", ready())) + .unwrap(); + flat.insert(path("replaced.rs"), entry(b"replacement", ready())) + .unwrap(); + flat.insert(path("added.rs"), entry(b"addition", ready())) + .unwrap(); + let flat = ComposedGeneration::new("flat", flat, None); + assert_eq!( + composed.entries().collect::>(), + flat.entries().collect::>() + ); + assert_eq!(composed.membership(), flat.membership()); + assert_eq!(composed.segment_ids(), flat.segment_ids()); + let live = LiveDelta::new(Arc::new(ComposedGeneration::new( + "empty", + ManifestV2::new(header()), + None, + ))); + for name in [ + "inherited.rs", + "deleted.rs", + "replaced.rs", + "exception.rs", + "added.rs", + "unknown.rs", + ] { + let path = path(name); + assert_eq!(composed.entry(&path), flat.entry(&path)); + assert_eq!(composed.disk_state(&path), flat.disk_state(&path)); + for plane in FamilyPlane::ALL { + assert_eq!( + composed.plane_readiness(&path, plane), + flat.plane_readiness(&path, plane) + ); + assert_eq!( + readiness::plane_readiness( + plane, + Some(&composed), + &live, + &FillMap::default(), + &|_| true + ), + readiness::plane_readiness( + plane, + Some(&flat), + &live, + &FillMap::default(), + &|_| true + ) + ); + } + } + // The graph projection is honest about the one inherited pending member. + assert!(super::super::callgraph::project_manifest(&composed).is_err()); + assert_eq!(composed.flatten().unwrap(), flat.flatten().unwrap()); + } + + #[test] + fn partial_overrides_preserve_other_planes_and_delta_none_does_not_inherit() { + let path = path("partial.rs"); + let replaced = self::path("replaced.rs"); + let original = EntryPlanes { + trigram: Some(ready()), + semantic: Some(PlaneState::pending("base semantic")), + callgraph: Some(PlaneState::Failed { + reason: "extractor failed".into(), + producer: "extractor".into(), + }), + }; + let mut manifest = ManifestV2::new(header()); + manifest + .insert( + path.clone(), + EntryV2::regular(ContentHash::of(b"same"), 4, original.clone()), + ) + .unwrap(); + manifest + .insert( + replaced.clone(), + EntryV2::regular(ContentHash::of(b"old"), 3, original.clone()), + ) + .unwrap(); + let base = Arc::new(ComposedGeneration::new("base", manifest, None)); + let mut delta = worker(); + delta + .set_readiness_override( + path.clone(), + EntryPlanes { + semantic: Some(ready()), + ..Default::default() + }, + ) + .unwrap(); + delta + .set( + replaced.clone(), + Some(EntryV2::regular( + ContentHash::of(b"new"), + 3, + EntryPlanes::default(), + )), + ) + .unwrap(); + let composed = ComposedGeneration::compose("worker", delta, base, None).unwrap(); + assert_eq!( + composed.plane_readiness(&path, FamilyPlane::Trigram), + original.trigram.as_ref() + ); + assert_eq!( + composed.plane_readiness(&path, FamilyPlane::Semantic), + Some(&ready()) + ); + assert_eq!( + composed.plane_readiness(&path, FamilyPlane::Callgraph), + original.callgraph.as_ref() + ); + for plane in FamilyPlane::ALL { + assert_eq!(composed.plane_readiness(&replaced, plane), None); + } + } + + #[test] + fn worker_queue_excludes_inherited_base_work_but_counts_its_gap() { + let mut delta = worker(); + delta + .set_readiness_override( + path("exception.rs"), + planes(PlaneState::pending("dependency changed")), + ) + .unwrap(); + let composed = + Arc::new(ComposedGeneration::compose("worker", delta, base(), None).unwrap()); + let live = LiveDelta::new(composed.clone()); + for plane in FamilyPlane::ALL { + assert_eq!( + readiness::plane_readiness( + plane, + Some(&composed), + &live, + &FillMap::default(), + &|_| true + ), + PlaneReadiness::Ready { + pending: 4, + failed: 0 + } + ); + let work = readiness::work_queue( + plane, + "producer", + Some(&composed), + &live, + &FillMap::default(), + &|_| true, + ); + assert_eq!( + work.iter() + .map(|item| item.rel_path.clone()) + .collect::>(), + vec![path("exception.rs")] + ); + } + } + + #[test] + fn equality_preserves_readiness_and_rebase_drops_only_satisfied_patches() { + let base = base(); + let path = path("exception.rs"); + let mut live = LiveDelta::new(base.clone()); + live.set_readiness( + path.clone(), + PlanePatch { + content: ContentHash::of(b"exception.rs"), + planes: planes(ready()), + }, + ); + live.apply(path.clone(), LiveEntry::new(base.disk_state(&path), 1)); + assert!(live.entry(&path).is_none()); + assert!(live.readiness().contains_key(&path)); + assert!(rebase_paths(&live.cut(), &base).contains(&path)); + let draft = derive_successor(&live.cut(), base.clone(), &carry_disk_state_only); + live.replay_and_swap(draft, &carry_disk_state_only).unwrap(); + assert!(live.readiness().contains_key(&path)); + let mut next = base.flatten().unwrap(); + next.set(path.clone(), Some(entry(b"exception.rs", ready()))) + .unwrap(); + let next = Arc::new(ComposedGeneration::new("filled", next, None)); + let draft = derive_successor(&live.cut(), next, &carry_disk_state_only); + live.replay_and_swap(draft, &carry_disk_state_only).unwrap(); + assert!(live.readiness().is_empty()); + } + + #[test] + fn a_readiness_patch_never_vouches_for_changed_content() { + let path = path("exception.rs"); + let mut manifest = base().flatten().unwrap(); + manifest + .set(path.clone(), Some(entry(b"exception.rs", ready()))) + .unwrap(); + let base = Arc::new(ComposedGeneration::new("ready-base", manifest, None)); + let mut live = LiveDelta::new(base); + live.set_readiness( + path.clone(), + PlanePatch { + content: ContentHash::of(b"exception.rs"), + planes: planes(ready()), + }, + ); + live.apply( + path.clone(), + LiveEntry::new(DiskState::of_bytes(b"changed bytes"), 1), + ); + assert!(live.readiness().contains_key(&path)); + for plane in FamilyPlane::ALL { + assert_eq!(live.snapshot().plane_readiness(&path, plane), None); + } + } + + #[test] + fn fold_persists_only_worker_override_fills_and_keeps_the_base_reference() { + use super::super::readiness::{Admission, Completion, WorkItem}; + let base = base(); + let path = path("exception.rs"); + let mut delta = worker(); + delta + .set_readiness_override( + path.clone(), + EntryPlanes { + semantic: Some(PlaneState::pending("worker dependency")), + ..Default::default() + }, + ) + .unwrap(); + let composed = ComposedGeneration::compose("worker", delta, base.clone(), None).unwrap(); + let key = crate::blob_store::v2::FamilyKey::new(FamilyPlane::Semantic, [0x55; 32]); + let completion = Completion { + item: WorkItem { + plane: FamilyPlane::Semantic, + rel_path: path.clone(), + content: ContentHash::of(b"exception.rs"), + producer: "sem".into(), + }, + key, + }; + let mut fill = FillMap::default(); + assert_eq!( + fill.admit(&completion, "sem", None, Some(&composed)), + Admission::Installed + ); + let folded = readiness::fold(&composed, &header().producers, [], &fill).unwrap(); + assert_eq!(folded.base_header(), composed.base_header()); + assert!(folded.is_empty()); + assert_eq!(folded.ready_keys().collect::>(), vec![key]); + let next = ComposedGeneration::compose("folded", folded, base, None).unwrap(); + assert_eq!( + next.plane_readiness(&path, FamilyPlane::Semantic), + Some(&PlaneState::ready(&key)) + ); + assert!(next + .plane_readiness(&self::path("inherited.rs"), FamilyPlane::Semantic) + .unwrap() + .is_pending()); + } + + #[test] + fn v3_sections_roundtrip_and_ready_keys_include_deduplicated_overrides() { + let mut delta = worker(); + delta + .set(path("added.rs"), Some(entry(b"added", ready()))) + .unwrap(); + delta.set(path("deleted.rs"), None).unwrap(); + delta + .set_readiness_override(path("exception.rs"), planes(ready())) + .unwrap(); + let bytes = delta.to_json_bytes().unwrap(); + assert_eq!(ManifestV2::from_json_bytes(&bytes).unwrap(), delta); + let keys: Vec<_> = delta.ready_keys().collect(); + assert_eq!(keys.len(), 3); + delta.set(path("added.rs"), None).unwrap(); + assert_eq!(delta.ready_keys().collect::>(), keys); + let mut json: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); + json["readiness_overrides"][0]["rel_path"] = json["entries"][0]["rel_path"].clone(); + assert!(ManifestV2::from_json_bytes(&serde_json::to_vec(&json).unwrap()).is_err()); + } + + #[test] + #[should_panic(expected = "a worker must be opened with ComposedGeneration::compose")] + fn a_worker_cannot_be_opened_as_a_flat_generation() { + ComposedGeneration::new("worker", worker(), None); + } + + #[test] + fn composition_rejects_base_chains_and_missing_override_members() { + let first = Arc::new(ComposedGeneration::compose("first", worker(), base(), None).unwrap()); + assert!(ComposedGeneration::compose("chain", worker(), first, None).is_err()); + let mut delta = worker(); + delta + .set_readiness_override(path("unknown.rs"), planes(ready())) + .unwrap(); + assert!(ComposedGeneration::compose("bad", delta, base(), None).is_err()); + let mut incompatible = worker(); + incompatible.header_mut().producers.trigram = "other policy".into(); + assert!(ComposedGeneration::compose("incompatible", incompatible, base(), None).is_err()); + } } diff --git a/crates/aft/src/views/trigram.rs b/crates/aft/src/views/trigram.rs index 4172aedaf5..f31b0e9e4f 100644 --- a/crates/aft/src/views/trigram.rs +++ b/crates/aft/src/views/trigram.rs @@ -55,7 +55,7 @@ impl GenerationIndex { .iter() .map(|file| (&file.rel_path, file.content)) .collect::>(); - for (path, entry) in snapshot.generation().manifest().entries() { + for (path, entry) in snapshot.generation().entries() { let Some(content) = entry.content() else { continue; }; @@ -63,7 +63,9 @@ impl GenerationIndex { continue; } let key = crate::blob_store::v2::TrigramKey { content, policy }.family_key(); - if entry.plane_state(FamilyPlane::Trigram) + if snapshot + .generation() + .plane_readiness(path, FamilyPlane::Trigram) != Some(&super::readiness::PlaneState::ready(&key)) { continue; @@ -85,12 +87,11 @@ impl GenerationIndex { payloads: &BTreeMap, ) -> Result { if segment.policy_fingerprint() != policy.fingerprint() - || snapshot.generation().manifest().header().producers.trigram - != policy.fingerprint_hex() + || snapshot.generation().header().producers.trigram != policy.fingerprint_hex() { return Err("trigram producer mismatch".into()); } - let manifest = snapshot.generation().manifest(); + let manifest = snapshot.generation(); let mut overlay = BTreeMap::new(); let base = segment .files() @@ -99,7 +100,7 @@ impl GenerationIndex { .collect::>(); for path in base.keys() { if manifest - .get(path) + .entry(path) .and_then(|entry| entry.content()) .is_none() { @@ -383,7 +384,7 @@ pub type Publisher = dyn Fn( &super::contracts::ViewAccess, &Snapshot, bool, - ) -> Result, super::contracts::PlaneError> + ) -> Result, super::contracts::PlaneError> + Send + Sync; pub type Opener = dyn Fn( @@ -489,6 +490,7 @@ impl TrigramDriver { Ok(super::first_load::ReconciledCheckout { revision: revision(&delta), entries: walk.entries, + cut: None, }) } pub fn revision(&self, _: &super::contracts::ViewAccess) -> u64 { @@ -499,7 +501,7 @@ impl TrigramDriver { access: &super::contracts::ViewAccess, snapshot: &Snapshot, seed_derived: bool, - ) -> Result, super::contracts::PlaneError> { + ) -> Result, super::contracts::PlaneError> { self.validate(access)?; (self.publish)(access, snapshot, seed_derived) } @@ -737,11 +739,7 @@ impl TrigramAdapter { ) -> Result { let id = snapshot .generation() - .manifest() - .header() - .segment - .as_ref() - .and_then(|id| crate::blob_store::v2::parse_hex32(id)) + .segment_id() .ok_or_else(|| plane_error("generation has no trigram segment"))?; let segment_key = (access.family().to_string(), id); let cached = { @@ -811,14 +809,14 @@ impl super::contracts::PlaneAdapter for TrigramAdapter { fn open_generation( &self, access: &super::contracts::ViewAccess, - generation: &Arc, + generation: &Arc, ) -> Result<(), super::contracts::PlaneError> { let snapshot = super::snapshot::LiveDelta::new(generation.clone()).snapshot(); let index = Arc::new(self.open_index(access, &snapshot)?); self.residents .lock() .unwrap_or_else(|e| e.into_inner()) - .insert((access.scope().into(), generation.name().into()), index); + .insert((access.scope().into(), generation.resident_key()), index); Ok(()) } fn release_generation(&self, access: &super::contracts::ViewAccess, generation: &str) { @@ -836,7 +834,7 @@ impl super::contracts::PlaneAdapter for TrigramAdapter { let Some(index) = residents .get(&( access.scope().to_owned(), - snapshot.generation().name().to_owned(), + snapshot.generation().resident_key(), )) .cloned() else { @@ -853,7 +851,6 @@ impl super::contracts::PlaneAdapter for TrigramAdapter { let pending = index.candidates(snapshot, &members, "").direct.len(); let failed = snapshot .generation() - .manifest() .entries() .filter(|(path, entry)| { !matches!(snapshot.source(path), Source::Live(_)) @@ -893,7 +890,7 @@ mod tests { } use super::super::contracts::{PlaneAdapter, ViewAccess}; use super::super::manifest_v2::{ManifestHeader, ManifestV2, Producers}; - use super::super::snapshot::{LiveDelta, OpenGeneration}; + use super::super::snapshot::{ComposedGeneration, LiveDelta}; use super::*; fn fixture() -> ( @@ -929,7 +926,11 @@ mod tests { ignore_fingerprint: None, segment: Some(crate::blob_store::v2::to_hex(&segment.id)), }); - let delta = LiveDelta::new(Arc::new(OpenGeneration::new("generation", manifest, None))); + let delta = LiveDelta::new(Arc::new(ComposedGeneration::new( + "generation", + manifest, + None, + ))); let adapter = Arc::new(TrigramAdapter::new(storage.path().to_path_buf(), policy)); (storage, root, ViewAccess::Owner(owner), adapter, delta) } diff --git a/crates/aft/tests/integration/branch_switch_test.rs b/crates/aft/tests/integration/branch_switch_test.rs index 217c4c02e0..eaa46f46cd 100644 --- a/crates/aft/tests/integration/branch_switch_test.rs +++ b/crates/aft/tests/integration/branch_switch_test.rs @@ -565,7 +565,8 @@ fn views_round_trip_reuses_semantic_blobs_without_embedding() { .expect("views-on semantic view") .installed() .generation() - .manifest() + .flatten() + .unwrap() .entries() .filter(|(_, entry)| { matches!( @@ -667,7 +668,8 @@ fn wait_for_semantic_view(ctx: &AppContext) { && snapshot.live_entries().next().is_none() && !snapshot .generation() - .manifest() + .flatten() + .unwrap() .entries() .any(|(_, entry)| { entry diff --git a/crates/aft/tests/integration/per_checkout_callgraph.rs b/crates/aft/tests/integration/per_checkout_callgraph.rs index d4483c80c4..adbfa4369b 100644 --- a/crates/aft/tests/integration/per_checkout_callgraph.rs +++ b/crates/aft/tests/integration/per_checkout_callgraph.rs @@ -87,7 +87,12 @@ fn query_fixture(with_graph: bool) -> QueryFixture { let database = views.derived_path(&name.to_string()).unwrap(); std::fs::create_dir_all(database.parent().unwrap()).unwrap(); let store = view.open_store(FamilyPlane::Callgraph).unwrap(); - callgraph::materialize(&database, &manifest, &BlobReader(store.reader())).unwrap(); + callgraph::materialize( + &database, + &OpenGeneration::new("projected", manifest.clone(), None), + &BlobReader(store.reader()), + ) + .unwrap(); let prepared = views.prepare_v2(&name, None, &manifest, None).unwrap(); views.commit_v2(prepared, None).unwrap(); let marker = @@ -271,6 +276,7 @@ impl FirstLoadDriver for Driver { let mut live = LiveEntry::new(DiskState::of_bytes(&self.source), self.revision(access)); callgraph::attach(&mut live, &self.source, "typescript")?; Ok(ReconciledCheckout { + cut: None, revision: self.revision(access), entries: BTreeMap::from([(RelPath::new(b"fixture.ts").unwrap(), live)]), }) @@ -323,7 +329,11 @@ impl FirstLoadDriver for Driver { let views = view.view_store().map_err(error)?; let database = views.derived_path(&name.to_string()).map_err(error)?; std::fs::create_dir_all(database.parent().unwrap()).map_err(error)?; - callgraph::materialize(&database, &manifest, &BlobReader(store.reader()))?; + callgraph::materialize( + &database, + &OpenGeneration::new("projected", manifest.clone(), None), + &BlobReader(store.reader()), + )?; let prepared = views .prepare_v2(&name, None, &manifest, None) .map_err(error)?; @@ -460,7 +470,8 @@ fn store_clone_pins_old_blobs_after_swap_and_gc() { let held_store = reader.store.clone(); let old_name = old.name().to_string(); let key_hex = old - .manifest() + .flatten() + .unwrap() .entries() .find_map(|(_, e)| match e.plane_state(FamilyPlane::Callgraph) { Some(PlaneState::Ready { key }) => Some(key.clone()), @@ -475,7 +486,12 @@ fn store_clone_pins_old_blobs_after_swap_and_gc() { let name = GenerationName::for_manifest(&empty).unwrap(); let database = views.derived_path(&name.to_string()).unwrap(); let store = view.open_store(FamilyPlane::Callgraph).unwrap(); - callgraph::materialize(&database, &empty, &BlobReader(store.reader())).unwrap(); + callgraph::materialize( + &database, + &OpenGeneration::new("empty", empty.clone(), None), + &BlobReader(store.reader()), + ) + .unwrap(); let prepared = views .prepare_v2(&name, Some(&old_name), &empty, None) .unwrap(); diff --git a/crates/aft/tests/integration/per_checkout_trigram.rs b/crates/aft/tests/integration/per_checkout_trigram.rs index 6f99025cac..1cdd7107f0 100644 --- a/crates/aft/tests/integration/per_checkout_trigram.rs +++ b/crates/aft/tests/integration/per_checkout_trigram.rs @@ -480,7 +480,8 @@ fn trigram_ten_and_forty_view_memory() { + std::mem::size_of::() + delta .base() - .manifest() + .flatten() + .unwrap() .entries() .map(|(path, entry)| { path.as_bytes().len() @@ -557,7 +558,7 @@ fn trigram_persistence_child() { .register_view_with_storage(registry.storage(), "scope", &root) .unwrap(); let files: &[(&str, &[u8])] = &[("a.txt", b"old\n")]; - let mut manifest = generation(files, "a").manifest().clone(); + let mut manifest = generation(files, "a").flatten().unwrap().clone(); let store = registration.open_store(FamilyPlane::Trigram).unwrap(); let key = TrigramKey { content: ContentHash::of(files[0].1), @@ -686,7 +687,7 @@ fn trigram_driver_rejects_obsolete_install_and_reports_actual_gaps() { write(&root, "a.txt", b"old\n"); let files: &[(&str, &[u8])] = &[("a.txt", b"old\n")]; let generation = generation(files, "a"); - let producers = generation.manifest().header().producers.clone(); + let producers = generation.flatten().unwrap().header().producers.clone(); let delta = Arc::new(Mutex::new(LiveDelta::new(generation))); let registry = aft::views::registry::FamilyRegistry::open(storage.path(), "family").unwrap(); let access = ViewAccess::Owner( @@ -833,7 +834,10 @@ fn trigram_materializer_preserves_shared_segment_and_other_planes() { .register_view_with_storage(registry.storage(), "scope", &root) .unwrap(); let access = ViewAccess::Owner(registration.clone()); - let mut manifest = generation(&[("a.txt", b"old\n")], "a").manifest().clone(); + let mut manifest = generation(&[("a.txt", b"old\n")], "a") + .flatten() + .unwrap() + .clone(); if let EntryV2::Regular { planes, .. } = manifest.get_mut(&rel("a.txt")).unwrap() { planes.semantic = Some(PlaneState::pending("model work")); } @@ -863,7 +867,8 @@ fn trigram_materializer_preserves_shared_segment_and_other_planes() { .set( rel("renamed.txt"), generation(&[("renamed.txt", b"new needle\n")], "b") - .manifest() + .flatten() + .unwrap() .get(&rel("renamed.txt")) .cloned(), ) @@ -919,7 +924,7 @@ fn trigram_materializer_pins_all_keys_with_one_key_file_write() { let registration = registry .register_view_with_storage(registry.storage(), "scope", &root) .unwrap(); - let mut manifest = generation(&files, "a").manifest().clone(); + let mut manifest = generation(&files, "a").flatten().unwrap().clone(); let observed = live_delta::strict_walk(&root, &policy(), 1); let (writes_before, syncs_before) = key_file_work(); let materialized = materialize( From 7aab4ea687dc5a1f109d60246e903aa501e51b67 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Sun, 25 Oct 2026 22:33:18 +0100 Subject: [PATCH 02/19] mason: retain composed semantic readers through bundle retirement Preserve reader-owned semantic runs and per-binding fill state across unbind and rebind. Old-snapshot readiness and pending work use the same pinned state as search. Adjust the existing chunk-index eviction test to retain runs while a bundle is held and preserve the original eviction assertion after the last owner drops; this is the required bundle-lifetime contract, not relaxed eviction. Restore the successful v2 publication's read-only pointer probe. Removing that pre-slice operation removed SQLite WAL/SHM sidecars and changed subsequent read-only-directory errors; keep the existing storage-error assertion unchanged. Co-authored-by: alfonso-aft <289616620+alfonso-aft@users.noreply.github.com> --- crates/aft/src/views/contracts.rs | 24 ++ crates/aft/src/views/first_load.rs | 6 + crates/aft/src/views/semantic.rs | 248 +++++++++++++++++--- crates/aft/src/views/semantic_read_tests.rs | 6 + crates/aft/src/views/snapshot.rs | 70 +++++- 5 files changed, 315 insertions(+), 39 deletions(-) diff --git a/crates/aft/src/views/contracts.rs b/crates/aft/src/views/contracts.rs index cc25ea1ef0..358f1cf194 100644 --- a/crates/aft/src/views/contracts.rs +++ b/crates/aft/src/views/contracts.rs @@ -211,6 +211,30 @@ pub trait PlaneAdapter: Send + Sync { generation: &Arc, ) -> Result<(), PlaneError>; + /// Open the resident state and optionally retain its actual read handle. + /// Adapters whose scope caches may be cleared while queries outlive them + /// return an owned handle; simpler adapters use the release-generation lease. + fn open_reader( + &self, + access: &ViewAccess, + generation: &Arc, + ) -> Result, PlaneError> { + self.open_generation(access, generation)?; + Ok(None) + } + + /// Bind a shared immutable reader to this bundle's query state. This runs + /// for every bundle, including cache hits, so a new binding cannot inherit + /// mutable fill state from a retired binding of the same generation. + fn bind_reader( + &self, + _access: &ViewAccess, + _generation: &Arc, + reader: Option, + ) -> Result, PlaneError> { + Ok(reader) + } + /// Drops resident state for a generation that no snapshot holds. fn release_generation(&self, access: &ViewAccess, generation: &str); diff --git a/crates/aft/src/views/first_load.rs b/crates/aft/src/views/first_load.rs index 3fffe5c1d1..45e1fd4920 100644 --- a/crates/aft/src/views/first_load.rs +++ b/crates/aft/src/views/first_load.rs @@ -1861,6 +1861,12 @@ impl FirstLoadDriver for CheckoutDriver { .registry() .note_publish(self.owner.scope()) .map_err(|error| SiblingLoader::error(error.to_string()))?; + // Successful publication must also be readable through the registry's + // read-only pointer path. Let SQLite establish its WAL sidecars here; + // skipping this probe changes later opens of a read-only view directory. + let _ = store + .current_generation_read_only() + .map_err(|error| SiblingLoader::error(error.to_string()))?; for adapter in &self.adapters { adapter.generation_published(access, &generation)?; } diff --git a/crates/aft/src/views/semantic.rs b/crates/aft/src/views/semantic.rs index 9fa69d86b3..8dd6beebed 100644 --- a/crates/aft/src/views/semantic.rs +++ b/crates/aft/src/views/semantic.rs @@ -487,6 +487,11 @@ struct Resident { legacy_summaries: BTreeSet, } +struct SemanticReader { + resident: Arc, + state: Arc>, +} + type CacheKey = (String, [u8; 32], u64, bool); /// Per-checkout mutable state: installed completions and failures that are @@ -639,6 +644,76 @@ impl SemanticPlane { ) } + fn query_view(&self, access: &ViewAccess, snapshot: &Snapshot) -> Arc> { + self.query_view_for(access.family(), access.scope(), snapshot) + } + + fn query_view_for( + &self, + family: &str, + scope: &str, + snapshot: &Snapshot, + ) -> Arc> { + if let Some(reader) = self.pinned_reader(family, scope, snapshot) { + return reader.state.clone(); + } + lock(&self.views) + .get(&(family.to_owned(), scope.to_owned())) + .cloned() + .unwrap_or_default() + } + + fn open_resident( + &self, + access: &ViewAccess, + generation: &Arc, + ) -> Result, PlaneError> { + let resident = if let Some(resident) = self.resident(access, &generation.resident_key()) { + resident + } else { + let resident = Arc::new(self.admit(access, generation)?); + let key = ( + access.family().to_owned(), + access.scope().to_owned(), + generation.resident_key(), + ); + lock(&self.residents).entry(key).or_insert(resident).clone() + }; + if matches!(access, ViewAccess::Reader { .. }) && !resident.legacy_summaries.is_empty() { + log::info!("semantic borrower loaded {} legacy small-budget summary runs; serving published vectors until owner repair", resident.legacy_summaries.len()); + } + Ok(resident) + } + + fn pinned_reader( + &self, + family: &str, + scope: &str, + snapshot: &Snapshot, + ) -> Option> { + let key = snapshot.generation().resident_key(); + let readers = snapshot.readers(); + readers + .and_then(|readers| readers.resource(self as *const Self as usize, family, scope, &key)) + .and_then(|resource| resource.downcast::().ok()) + } + + fn pinned_resident( + &self, + family: &str, + scope: &str, + snapshot: &Snapshot, + ) -> Option> { + let key = snapshot.generation().resident_key(); + self.pinned_reader(family, scope, snapshot) + .map(|reader| reader.resident.clone()) + .or_else(|| { + lock(&self.residents) + .get(&(family.to_owned(), scope.to_owned(), key)) + .cloned() + }) + } + fn resident(&self, access: &ViewAccess, generation: &str) -> Option> { lock(&self.residents) .get(&( @@ -913,7 +988,7 @@ impl SemanticPlane { return Err(plane_error("view belongs to another storage root")); } let resident = self - .resident(&access, &snapshot.generation().resident_key()) + .pinned_resident(access.family(), access.scope(), snapshot) .ok_or_else(|| plane_error("semantic generation not resident"))?; let view = self.view(&access); let mut report = FillReport::default(); @@ -1773,10 +1848,10 @@ impl SemanticPlane { snapshot: &Snapshot, ) -> Result { let resident = self - .resident(access, &snapshot.generation().resident_key()) + .pinned_resident(access.family(), access.scope(), snapshot) .ok_or_else(|| plane_error("semantic generation not resident"))?; let arena = self.arena(access.family()); - let view = self.view(access); + let view = self.query_view(access, snapshot); let mut state = lock(&view); let mut members = snapshot.membership(); let unvouched = snapshot.watcher() != WatcherState::Healthy @@ -1943,10 +2018,10 @@ impl SemanticPlane { /// Outstanding semantic work for the checkout, as a fill would see it. pub fn pending_work(&self, access: &ViewAccess, snapshot: &Snapshot) -> Vec { - let Some(resident) = self.resident(access, &snapshot.generation().resident_key()) else { + let Some(resident) = self.pinned_resident(access.family(), access.scope(), snapshot) else { return Vec::new(); }; - let view = self.view(access); + let view = self.query_view(access, snapshot); let state = lock(&view); self.work_items( &state, @@ -1956,9 +2031,10 @@ impl SemanticPlane { ) } - /// Forgets everything this process holds for a checkout that unbinds: - /// its fill map and live pin, its resident generations, and any arena run - /// no other view still references. + /// Remove mutable scope caches and lookup entries when a checkout unbinds. + /// Query bundles retain their readers and the ViewState whose live pin + /// protects unpublished fill keys until the last query drops. Only + /// unreferenced arena runs can be trimmed here. pub fn unbind(&self, access: &ViewAccess) { let family = access.family().to_owned(); let scope = access.scope().to_owned(); @@ -1991,22 +2067,33 @@ impl PlaneAdapter for SemanticPlane { access: &ViewAccess, generation: &Arc, ) -> Result<(), PlaneError> { - let key = ( - access.family().to_owned(), - access.scope().to_owned(), - generation.resident_key(), - ); - let existing = lock(&self.residents).get(&key).cloned(); - let resident = if let Some(resident) = existing { - resident - } else { - let resident = Arc::new(self.admit(access, generation)?); - Arc::clone(lock(&self.residents).entry(key).or_insert(resident)) + self.open_resident(access, generation).map(|_| ()) + } + + fn open_reader( + &self, + access: &ViewAccess, + generation: &Arc, + ) -> Result, PlaneError> { + let handle = self.open_resident(access, generation)?; + Ok(Some(handle)) + } + + fn bind_reader( + &self, + access: &ViewAccess, + _: &Arc, + handle: Option, + ) -> Result, PlaneError> { + let resident = handle + .and_then(|handle| handle.downcast::().ok()) + .ok_or_else(|| plane_error("semantic reader handle missing"))?; + let state = match access { + ViewAccess::Owner(_) => self.view(access), + ViewAccess::Reader { .. } => Arc::default(), }; - if matches!(access, ViewAccess::Reader { .. }) && !resident.legacy_summaries.is_empty() { - log::info!("semantic borrower loaded {} legacy small-budget summary runs; serving published vectors until owner repair", resident.legacy_summaries.len()); - } - Ok(()) + let reader = Arc::new(SemanticReader { resident, state }); + Ok(Some(reader)) } fn generation_published( @@ -2073,18 +2160,10 @@ impl SemanticPlane { snapshot: &Snapshot, repair_summaries: bool, ) -> PlaneReadiness { - let key = ( - family.to_owned(), - scope.to_owned(), - snapshot.generation().resident_key(), - ); - let Some(resident) = lock(&self.residents).get(&key).cloned() else { + let Some(resident) = self.pinned_resident(family, scope, snapshot) else { return PlaneReadiness::Building; }; - let view = lock(&self.views) - .get(&(family.to_owned(), scope.to_owned())) - .cloned() - .unwrap_or_default(); + let view = self.query_view_for(family, scope, snapshot); let state = lock(&view); let mut pending = 0; let mut failed = 0; @@ -2383,7 +2462,7 @@ mod tests { ) .unwrap(); let draft = Arc::new(ComposedGeneration::new("unpublished-draft", manifest, None)); - plane.open_generation(&checkout.access, &draft).unwrap(); + let _draft_reader = plane.open_reader(&checkout.access, &draft).unwrap(); assert_eq!(lock(&view).fill.len(), 1); assert!(lock(&view).live.as_ref().unwrap().keys_path().exists()); plane @@ -4021,6 +4100,96 @@ mod tests { ); } + #[test] + fn retired_semantic_readiness_uses_its_pinned_fills_after_unbind_and_rebind() { + let storage = tempfile::tempdir().unwrap(); + let root = tempfile::tempdir().unwrap(); + write_tree(root.path(), FILES); + let plane = new_plane(storage.path(), "model-a"); + let checkout = ready_checkout(storage.path(), root.path(), &plane); + let installed = checkout.installed(); + write_tree(root.path(), &[("src/alpha.rs", "fn alpha_edited() {}\n")]); + let mut delta = LiveDelta::new(installed.generation().clone()); + crate::views::live_delta::reconcile(&mut delta, root.path(), &trigram_policy()); + let snapshot = delta + .snapshot() + .with_readers(installed.readers().unwrap().clone()); + let model = Model::default(); + assert_eq!(checkout.fill(&snapshot, &model, "model-a").installed, 1); + let access = checkout.access.clone(); + let ready = PlaneReadiness::Ready { + pending: 0, + failed: 0, + }; + assert_eq!(plane.readiness(&access, &snapshot), ready); + plane.unbind(&access); + assert_eq!(plane.readiness(&access, &snapshot), ready); + assert!(plane.pending_work(&access, &snapshot).is_empty()); + assert!(!lock(&plane.views) + .contains_key(&(access.family().to_owned(), access.scope().to_owned()))); + + let adapters: Vec> = vec![plane.clone()]; + let readers = super::super::snapshot::PlaneReaders::open( + &access, + snapshot.generation().clone(), + &adapters, + ) + .unwrap(); + let rebound = snapshot.clone().with_readers(readers); + assert_eq!( + plane.readiness(&access, &rebound), + PlaneReadiness::Ready { + pending: 1, + failed: 0 + } + ); + assert_eq!(plane.readiness(&access, &snapshot), ready); + assert!(plane.pending_work(&access, &snapshot).is_empty()); + } + + #[test] + fn an_old_semantic_bundle_survives_unbind_without_reopening_or_resurrecting_scope_state() { + let storage = tempfile::tempdir().unwrap(); + let root = tempfile::tempdir().unwrap(); + write_tree(root.path(), FILES); + let plane = new_plane(storage.path(), "model-a"); + let checkout = ready_checkout(storage.path(), root.path(), &plane); + let bundle = checkout.driver.installed_bundle(); + let snapshot = checkout.installed(); + let access = checkout.access.clone(); + let expected = rows( + root.path(), + &checkout.query(&snapshot, "model-a", "alpha").results, + ); + assert!(!expected.is_empty()); + let arena = plane.arena("family"); + let work = (arena.decode_count(), arena.store_reads()); + plane.unbind(&access); + drop(checkout); + let answer = plane + .search( + &access, + root.path(), + &snapshot, + &vector("model-a", "alpha"), + 100, + &|_| true, + ) + .unwrap(); + assert!(answer.complete(), "{answer:?}"); + assert_eq!(rows(root.path(), &answer.results), expected); + assert_eq!((arena.decode_count(), arena.store_reads()), work); + assert!( + !lock(&plane.views) + .contains_key(&(access.family().to_owned(), access.scope().to_owned())), + "a retired query must not recreate mutable scope state" + ); + drop(bundle); + assert!(arena.memory().runs > 0); + drop(snapshot); + assert_eq!(arena.memory().runs, 0); + } + #[test] fn queries_never_decode_store_rows_and_unbind_releases_the_arena() { let storage = tempfile::tempdir().unwrap(); @@ -4073,8 +4242,17 @@ mod tests { ); plane.unbind(&checkout.access); + drop(checkout); + assert!( + arena.memory().runs > 0, + "the old query still owns its resident reader" + ); drop(generation); - assert_eq!(arena.memory().runs, 0, "unbind left runs resident"); + assert_eq!( + arena.memory().runs, + 0, + "unbind left runs resident after the last query drained" + ); } /// A fold may install a generation built from a cut taken before a fill diff --git a/crates/aft/src/views/semantic_read_tests.rs b/crates/aft/src/views/semantic_read_tests.rs index 72c59713a0..5636f44eb2 100644 --- a/crates/aft/src/views/semantic_read_tests.rs +++ b/crates/aft/src/views/semantic_read_tests.rs @@ -62,6 +62,12 @@ fn resident_chunk_shortcut_matches_store_and_full_rebuild() { plane.unbind(&checkout.access); drop(run); arena.trim(); + assert!( + arena.chunk_vector(&chunk_key).is_some(), + "unbind evicted a run while the installed bundle still holds its reader" + ); + drop(checkout); + arena.trim(); assert!( arena.chunk_vector(&chunk_key).is_none(), "the chunk index retained an evicted run" diff --git a/crates/aft/src/views/snapshot.rs b/crates/aft/src/views/snapshot.rs index 417ef6ee22..b8cd232b94 100644 --- a/crates/aft/src/views/snapshot.rs +++ b/crates/aft/src/views/snapshot.rs @@ -952,6 +952,7 @@ mod mutation_tests { key: key.clone(), adapter, access, + resource: None, generation, }); cache.insert(key, Arc::downgrade(&replacement)); @@ -1293,19 +1294,26 @@ pub struct InstallCut { /// A lease on resident adapters. Query snapshots retain this lease, so removing /// the driver's old bundle cannot invalidate a lazy open by an old query. pub struct PlaneReaders { - generation: Arc, + // Bundle-owned state drops before the shared reader and the generation Arc + // that retains the residency pins. + resources: Vec>, adapters: Vec>, + generation: Arc, } struct AdapterLease { key: ReaderCacheKey, adapter: Arc, access: super::contracts::ViewAccess, + resource: Option, generation: Arc, } impl Drop for AdapterLease { fn drop(&mut self) { + // Release the owned handle before adapter cleanup trims its arena. + // The generation Arc retains residency pins until the lease is dropped. + drop(self.resource.take()); let mut cache = reader_cache() .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); @@ -1339,6 +1347,25 @@ impl fmt::Debug for PlaneReaders { } impl PlaneReaders { + pub(super) fn resource( + &self, + adapter: usize, + family: &str, + scope: &str, + generation: &str, + ) -> Option { + self.adapters + .iter() + .zip(&self.resources) + .find(|(lease, _)| { + lease.key.0 == adapter + && lease.key.1 == family + && lease.key.2 == scope + && lease.key.3 == generation + }) + .and_then(|(_, resource)| resource.clone()) + } + pub fn open_available( access: &super::contracts::ViewAccess, generation: Arc, @@ -1347,11 +1374,15 @@ impl PlaneReaders { let mut readers = Self { generation: generation.clone(), adapters: Vec::new(), + resources: Vec::new(), }; let mut errors = Vec::new(); for adapter in adapters { match Self::open(access, generation.clone(), std::slice::from_ref(adapter)) { - Ok(open) => readers.adapters.extend(open.adapters.iter().cloned()), + Ok(open) => { + readers.adapters.extend(open.adapters.iter().cloned()); + readers.resources.extend(open.resources.iter().cloned()); + } Err(error) => errors.push(error), } } @@ -1366,6 +1397,7 @@ impl PlaneReaders { let mut readers = Self { generation, adapters: Vec::new(), + resources: Vec::new(), }; for adapter in adapters { let key = ( @@ -1381,11 +1413,12 @@ impl PlaneReaders { let lease = match cache.get(&key).and_then(std::sync::Weak::upgrade) { Some(lease) => lease, None => { - adapter.open_generation(access, &readers.generation)?; + let resource = adapter.open_reader(access, &readers.generation)?; let lease = Arc::new(AdapterLease { key: key.clone(), adapter: adapter.clone(), access: access.clone(), + resource, generation: readers.generation.clone(), }); cache.insert(key, Arc::downgrade(&lease)); @@ -1393,7 +1426,10 @@ impl PlaneReaders { } }; drop(cache); + let resource = + adapter.bind_reader(access, &readers.generation, lease.resource.clone())?; readers.adapters.push(lease); + readers.resources.push(resource); } Ok(Arc::new(readers)) } @@ -1858,8 +1894,27 @@ mod composition_tests { LiveEntry::new(DiskState::of_bytes(b"changed bytes"), 1), ); assert!(live.readiness().contains_key(&path)); + let snapshot = live.snapshot(); for plane in FamilyPlane::ALL { - assert_eq!(live.snapshot().plane_readiness(&path, plane), None); + assert_eq!(snapshot.plane_readiness(&path, plane), None); + for facts in [ + &live as &dyn readiness::LiveEntries, + &snapshot as &dyn readiness::LiveEntries, + ] { + assert_eq!( + readiness::plane_readiness( + plane, + Some(snapshot.generation()), + facts, + &FillMap::default(), + &|_| true + ), + PlaneReadiness::Ready { + pending: 4, + failed: 0 + } + ); + } } } @@ -1938,6 +1993,13 @@ mod composition_tests { #[test] fn composition_rejects_base_chains_and_missing_override_members() { + assert!(ComposedGeneration::compose( + "not-a-worker", + ManifestV2::new(header()), + base(), + None + ) + .is_err()); let first = Arc::new(ComposedGeneration::compose("first", worker(), base(), None).unwrap()); assert!(ComposedGeneration::compose("chain", worker(), first, None).is_err()); let mut delta = worker(); From d8d3f59d42384a7785e9af2b14121c82105f928d Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Mon, 26 Oct 2026 08:30:54 +0100 Subject: [PATCH 03/19] mason: preserve snapshot caches and summary ownership after rebase Keep main's owner-only summary repairs and saved-vector availability behind the composed worker ownership gate. Add a regression covering inherited summaries and pending files. Borrower overlays now live in a pinned reader binding, not the owner's scope cache. Add a snapshot-aware cache observation helper and migrate the upstream borrower parity test to it, retaining all old/new vector assertions and additionally checking the older reader cache survives a newer binding. Owner cache callers and routing are unchanged. Co-authored-by: alfonso-aft <289616620+alfonso-aft@users.noreply.github.com> --- crates/aft/src/views/semantic.rs | 84 ++++++++++++++++++++++++++++++-- 1 file changed, 80 insertions(+), 4 deletions(-) diff --git a/crates/aft/src/views/semantic.rs b/crates/aft/src/views/semantic.rs index 8dd6beebed..9d2e93f35f 100644 --- a/crates/aft/src/views/semantic.rs +++ b/crates/aft/src/views/semantic.rs @@ -1972,8 +1972,9 @@ impl SemanticPlane { }) } - /// The index `overlay` last built for the view, whatever snapshot it was - /// built for. Never builds one. + /// The index `overlay` last built for the owner's view, whatever snapshot + /// it was built for. Borrowers use `cached_index_for` to name their reader + /// binding. Never builds one. pub fn cached_index(&self, access: &ViewAccess) -> Option> { let view = lock(&self.views) .get(&(access.family().to_owned(), access.scope().to_owned())) @@ -1982,6 +1983,19 @@ impl SemanticPlane { state.cache.as_ref().map(|(_, index)| Arc::clone(index)) } + /// The last overlay cached in this snapshot's reader binding. Borrowers + /// have private caches, so a scope-only lookup could observe an owner's + /// unpublished fills or a different reader's generation. Never builds one. + pub fn cached_index_for( + &self, + access: &ViewAccess, + snapshot: &Snapshot, + ) -> Option> { + let view = self.query_view(access, snapshot); + let state = lock(&view); + state.cache.as_ref().map(|(_, index)| Arc::clone(index)) + } + /// How many scored indexes `overlay` has built. pub fn overlay_builds(&self) -> u64 { self.overlay_builds @@ -2800,7 +2814,7 @@ mod tests { assert!(query.complete()); assert!(!query.results.is_empty()); let old_summary = plane - .cached_index(&access) + .cached_index_for(&access, &before) .unwrap() .entry_vectors_for_test() .into_iter() @@ -2859,7 +2873,7 @@ mod tests { assert!(query.complete()); assert!(!query.results.is_empty()); let new_summary = plane - .cached_index(&access) + .cached_index_for(&access, &after) .unwrap() .entry_vectors_for_test() .into_iter() @@ -2868,6 +2882,18 @@ mod tests { .2; assert_ne!(old_summary, new_summary); assert_eq!(new_summary, vector("model-a", &embedded[0])); + assert_eq!( + plane + .cached_index_for(&access, &before) + .unwrap() + .entry_vectors_for_test() + .into_iter() + .find(|(_, name, _)| name == "summary") + .unwrap() + .2, + old_summary, + "a newer reader binding must not replace the old query's cache" + ); assert_ne!(before.generation().name(), after.generation().name()); assert!(plane.pending_work(&checkout.access, &published).is_empty()); let current = published.clone(); @@ -3062,6 +3088,56 @@ mod tests { .any(|row| row.name == "exported")); } + #[test] + fn composed_worker_never_repairs_inherited_summaries_or_fills_inherited_pending_files() { + use super::super::manifest_v2::{BaseHeader, EntryPlanes, EntryV2, ManifestV2}; + let storage = tempfile::tempdir().unwrap(); + let root = tempfile::tempdir().unwrap(); + let caps = EmbedTextCaps { + total_chars: 70, + ..EmbedTextCaps::default() + }; + let (legacy, _, _) = legacy_summary_checkout(storage.path(), root.path(), caps); + let mut manifest = legacy.installed().generation().flatten().unwrap(); + manifest + .insert( + RelPath::new(b"src/pending.rs".to_vec()).unwrap(), + EntryV2::regular( + ContentHash::of(b"pending"), + 7, + EntryPlanes { + semantic: Some(PlaneState::pending("base fill pending")), + ..Default::default() + }, + ), + ) + .unwrap(); + let header = manifest.header().clone(); + let base = Arc::new(ComposedGeneration::new("base", manifest, None)); + let worker = ManifestV2::new_v3( + header, + Some(BaseHeader { + scope: "base".into(), + membership_digest: "11".repeat(32), + identity_digest: "22".repeat(32), + bound_generation: "base".into(), + }), + Default::default(), + ); + let composed = Arc::new(ComposedGeneration::compose("worker", worker, base, None).unwrap()); + let plane = SemanticPlane::new( + storage.path().to_path_buf(), + SemanticProducer::current("model-a", caps), + ); + let resident = plane.admit(&legacy.access, &composed).unwrap(); + assert_eq!(resident.legacy_summaries.len(), 1); + let snapshot = LiveDelta::new(composed).snapshot(); + let state = ViewState::default(); + assert!(plane + .work_items(&state, &resident, &snapshot, true) + .is_empty()); + } + #[test] fn small_budget_fill_resumes_after_second_batch_pause_with_aligned_rows() { use crate::semantic_index::{retry_lane_pause, with_lane_pause_clock, PauseRetryScope}; From 8c5859b286f184e5df413c30b665b6e319ec04b5 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Mon, 26 Oct 2026 10:51:48 +0100 Subject: [PATCH 04/19] mason: fix Athena F2/c1 reader I/O under shared locks Use per-key opening/live/retiring slots while the global cache only owns weak slot references. Adapter opens, reader teardown and residency-pin cleanup run without the global or per-key lock. Token checks prevent an old destructor from retiring a replacement. Swap installed bundles and live deltas as whole owners, then release both root guards before last-drop cleanup. Likewise defer InstalledDriver and callgraph-map retirement outside their write locks. Three new regressions failed before the fix on blocked open, blocked retirement and root-lock retirement; final batched views and Windows verification follows the audit remediation. Co-authored-by: alfonso-aft <289616620+alfonso-aft@users.noreply.github.com> --- crates/aft/src/views/callgraph.rs | 8 +- crates/aft/src/views/first_load.rs | 181 ++++++++++--- crates/aft/src/views/snapshot.rs | 398 ++++++++++++++++++++++++++--- 3 files changed, 518 insertions(+), 69 deletions(-) diff --git a/crates/aft/src/views/callgraph.rs b/crates/aft/src/views/callgraph.rs index 83b69b2fd8..b8c6f3f83a 100644 --- a/crates/aft/src/views/callgraph.rs +++ b/crates/aft/src/views/callgraph.rs @@ -228,7 +228,8 @@ impl PlaneAdapter for CallgraphPlane { generation.name(), ) .map_err(error)?; - self.readers + let retired = self + .readers .write() .unwrap_or_else(std::sync::PoisonError::into_inner) .insert( @@ -238,13 +239,16 @@ impl PlaneAdapter for CallgraphPlane { generation: generation.clone(), }), ); + drop(retired); Ok(()) } fn release_generation(&self, access: &ViewAccess, generation: &str) { - self.readers + let retired = self + .readers .write() .unwrap_or_else(std::sync::PoisonError::into_inner) .remove(&(access.scope().into(), generation.into())); + drop(retired); } fn readiness(&self, access: &ViewAccess, snapshot: &Snapshot) -> PlaneReadiness { if !self diff --git a/crates/aft/src/views/first_load.rs b/crates/aft/src/views/first_load.rs index 45e1fd4920..8705fcdb83 100644 --- a/crates/aft/src/views/first_load.rs +++ b/crates/aft/src/views/first_load.rs @@ -1667,38 +1667,34 @@ impl FirstLoadDriver for CheckoutDriver { .installed .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); - let installed = Arc::make_mut(&mut installed); if cut.seq != installed.seq || !Arc::ptr_eq(&cut.resident, &installed.composed) { return Err(SiblingLoader::error( "BaseChanged: installed bundle superseded", )); } - installed.seq += 1; + let seq = installed.seq + 1; successor.set_watcher(super::snapshot::WatcherState::Healthy); - installed.composed = snapshot.generation().clone(); - installed.published = snapshot + let published = snapshot .generation() .published() .cloned() .unwrap_or_default(); - installed.readers = readers.clone(); - installed.snapshot = - successor - .snapshot() - .with_readers(readers) - .with_install_cut(InstallCut { - seq: installed.seq, - resident: installed.composed.clone(), - epoch: successor.epoch(), - version: successor.version(), - intent_version: successor.snapshot().intent_version(), - }); - installed.coverage = FamilyPlane::ALL + let installed_snapshot = successor + .snapshot() + .with_readers(readers.clone()) + .with_install_cut(InstallCut { + seq, + resident: snapshot.generation().clone(), + epoch: successor.epoch(), + version: successor.version(), + intent_version: successor.snapshot().intent_version(), + }); + let coverage = FamilyPlane::ALL .into_iter() .map(|plane| { ( plane, - if installed.snapshot.pending_intent().any(|path| { + if installed_snapshot.pending_intent().any(|path| { self.planes .iter() .any(|driver| driver.plane() == plane && driver.applies_to(path)) @@ -1707,8 +1703,8 @@ impl FirstLoadDriver for CheckoutDriver { } else { super::readiness::plane_readiness( plane, - Some(&installed.composed), - &installed.snapshot, + Some(snapshot.generation()), + &installed_snapshot, &Default::default(), &|_| true, ) @@ -1716,9 +1712,25 @@ impl FirstLoadDriver for CheckoutDriver { ) }) .collect(); - installed.uncertain_paths.clear(); - installed.own_installed = own_installed; - *live = successor; + let replacement = Arc::new(InstalledBundle { + seq, + composed: snapshot.generation().clone(), + coverage, + readers, + published, + revision: installed.revision, + snapshot: installed_snapshot, + uncertain_paths: Default::default(), + own_installed, + }); + let retired_bundle = std::mem::replace(&mut *installed, replacement); + let retired_delta = std::mem::replace(&mut *live, successor); + drop(installed); + drop(live); + // Last-drop reader close and residency-pin cleanup can perform I/O. + // Keep displaced owners alive until both root locks are released. + drop(retired_bundle); + drop(retired_delta); Ok(()) } fn build_own_generation( @@ -1956,7 +1968,7 @@ impl QueryState for CheckoutDriver { #[cfg(test)] mod composite_tests { - use super::super::contracts::{PlaneLoader, QueryWait, WaitOutcome}; + use super::super::contracts::{PlaneAdapter, PlaneLoader, QueryWait, WaitOutcome}; use super::super::manifest_v2::{EntryPlanes, EntryV2, ManifestV2}; use super::super::snapshot::{DiskState, PlaneAttachment}; use super::*; @@ -2091,6 +2103,105 @@ mod composite_tests { )); (driver, walker, plane, ViewAccess::Owner(owner)) } + + struct RetirePauseAdapter { + armed: AtomicBool, + entered: std::sync::mpsc::Sender<()>, + resume: std::sync::Mutex>, + } + + impl PlaneAdapter for RetirePauseAdapter { + fn plane(&self) -> FamilyPlane { + FamilyPlane::Trigram + } + fn producer(&self) -> String { + "test".into() + } + fn applies_to(&self, _: &RelPath) -> bool { + true + } + fn open_generation( + &self, + _: &ViewAccess, + _: &Arc, + ) -> Result<(), PlaneError> { + Ok(()) + } + fn release_generation(&self, _: &ViewAccess, _: &str) { + if self.armed.swap(false, Ordering::SeqCst) { + self.entered.send(()).unwrap(); + self.resume + .lock() + .unwrap() + .recv_timeout(std::time::Duration::from_secs(10)) + .unwrap(); + } + } + fn readiness( + &self, + _: &ViewAccess, + _: &Snapshot, + ) -> super::super::readiness::PlaneReadiness { + super::super::readiness::PlaneReadiness::Ready { + pending: 0, + failed: 0, + } + } + } + + #[test] + fn installed_bundle_retirement_releases_live_and_installed_locks() { + let root = tempfile::tempdir().unwrap(); + let storage = tempfile::tempdir().unwrap(); + let (driver, _, _, access) = fixture(root.path(), storage.path()); + let (entered, arrived) = std::sync::mpsc::channel(); + let (resume, released) = std::sync::mpsc::channel(); + let adapter = Arc::new(RetirePauseAdapter { + armed: false.into(), + entered, + resume: std::sync::Mutex::new(released), + }); + let driver = Arc::new( + Arc::try_unwrap(driver) + .ok() + .unwrap() + .with_adapters(vec![adapter.clone()]), + ); + drop( + SiblingLoader::new(driver.clone(), vec![]) + .load(&access) + .unwrap(), + ); + let (previous, revision) = driver.installed_cut(); + let cut = previous.install_cut().unwrap().clone(); + let generation = Arc::new(ComposedGeneration::new( + "replacement", + previous.generation().flatten().unwrap(), + None, + )); + let successor = super::super::snapshot::LiveDelta::new(generation) + .snapshot() + .with_install_cut(cut); + drop(previous); + adapter.armed.store(true, Ordering::SeqCst); + let installing = driver.clone(); + let worker = std::thread::spawn(move || installing.install(&access, &successor, revision)); + arrived + .recv_timeout(std::time::Duration::from_secs(10)) + .unwrap(); + let live_unlocked = driver.live.try_lock().is_ok(); + let installed_unlocked = driver.installed.try_lock().is_ok(); + resume.send(()).unwrap(); + worker.join().unwrap().unwrap(); + assert!( + live_unlocked, + "reader retirement ran while the live lock was held" + ); + assert!( + installed_unlocked, + "reader retirement ran while the installed lock was held" + ); + } #[test] fn composite_reconcile_omits_file_deleted_after_listing() { let root = tempfile::tempdir().unwrap(); @@ -2813,10 +2924,12 @@ pub struct InstalledDriver(Arc>>>); impl InstalledDriver { pub fn install(&self, driver: Arc) { driver.register_write_intent(); - *self + let retired = self .0 .write() - .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(driver); + .unwrap_or_else(std::sync::PoisonError::into_inner) + .replace(driver); + drop(retired); } /// Removes `driver` unless another one replaced it meanwhile. @@ -2825,19 +2938,25 @@ impl InstalledDriver { .0 .write() .unwrap_or_else(std::sync::PoisonError::into_inner); - if installed + let retired = if installed .as_ref() .is_some_and(|current| Arc::ptr_eq(current, driver)) { - *installed = None; - } + installed.take() + } else { + None + }; + drop(installed); + drop(retired); } pub fn clear(&self) { - *self + let retired = self .0 .write() - .unwrap_or_else(std::sync::PoisonError::into_inner) = None; + .unwrap_or_else(std::sync::PoisonError::into_inner) + .take(); + drop(retired); } pub fn get(&self) -> Option> { diff --git a/crates/aft/src/views/snapshot.rs b/crates/aft/src/views/snapshot.rs index b8cd232b94..96d91f3b61 100644 --- a/crates/aft/src/views/snapshot.rs +++ b/crates/aft/src/views/snapshot.rs @@ -851,6 +851,7 @@ impl LiveEntries for LiveDelta { #[cfg(test)] mod mutation_tests { use super::*; + use crate::views::contracts::{PlaneAdapter, PlaneError, ViewAccess}; use crate::views::manifest_v2::{ManifestHeader, Producers}; fn delta() -> LiveDelta { @@ -874,6 +875,211 @@ mod mutation_tests { resident: std::sync::atomic::AtomicBool, releases: std::sync::atomic::AtomicUsize, } + + struct ReaderPause { + entered: std::sync::mpsc::Sender<()>, + resume: std::sync::Mutex>, + } + + impl ReaderPause { + fn wait(&self) { + self.entered.send(()).unwrap(); + self.resume + .lock() + .unwrap() + .recv_timeout(std::time::Duration::from_secs(10)) + .unwrap(); + } + } + + struct PausedLeaseAdapter { + inner: LeaseAdapter, + open: Option, + retire: Option, + } + + impl PlaneAdapter for PausedLeaseAdapter { + fn plane(&self) -> FamilyPlane { + FamilyPlane::Trigram + } + fn producer(&self) -> String { + "test".into() + } + fn applies_to(&self, _: &RelPath) -> bool { + true + } + fn open_generation( + &self, + access: &ViewAccess, + generation: &Arc, + ) -> Result<(), PlaneError> { + if let Some(pause) = &self.open { + pause.wait(); + } + self.inner.open_generation(access, generation) + } + fn release_generation(&self, access: &ViewAccess, generation: &str) { + if let Some(pause) = &self.retire { + pause.wait(); + } + self.inner.release_generation(access, generation); + } + fn readiness( + &self, + access: &ViewAccess, + snapshot: &Snapshot, + ) -> super::super::readiness::PlaneReadiness { + self.inner.readiness(access, snapshot) + } + } + + #[test] + fn reader_open_io_releases_the_global_cache_lock() { + let root = tempfile::tempdir().unwrap(); + let storage = tempfile::tempdir().unwrap(); + let registry = + super::super::registry::FamilyRegistry::open(storage.path(), "reader-io").unwrap(); + let owner = registry + .register_view_with_storage(storage.path(), "worker", root.path()) + .unwrap(); + let access = ViewAccess::Owner(owner); + let (entered, arrived) = std::sync::mpsc::channel(); + let (resume, released) = std::sync::mpsc::channel(); + let pause = ReaderPause { + entered, + resume: std::sync::Mutex::new(released), + }; + let blocked: Arc = Arc::new(PausedLeaseAdapter { + inner: LeaseAdapter { + resident: false.into(), + releases: 0.into(), + }, + open: Some(pause), + retire: None, + }); + let generation = delta().base().clone(); + let first_access = access.clone(); + let first = std::thread::spawn(move || { + PlaneReaders::open(&first_access, generation, &[blocked]).unwrap() + }); + arrived + .recv_timeout(std::time::Duration::from_secs(10)) + .unwrap(); + let fast: Arc = Arc::new(LeaseAdapter { + resident: false.into(), + releases: 0.into(), + }); + let (done, completed) = std::sync::mpsc::channel(); + let second = std::thread::spawn(move || { + done.send(PlaneReaders::open(&access, delta().base().clone(), &[fast]).unwrap()) + .unwrap() + }); + let progressed = completed.recv_timeout(std::time::Duration::from_secs(2)); + resume.send(()).unwrap(); + let held = first.join().unwrap(); + second.join().unwrap(); + assert!( + progressed.is_ok(), + "an unrelated reader waited for adapter open I/O" + ); + drop(held); + } + + #[test] + fn reader_retirement_io_releases_the_global_cache_lock() { + let root = tempfile::tempdir().unwrap(); + let storage = tempfile::tempdir().unwrap(); + let registry = + super::super::registry::FamilyRegistry::open(storage.path(), "reader-retire").unwrap(); + let owner = registry + .register_view_with_storage(storage.path(), "worker", root.path()) + .unwrap(); + let access = ViewAccess::Owner(owner); + let (entered, arrived) = std::sync::mpsc::channel(); + let (resume, released) = std::sync::mpsc::channel(); + let blocked: Arc = Arc::new(PausedLeaseAdapter { + inner: LeaseAdapter { + resident: false.into(), + releases: 0.into(), + }, + open: None, + retire: Some(ReaderPause { + entered, + resume: std::sync::Mutex::new(released), + }), + }); + let held = PlaneReaders::open(&access, delta().base().clone(), &[blocked]).unwrap(); + let first = std::thread::spawn(move || drop(held)); + arrived + .recv_timeout(std::time::Duration::from_secs(10)) + .unwrap(); + let fast: Arc = Arc::new(LeaseAdapter { + resident: false.into(), + releases: 0.into(), + }); + let (done, completed) = std::sync::mpsc::channel(); + let second = std::thread::spawn(move || { + done.send(PlaneReaders::open(&access, delta().base().clone(), &[fast]).unwrap()) + .unwrap() + }); + let progressed = completed.recv_timeout(std::time::Duration::from_secs(2)); + resume.send(()).unwrap(); + first.join().unwrap(); + second.join().unwrap(); + assert!( + progressed.is_ok(), + "an unrelated reader waited for adapter retirement I/O" + ); + } + + #[test] + fn concurrent_reader_opens_share_one_per_key_lease() { + let root = tempfile::tempdir().unwrap(); + let storage = tempfile::tempdir().unwrap(); + let registry = + super::super::registry::FamilyRegistry::open(storage.path(), "single-reader").unwrap(); + let owner = registry + .register_view_with_storage(storage.path(), "worker", root.path()) + .unwrap(); + let access = ViewAccess::Owner(owner); + let (entered, arrived) = std::sync::mpsc::channel(); + let (resume, released) = std::sync::mpsc::channel(); + let adapter: Arc = Arc::new(PausedLeaseAdapter { + inner: LeaseAdapter { + resident: false.into(), + releases: 0.into(), + }, + open: Some(ReaderPause { + entered, + resume: std::sync::Mutex::new(released), + }), + retire: None, + }); + let first_access = access.clone(); + let first_adapter = adapter.clone(); + let first = std::thread::spawn(move || { + PlaneReaders::open(&first_access, delta().base().clone(), &[first_adapter]).unwrap() + }); + arrived + .recv_timeout(std::time::Duration::from_secs(10)) + .unwrap(); + let (done, completed) = std::sync::mpsc::channel(); + let second = std::thread::spawn(move || { + done.send(PlaneReaders::open(&access, delta().base().clone(), &[adapter]).unwrap()) + .unwrap() + }); + assert!(completed + .recv_timeout(std::time::Duration::from_millis(100)) + .is_err()); + resume.send(()).unwrap(); + let first = first.join().unwrap(); + let second_lease = completed + .recv_timeout(std::time::Duration::from_secs(10)) + .unwrap(); + second.join().unwrap(); + assert!(Arc::ptr_eq(&first.adapters[0], &second_lease.adapters[0])); + } + impl super::super::contracts::PlaneAdapter for LeaseAdapter { fn plane(&self) -> FamilyPlane { FamilyPlane::Trigram @@ -932,7 +1138,8 @@ mod mutation_tests { .unwrap(); let key = old.adapters[0].key.clone(); let weak = Arc::downgrade(&old.adapters[0]); - let mut cache = reader_cache().lock().unwrap(); + let slot = old.adapters[0].slot.clone(); + let mut state = slot.state.lock().unwrap(); let (done, completed) = std::sync::mpsc::channel(); let retiring = std::thread::spawn(move || { drop(old); @@ -945,18 +1152,23 @@ mod mutation_tests { assert_eq!( weak.strong_count(), 0, - "the old destructor must be waiting for the cache lock" + "the old destructor must be waiting for its slot lock" ); adapter.open_generation(&access, &generation).unwrap(); let replacement = Arc::new(AdapterLease { key: key.clone(), + slot: slot.clone(), + token: next_reader_token(), adapter, access, resource: None, generation, }); - cache.insert(key, Arc::downgrade(&replacement)); - drop(cache); + *state = ReaderSlotState::Live { + token: replacement.token, + lease: Arc::downgrade(&replacement), + }; + drop(state); completed .recv_timeout(std::time::Duration::from_secs(5)) .unwrap(); @@ -1303,6 +1515,8 @@ pub struct PlaneReaders { struct AdapterLease { key: ReaderCacheKey, + slot: Arc, + token: u64, adapter: Arc, access: super::contracts::ViewAccess, resource: Option, @@ -1311,27 +1525,93 @@ struct AdapterLease { impl Drop for AdapterLease { fn drop(&mut self) { - // Release the owned handle before adapter cleanup trims its arena. - // The generation Arc retains residency pins until the lease is dropped. - drop(self.resource.take()); - let mut cache = reader_cache() - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - // A new lease can replace a zero-strong-count weak entry while this - // destructor waits for the lock. Never remove that new resident. - if !cache - .get(&self.key) - .is_some_and(|lease| lease.strong_count() != 0) - { - cache.remove(&self.key); + let owns_slot = { + let mut state = self + .slot + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let owns = matches!(&*state, + ReaderSlotState::Live { token, .. } | ReaderSlotState::Opening(token) + if *token == self.token); + if owns { + *state = ReaderSlotState::Retiring(self.token); + } + owns + }; + // Neither the global map nor the per-key slot is locked during teardown. + // The generation Arc retains residency pins until the lease fields drop. + if owns_slot { + let _retiring = ReaderTransition { + slot: self.slot.clone(), + token: self.token, + armed: true, + }; + drop(self.resource.take()); self.adapter .release_generation(&self.access, &self.generation.resident_key()); + } else { + drop(self.resource.take()); } } } type ReaderCacheKey = (usize, String, String, String); -type ReaderCache = std::sync::Mutex>>; +type ReaderCache = std::sync::Mutex>>; + +enum ReaderSlotState { + Vacant, + Opening(u64), + Live { + token: u64, + lease: std::sync::Weak, + }, + Retiring(u64), +} + +struct ReaderSlot { + state: std::sync::Mutex, + changed: std::sync::Condvar, +} + +impl ReaderSlot { + fn new() -> Self { + Self { + state: std::sync::Mutex::new(ReaderSlotState::Vacant), + changed: std::sync::Condvar::new(), + } + } +} + +struct ReaderTransition { + slot: Arc, + token: u64, + armed: bool, +} + +impl Drop for ReaderTransition { + fn drop(&mut self) { + if !self.armed { + return; + } + let mut state = self + .slot + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if matches!(&*state, ReaderSlotState::Opening(token) | ReaderSlotState::Retiring(token) if *token == self.token) + { + *state = ReaderSlotState::Vacant; + } + drop(state); + self.slot.changed.notify_all(); + } +} + +fn next_reader_token() -> u64 { + static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); + NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed) +} fn reader_cache() -> &'static ReaderCache { static CACHE: std::sync::OnceLock = std::sync::OnceLock::new(); @@ -1406,26 +1686,72 @@ impl PlaneReaders { access.scope().to_owned(), readers.generation.resident_key(), ); - let mut cache = reader_cache() - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - cache.retain(|_, lease| lease.strong_count() != 0); - let lease = match cache.get(&key).and_then(std::sync::Weak::upgrade) { - Some(lease) => lease, - None => { - let resource = adapter.open_reader(access, &readers.generation)?; - let lease = Arc::new(AdapterLease { - key: key.clone(), - adapter: adapter.clone(), - access: access.clone(), - resource, - generation: readers.generation.clone(), - }); - cache.insert(key, Arc::downgrade(&lease)); - lease + let slot = { + let mut cache = reader_cache() + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + cache.retain(|_, slot| slot.strong_count() != 0); + match cache.get(&key).and_then(std::sync::Weak::upgrade) { + Some(slot) => slot, + None => { + let slot = Arc::new(ReaderSlot::new()); + cache.insert(key.clone(), Arc::downgrade(&slot)); + slot + } + } + }; + let lease = loop { + let mut state = slot + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + match &*state { + ReaderSlotState::Live { lease, .. } if lease.strong_count() != 0 => { + if let Some(lease) = lease.upgrade() { + break lease; + } + } + ReaderSlotState::Vacant => { + let token = next_reader_token(); + *state = ReaderSlotState::Opening(token); + drop(state); + let mut opening = ReaderTransition { + slot: slot.clone(), + token, + armed: true, + }; + let resource = adapter.open_reader(access, &readers.generation)?; + let lease = Arc::new(AdapterLease { + key: key.clone(), + slot: slot.clone(), + token, + adapter: adapter.clone(), + access: access.clone(), + resource, + generation: readers.generation.clone(), + }); + *slot + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = + ReaderSlotState::Live { + token, + lease: Arc::downgrade(&lease), + }; + opening.armed = false; + slot.changed.notify_all(); + break lease; + } + _ => {} } + // An expired live weak reference still owns retirement. Wait for + // its destructor, rather than opening over a half-closed reader. + state = slot + .changed + .wait(state) + .unwrap_or_else(std::sync::PoisonError::into_inner); + drop(state); }; - drop(cache); let resource = adapter.bind_reader(access, &readers.generation, lease.resource.clone())?; readers.adapters.push(lease); From 29bde9c440c06aa53e38c28380984ef33768db5c Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Mon, 26 Oct 2026 10:58:55 +0100 Subject: [PATCH 05/19] mason: strengthen Athena T1/T2 publication boundary tests Keep the no-marker production boundary empty as required, and separately seed a populated opaque v3 namespace. Dry-run pruning must preserve both content digests and exact reader-floor bytes. Exercise real CheckoutDriver materialization, manifest and blob reads, retained SQLite reader resources, pointer CAS and installation ordering rather than relying only on nine-step mock labels. Final views/Windows checks are batched after audit remediation; no mutation controls are added or run. Co-authored-by: alfonso-aft <289616620+alfonso-aft@users.noreply.github.com> --- crates/aft/src/views/first_load.rs | 138 ++++++++++++++++++ .../aft/src/views/semantic_runtime_tests.rs | 77 ++++++++++ 2 files changed, 215 insertions(+) diff --git a/crates/aft/src/views/first_load.rs b/crates/aft/src/views/first_load.rs index 8705fcdb83..aa243ae11e 100644 --- a/crates/aft/src/views/first_load.rs +++ b/crates/aft/src/views/first_load.rs @@ -2110,6 +2110,144 @@ mod composite_tests { resume: std::sync::Mutex>, } + struct PublicationOrderAdapter { + owner: super::super::registry::ViewRegistration, + producer: String, + events: Arc>>, + } + + impl PlaneAdapter for PublicationOrderAdapter { + fn plane(&self) -> FamilyPlane { + FamilyPlane::Trigram + } + fn producer(&self) -> String { + self.producer.clone() + } + fn applies_to(&self, _: &RelPath) -> bool { + true + } + fn open_generation( + &self, + access: &ViewAccess, + generation: &Arc, + ) -> Result<(), PlaneError> { + self.open_reader(access, generation).map(|_| ()) + } + fn open_reader( + &self, + _: &ViewAccess, + generation: &Arc, + ) -> Result, PlaneError> { + let view = self + .owner + .view_store() + .map_err(|e| SiblingLoader::error(e.to_string()))?; + let manifest = view + .load_manifest_v2(generation.name()) + .map_err(|e| SiblingLoader::error(e.to_string()))?; + let store = self + .owner + .open_store(FamilyPlane::Trigram) + .map_err(|e| SiblingLoader::error(e.to_string()))?; + let reader = store.reader(); + let keys: Vec<_> = manifest.ready_keys().collect(); + assert!( + !keys.is_empty(), + "the real builder must have materialized a blob" + ); + for key in keys { + assert!(reader.get(&key).unwrap().is_some()); + } + self.events.lock().unwrap().push("reader-open"); + Ok(Some(Arc::new(std::sync::Mutex::new(reader)))) + } + fn release_generation(&self, _: &ViewAccess, _: &str) {} + fn readiness( + &self, + _: &ViewAccess, + _: &Snapshot, + ) -> super::super::readiness::PlaneReadiness { + super::super::readiness::PlaneReadiness::Ready { + pending: 0, + failed: 0, + } + } + } + + struct PublicationOrderFence { + driver: std::sync::Weak, + store: super::super::ViewStore, + events: Arc>>, + } + + impl PublishFence for PublicationOrderFence { + fn publish( + &self, + commit: &mut dyn FnMut() -> Result<(), PlaneError>, + ) -> Result<(), PlaneError> { + assert_eq!(*self.events.lock().unwrap(), ["reader-open"]); + let driver = self.driver.upgrade().unwrap(); + assert_eq!(driver.installed_bundle().composed.name(), "empty"); + assert!(self.store.current_generation_read_only().unwrap().is_none()); + commit()?; + assert!(self.store.current_generation_read_only().unwrap().is_some()); + assert_eq!( + driver.installed_bundle().composed.name(), + "empty", + "pointer publication must not pretend installation happened" + ); + self.events.lock().unwrap().push("pointer-published"); + Ok(()) + } + } + + #[test] + fn real_checkout_publication_opens_blob_readers_before_cas_and_installs_after() { + let root = tempfile::tempdir().unwrap(); + let storage = tempfile::tempdir().unwrap(); + let (driver, _, _, access) = fixture(root.path(), storage.path()); + let owner = driver.owner.clone(); + let events = Arc::new(std::sync::Mutex::new(Vec::new())); + let adapter = Arc::new(PublicationOrderAdapter { + owner: owner.clone(), + producer: driver.producers.trigram.clone(), + events: events.clone(), + }); + let driver = Arc::new( + Arc::try_unwrap(driver) + .ok() + .unwrap() + .with_adapters(vec![adapter.clone()]), + ); + let store = owner.view_store().unwrap(); + driver.set_publish_fence(Arc::new(PublicationOrderFence { + driver: Arc::downgrade(&driver), + store: store.clone(), + events: events.clone(), + })); + let outcome = SiblingLoader::new(driver.clone(), vec![]) + .load(&access) + .unwrap(); + let published = store.current_generation_read_only().unwrap().unwrap(); + assert_eq!(outcome.snapshot.generation().name(), published); + assert_eq!(driver.installed_bundle().composed.name(), published); + assert!(outcome + .snapshot + .readers() + .unwrap() + .resource( + Arc::as_ptr(&adapter) as usize, + access.family(), + access.scope(), + &outcome.snapshot.generation().resident_key() + ) + .is_some()); + assert_eq!( + *events.lock().unwrap(), + ["reader-open", "pointer-published"] + ); + } + impl PlaneAdapter for RetirePauseAdapter { fn plane(&self) -> FamilyPlane { FamilyPlane::Trigram diff --git a/crates/aft/src/views/semantic_runtime_tests.rs b/crates/aft/src/views/semantic_runtime_tests.rs index c050d68109..808ce60b98 100644 --- a/crates/aft/src/views/semantic_runtime_tests.rs +++ b/crates/aft/src/views/semantic_runtime_tests.rs @@ -149,6 +149,83 @@ fn composition_no_marker_boundary_record() { assert_eq!(answers[0], answers[1]); } +#[test] +fn composition_prune_dry_run_preserves_populated_v3_inventory() { + use crate::migration::prune_legacy::{ + prune_legacy, CensusFinding, ProcessCensus, PruneExit, PruneOptions, + }; + struct NoProcesses; + impl ProcessCensus for NoProcesses { + fn take(&self) -> Result, String> { + Ok(Vec::new()) + } + } + fn inventory(root: &Path, path: &Path, out: &mut std::collections::BTreeMap) { + for entry in std::fs::read_dir(path).unwrap() { + let path = entry.unwrap().path(); + if path.is_dir() { + inventory(root, &path, out); + } else { + out.insert( + path.strip_prefix(root) + .unwrap() + .to_string_lossy() + .into_owned(), + *blake3::hash(&std::fs::read(&path).unwrap()).as_bytes(), + ); + } + } + } + let temp = tempfile::tempdir().unwrap(); + let storage = temp.path(); + let v3 = storage.join("blobs/v3/populated"); + std::fs::create_dir_all(v3.join("views/worker")).unwrap(); + // A legacy pruner must preserve opaque newer files, not just rows it can decode. + // These fixture files are opaque bytes with no live SQLite connection. + std::fs::write(v3.join("semantic.sqlite"), b"opaque future plane payload").unwrap(); + std::fs::write( + v3.join("views/worker/manifest-future.json"), + br#"{"format_version":3,"entries":["kept.rs"]}"#, + ) + .unwrap(); + crate::reader_floor::raise( + storage, + &[ + (crate::persisted_format::PersistedStore::ViewsV3Registry, 1), + (crate::persisted_format::PersistedStore::ViewsV3Store, 1), + ], + ) + .unwrap(); + let mut before = Default::default(); + inventory(storage, &v3, &mut before); + assert_eq!(before.len(), 2); + let floor = std::fs::read(storage.join("reader-floor.json")).unwrap(); + let mut output = Vec::new(); + assert_eq!( + prune_legacy( + storage, + &PruneOptions { + yes: false, + now: std::time::SystemTime::now() + }, + &NoProcesses, + None, + &mut output + ) + .unwrap(), + PruneExit::Clean + ); + let mut after = Default::default(); + if v3.exists() { + inventory(storage, &v3, &mut after); + } + assert_eq!(before, after, "dry-run changed populated v3 files"); + assert_eq!( + floor, + std::fs::read(storage.join("reader-floor.json")).unwrap() + ); +} + #[test] fn lane_paused_view_fill_waits_without_rechunking_and_resumes() { use crate::semantic_index::{lane_pause_chunk_calls, with_lane_pause_clock, PauseRetryScope}; From 4e47fb5ddb3acf0f3adedd19c83b0abd59b9d75f Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Mon, 26 Oct 2026 11:10:23 +0100 Subject: [PATCH 06/19] mason: fix Athena F1/c5 cleanup after a possible landed CAS Track publication progress before invoking CAS. Known losing attempts still abort-clean and revalidate, but published or indeterminate failures use a separate recovery operation that must preserve insertion intent and acquisition protection. Replace the old unsafe assertion that every failure runs terminal cleanup with phase-appropriate recovery checks. A filesystem-backed intent regression verifies both post-CAS preservation and pre-CAS release. Durable binding implementations remain fail-closed; final verification is batched per the latest instruction, without mutation controls. Co-authored-by: alfonso-aft <289616620+alfonso-aft@users.noreply.github.com> --- crates/aft/src/views/first_load.rs | 128 +++++++++++++++++++++++++---- 1 file changed, 113 insertions(+), 15 deletions(-) diff --git a/crates/aft/src/views/first_load.rs b/crates/aft/src/views/first_load.rs index aa243ae11e..7ccb3d9587 100644 --- a/crates/aft/src/views/first_load.rs +++ b/crates/aft/src/views/first_load.rs @@ -135,7 +135,12 @@ pub trait PublicationBinding { /// Hold the ownership PublishFence across final marker verification and /// pointer CAS, and return only after the pointer is synced. Keeping both /// operations in one callback prevents a validation-to-CAS ownership race. - fn publish_verified(&mut self) -> Result { + /// Mark `MayHaveLanded` immediately before CAS, before any error can hide a + /// committed transaction. Leave it `NotAttempted` if verification refuses. + fn publish_verified( + &mut self, + _progress: &mut PublicationProgress, + ) -> Result { Err(binding_unavailable()) } fn referrer_published(&mut self) -> Result<(), PlaneError> { @@ -147,14 +152,30 @@ pub trait PublicationBinding { fn schedule_revalidated(&mut self) -> Result<(), PlaneError> { Err(binding_unavailable()) } - /// Under the registry barrier, remove any unfinished reference intent and - /// release the temporary base marker acquired by `acquire`. After a - /// successful install, the bundle retains separate query-residency markers. + /// Preserve intent and temporary protection for a publication that landed or + /// whose CAS outcome is uncertain. Recovery must reconcile the pointer and + /// finish its referrer/install; it must not abort the published reference. + fn recover_published(&mut self) -> Result<(), PlaneError> { + Err(binding_unavailable()) + } + /// Remove the temporary insertion intent and acquisition marker after a + /// known unpublished attempt, or after InstalledBundle.readers retains + /// independent ComposedGeneration residency markers. Never called to abort + /// a landed or uncertain CAS after a later step failed. fn terminal_cleanup(&mut self) -> Result<(), PlaneError> { Err(binding_unavailable()) } } +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub enum PublicationProgress { + #[default] + NotAttempted, + /// Set before invoking CAS: a storage error may hide a committed transaction. + MayHaveLanded, + Published, +} + fn binding_unavailable() -> PlaneError { PlaneError::loader( LoaderStage::Driver, @@ -174,15 +195,19 @@ pub enum BindingOutcome { pub fn publish_and_install( binding: &mut dyn PublicationBinding, ) -> Result { + let mut progress = PublicationProgress::NotAttempted; let result: Result = (|| { binding.cut()?; binding.acquire()?; binding.build_and_flush()?; binding.open_readers()?; - match binding.publish_verified()? { - super::manifest_v2::PublishV2::Published => {} + match binding.publish_verified(&mut progress)? { + super::manifest_v2::PublishV2::Published => { + progress = PublicationProgress::Published; + } super::manifest_v2::PublishV2::Conflict { .. } | super::manifest_v2::PublishV2::EquivalentWinner { .. } => { + progress = PublicationProgress::NotAttempted; return Ok(BindingOutcome::Revalidate); } } @@ -190,11 +215,22 @@ pub fn publish_and_install( binding.install_replayed()?; Ok(BindingOutcome::Installed) })(); - let cleanup = binding.terminal_cleanup(); + let recovering = result.is_err() && progress != PublicationProgress::NotAttempted; + let cleanup = if recovering { + binding.recover_published() + } else { + binding.terminal_cleanup() + }; match (result, cleanup) { (Err(error), Err(cleanup)) => Err(SiblingLoader::error(format!( - "publication failed: {}; terminal cleanup failed: {}", - error.reason, cleanup.reason + "publication failed: {}; {} failed: {}", + error.reason, + if recovering { + "published recovery" + } else { + "terminal cleanup" + }, + cleanup.reason ))), (Err(error), Ok(())) => Err(error), (Ok(_), Err(error)) => Err(error), @@ -224,10 +260,13 @@ mod publication_binding_tests { assert!(binding.open_readers().is_err()); assert!(binding.verify_held().is_err()); assert!(binding.publish().is_err()); - assert!(binding.publish_verified().is_err()); + let mut progress = PublicationProgress::NotAttempted; + assert!(binding.publish_verified(&mut progress).is_err()); + assert_eq!(progress, PublicationProgress::NotAttempted); assert!(binding.referrer_published().is_err()); assert!(binding.install_replayed().is_err()); assert!(binding.schedule_revalidated().is_err()); + assert!(binding.recover_published().is_err()); assert!(binding.terminal_cleanup().is_err()); let error = publish_and_install(&mut binding).unwrap_err(); assert!(error.reason.contains("terminal cleanup failed")); @@ -237,6 +276,7 @@ mod publication_binding_tests { steps: Vec<&'static str>, fail: Option<&'static str>, outcome: PublishV2, + intent: Option, } impl Binding { fn step(&mut self, name: &'static str) -> Result<(), PlaneError> { @@ -253,7 +293,11 @@ mod publication_binding_tests { self.step("cut") } fn acquire(&mut self) -> Result<(), PlaneError> { - self.step("acquire") + self.step("acquire")?; + if let Some(path) = &self.intent { + std::fs::write(path, b"pending reference").unwrap(); + } + Ok(()) } fn build_and_flush(&mut self) -> Result<(), PlaneError> { self.step("closure-flushed") @@ -268,8 +312,12 @@ mod publication_binding_tests { self.step("pointer-synced")?; Ok(self.outcome.clone()) } - fn publish_verified(&mut self) -> Result { + fn publish_verified( + &mut self, + progress: &mut PublicationProgress, + ) -> Result { self.verify_held()?; + *progress = PublicationProgress::MayHaveLanded; self.publish() } fn referrer_published(&mut self) -> Result<(), PlaneError> { @@ -282,12 +330,21 @@ mod publication_binding_tests { self.step("revalidated-job") } fn terminal_cleanup(&mut self) -> Result<(), PlaneError> { - self.step("terminal-cleanup") + self.step("terminal-cleanup")?; + if let Some(path) = &self.intent { + if path.exists() { + std::fs::remove_file(path).unwrap(); + } + } + Ok(()) + } + fn recover_published(&mut self) -> Result<(), PlaneError> { + self.step("published-recovery") } } #[test] - fn publication_binding_nine_steps_and_cleanup_on_each_failure() { + fn publication_binding_nine_steps_and_phase_appropriate_failure_recovery() { let expected = [ "cut", "acquire", @@ -303,6 +360,7 @@ mod publication_binding_tests { steps: Vec::new(), fail: None, outcome: PublishV2::Published, + intent: None, }; assert_eq!( publish_and_install(&mut binding).unwrap(), @@ -314,11 +372,16 @@ mod publication_binding_tests { steps: Vec::new(), fail: Some(step), outcome: PublishV2::Published, + intent: None, }; assert!(publish_and_install(&mut binding).is_err(), "{step}"); let mut prefix = expected[..=index].to_vec(); if *step != "terminal-cleanup" { - prefix.push("terminal-cleanup"); + prefix.push(if index >= 5 { + "published-recovery" + } else { + "terminal-cleanup" + }); } assert_eq!(binding.steps, prefix, "{step}"); } @@ -338,6 +401,7 @@ mod publication_binding_tests { steps: Vec::new(), fail: None, outcome, + intent: None, }; assert_eq!( publish_and_install(&mut binding).unwrap(), @@ -358,6 +422,40 @@ mod publication_binding_tests { ); } } + + #[test] + fn landed_or_ambiguous_cas_failure_preserves_insertion_protection() { + for failed in ["pointer-synced", "referrer-published", "installed"] { + let directory = tempfile::tempdir().unwrap(); + let intent = directory.path().join("insertion-intent"); + let mut binding = Binding { + steps: Vec::new(), + fail: Some(failed), + outcome: PublishV2::Published, + intent: Some(intent.clone()), + }; + assert!(publish_and_install(&mut binding).is_err()); + assert!( + intent.is_file(), + "{failed}: recovery discarded the published reference's protection" + ); + assert!(!binding.steps.contains(&"terminal-cleanup")); + assert_eq!(binding.steps.last(), Some(&"published-recovery")); + } + let directory = tempfile::tempdir().unwrap(); + let intent = directory.path().join("insertion-intent"); + let mut binding = Binding { + steps: Vec::new(), + fail: Some("verify-held"), + outcome: PublishV2::Published, + intent: Some(intent.clone()), + }; + assert!(publish_and_install(&mut binding).is_err()); + assert!( + !intent.exists(), + "a known pre-CAS refusal should release its temporary protection" + ); + } } /// Decides, atomically with the pointer publication itself, whether the From 117e0a52891e35f6eeefd7e8bf8f98401f0b8398 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Mon, 26 Oct 2026 11:17:14 +0100 Subject: [PATCH 07/19] mason: fix Athena c7 sparse fold ownership and fill content Record only changed plane states in inherited readiness overrides. Applicability removal still uses a full entry, but unchanged planes in that representation remain inherited and cannot consume worker fills. Resolve override-fill content from the post-update inherited entry, never the previous replacement. Semantic live-patch ownership now requires an explicit semantic state for the matching content, not merely a path in another plane's map. Add regressions for trigram-only updates, applicability removal, B-to-A restoration, and trigram-only live patches. Final filtered views verification is batched; no mutation controls. Co-authored-by: alfonso-aft <289616620+alfonso-aft@users.noreply.github.com> --- crates/aft/src/views/semantic.rs | 35 ++++- crates/aft/src/views/snapshot.rs | 213 ++++++++++++++++++++++++++++--- 2 files changed, 226 insertions(+), 22 deletions(-) diff --git a/crates/aft/src/views/semantic.rs b/crates/aft/src/views/semantic.rs index 9d2e93f35f..1f627a8370 100644 --- a/crates/aft/src/views/semantic.rs +++ b/crates/aft/src/views/semantic.rs @@ -905,7 +905,12 @@ impl SemanticPlane { }; (applies_to(&path) && (matches!(snapshot.source(&path), Source::Live(_)) - || snapshot.readiness_patches().contains_key(&path) + || snapshot + .readiness_patches() + .get(&path) + .is_some_and(|patch| { + patch.content == content && patch.planes.semantic.is_some() + }) || snapshot .generation() .owns_work(&path, FamilyPlane::Semantic)) @@ -3131,11 +3136,37 @@ mod tests { ); let resident = plane.admit(&legacy.access, &composed).unwrap(); assert_eq!(resident.legacy_summaries.len(), 1); - let snapshot = LiveDelta::new(composed).snapshot(); + let mut live = LiveDelta::new(composed); + let snapshot = live.snapshot(); let state = ViewState::default(); assert!(plane .work_items(&state, &resident, &snapshot, true) .is_empty()); + for name in ["src/summary.rs", "src/pending.rs"] { + let path = RelPath::new(name.as_bytes().to_vec()).unwrap(); + let content = snapshot + .generation() + .entry(&path) + .unwrap() + .content() + .unwrap(); + live.set_readiness( + path, + super::super::snapshot::PlanePatch { + content, + planes: EntryPlanes { + trigram: Some(PlaneState::pending("worker trigram repair")), + ..Default::default() + }, + }, + ); + } + assert!( + plane + .work_items(&state, &resident, &live.snapshot(), true) + .is_empty(), + "a trigram-only live patch claimed inherited semantic work" + ); } #[test] diff --git a/crates/aft/src/views/snapshot.rs b/crates/aft/src/views/snapshot.rs index 96d91f3b61..04b1b12c64 100644 --- a/crates/aft/src/views/snapshot.rs +++ b/crates/aft/src/views/snapshot.rs @@ -236,7 +236,13 @@ impl ComposedGeneration { pub fn owns_work(&self, path: &RelPath, plane: FamilyPlane) -> bool { self.base.is_none() - || self.delta.get(path).is_some() + || self.delta.get(path).is_some_and(|entry| { + delta_owns_plane( + self.base.as_ref().and_then(|base| base.entry(path)), + entry, + plane, + ) + }) || self .delta .readiness_overrides() @@ -344,29 +350,50 @@ impl ComposedGeneration { for (path, entry) in updates { if let (Some(base), Some(entry)) = (&self.base, &entry) { if let Some(inherited) = base.entry(&path) { - let mut metadata = inherited.clone(); - if let (EntryV2::Regular { planes, .. }, Some(new_planes)) = - (&mut metadata, entry.planes()) - { - *planes = new_planes.clone(); - } - if &metadata == entry { - let patch = (entry != inherited) - .then(|| entry.planes().cloned()) - .flatten(); - next.inherit(path, patch); - continue; + if same_entry_identity(inherited, entry) { + let mut patch = EntryPlanes::default(); + let mut representable = true; + for plane in FamilyPlane::ALL { + if entry.plane_state(plane) != inherited.plane_state(plane) { + match entry.plane_state(plane) { + Some(state) => *patch.get_mut(plane) = Some(state.clone()), + None => { + representable = false; + break; + } + } + } + } + if representable { + let patch = FamilyPlane::ALL + .into_iter() + .any(|plane| patch.get(plane).is_some()) + .then_some(patch); + next.inherit(path, patch); + continue; + } } } } next.set(path, entry)?; } for (path, entry) in next.entries_mut() { + let owned = FamilyPlane::ALL.map(|plane| { + delta_owns_plane( + self.base.as_ref().and_then(|base| base.entry(path)), + entry, + plane, + ) + }); if let EntryV2::Regular { content, planes, .. } = entry { - apply_fills(path, content, planes, fill); + for (plane, owned) in FamilyPlane::ALL.into_iter().zip(owned) { + if owned { + apply_plane_fill(path, content, plane, planes.get_mut(plane), fill); + } + } } } let overrides: Vec<_> = next @@ -375,7 +402,12 @@ impl ComposedGeneration { .map(|(path, patch)| (path.clone(), patch.clone())) .collect(); for (path, mut patch) in overrides { - if let Some(content) = self.entry(&path).and_then(EntryV2::content) { + if let Some(content) = self + .base + .as_ref() + .and_then(|base| base.entry(&path)) + .and_then(EntryV2::content) + { apply_fills(&path, &content, &mut patch, fill); } next.set_readiness_override(path, patch)?; @@ -394,6 +426,55 @@ impl ComposedGeneration { } } +fn same_entry_identity(inherited: &EntryV2, entry: &EntryV2) -> bool { + match (inherited, entry) { + ( + EntryV2::Regular { + mode: a_mode, + content: a_content, + size: a_size, + git_oid: a_oid, + resolution_input: a_input, + .. + }, + EntryV2::Regular { + mode: b_mode, + content: b_content, + size: b_size, + git_oid: b_oid, + resolution_input: b_input, + .. + }, + ) => { + (a_mode, a_content, a_size, a_oid, a_input) + == (b_mode, b_content, b_size, b_oid, b_input) + } + _ => inherited == entry, + } +} + +fn delta_owns_plane(inherited: Option<&EntryV2>, entry: &EntryV2, plane: FamilyPlane) -> bool { + // Clearing applicability needs a full entry because an absent override + // means inheritance. That representation must not claim unchanged planes. + inherited.is_none_or(|base| { + !same_entry_identity(base, entry) || base.plane_state(plane) != entry.plane_state(plane) + }) +} + +fn apply_plane_fill( + path: &RelPath, + content: &ContentHash, + plane: FamilyPlane, + state: &mut Option, + fill: &super::readiness::FillMap, +) { + if matches!(state, Some(PlaneState::Pending { .. })) { + if let Some(key) = fill.get(path, content, plane) { + *state = Some(PlaneState::ready(key)); + } + } +} + fn apply_fills( path: &RelPath, content: &ContentHash, @@ -401,11 +482,7 @@ fn apply_fills( fill: &super::readiness::FillMap, ) { for plane in FamilyPlane::ALL { - if matches!(planes.get(plane), Some(PlaneState::Pending { .. })) { - if let Some(key) = fill.get(path, content, plane) { - *planes.get_mut(plane) = Some(PlaneState::ready(key)); - } - } + apply_plane_fill(path, content, plane, planes.get_mut(plane), fill); } } @@ -1986,6 +2063,102 @@ mod composition_tests { Arc::new(ComposedGeneration::new("base-generation", manifest, None)) } + #[test] + fn fold_keeps_unchanged_inherited_planes_out_of_worker_overrides() { + for trigram in [Some(ready()), None] { + let base = base(); + let file = path("inherited.rs"); + let mut update = base.entry(&file).unwrap().clone(); + let EntryV2::Regular { planes, .. } = &mut update else { + unreachable!() + }; + planes.trigram = trigram.clone(); + let composed = + ComposedGeneration::compose("worker", worker(), base.clone(), None).unwrap(); + let folded = composed + .fold( + &header().producers, + [(file.clone(), Some(update))], + &FillMap::default(), + ) + .unwrap(); + let next = Arc::new(ComposedGeneration::compose("next", folded, base, None).unwrap()); + assert!( + !next.owns_work(&file, FamilyPlane::Semantic), + "a trigram-only fold claimed inherited semantic work" + ); + assert_eq!( + next.plane_readiness(&file, FamilyPlane::Trigram), + trigram.as_ref() + ); + let live = LiveDelta::new(next.clone()); + assert!(readiness::work_queue( + FamilyPlane::Semantic, + "sem", + Some(&next), + &live, + &FillMap::default(), + &|_| true + ) + .is_empty()); + } + } + + #[test] + fn fold_uses_post_update_content_when_applying_override_fills() { + use super::super::readiness::{Admission, Completion, WorkItem}; + use crate::blob_store::v2::FamilyKey; + let base = base(); + let file = path("replaced.rs"); + let mut delta = worker(); + delta + .set( + file.clone(), + Some(entry(b"B", PlaneState::pending("B fill"))), + ) + .unwrap(); + let composed = ComposedGeneration::compose("worker", delta, base.clone(), None).unwrap(); + let key = FamilyKey::new(FamilyPlane::Semantic, [0x55; 32]); + let mut fills = FillMap::default(); + let disk = DiskState::of_bytes(b"B"); + assert_eq!( + fills.admit( + &Completion { + item: WorkItem { + plane: FamilyPlane::Semantic, + rel_path: file.clone(), + content: ContentHash::of(b"B"), + producer: "sem".into() + }, + key, + }, + "sem", + Some(&disk), + Some(&composed) + ), + Admission::Installed + ); + let mut restored = base.entry(&file).unwrap().clone(); + let EntryV2::Regular { planes, .. } = &mut restored else { + unreachable!() + }; + planes.semantic = Some(PlaneState::pending("repair inherited A")); + let folded = composed + .fold( + &header().producers, + [(file.clone(), Some(restored))], + &fills, + ) + .unwrap(); + let next = ComposedGeneration::compose("next", folded, base.clone(), None).unwrap(); + assert_eq!(next.disk_state(&file), base.disk_state(&file)); + assert_eq!( + next.plane_readiness(&file, FamilyPlane::Semantic), + Some(&PlaneState::pending("repair inherited A")), + "a fill for replaced B was attached to inherited A" + ); + } + #[test] fn composed_answers_equal_independently_flattened_manifest_for_every_plane() { let base = base(); From a77f6aa6e3de6b1792094e74760aef918403adae Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Mon, 26 Oct 2026 11:26:18 +0100 Subject: [PATCH 08/19] mason: fix Athena F5/c4 watcher and membership install fences Only a completed strict walk tagged with the current epoch may clear startup Reconciling or directory membership uncertainty. Overflowed and Stopped states are refused rather than silently promoted; future watcher-resume integration must establish a new valid observation boundary. Validate exact installed identity before publication as well as installation. Add tests for unsafe watcher promotion, startup without a walk, and directory uncertainty surviving a non-strict install until a full walk. Ordinary edits still replay without requiring a quiet revision. Co-authored-by: alfonso-aft <289616620+alfonso-aft@users.noreply.github.com> --- crates/aft/src/views/first_load.rs | 110 +++++++++++++++++++++++++++-- crates/aft/src/views/snapshot.rs | 11 +++ 2 files changed, 114 insertions(+), 7 deletions(-) diff --git a/crates/aft/src/views/first_load.rs b/crates/aft/src/views/first_load.rs index 7ccb3d9587..2be075b70a 100644 --- a/crates/aft/src/views/first_load.rs +++ b/crates/aft/src/views/first_load.rs @@ -591,7 +591,8 @@ impl SiblingLoader { for (path, entry) in checkout.entries { delta.apply(path, entry); } - let mut snapshot = delta.snapshot(); + let strict_epoch = checkout.cut.as_ref().map_or(delta.epoch(), |cut| cut.epoch); + let mut snapshot = delta.snapshot().with_reconciled_epoch(strict_epoch); if let Some(cut) = checkout.cut { snapshot = snapshot.with_install_cut(cut); } @@ -1583,6 +1584,35 @@ impl CheckoutDriver { )), } } + + fn validate_install_identity( + &self, + snapshot: &Snapshot, + cut: &InstallCut, + live: &super::snapshot::LiveDelta, + installed: &InstalledBundle, + ) -> Result<(), PlaneError> { + if cut.seq != installed.seq || !Arc::ptr_eq(&cut.resident, &installed.composed) { + return Err(SiblingLoader::error( + "BaseChanged: installed bundle superseded", + )); + } + // A prior ignore-file record is not proof that the current membership + // epoch was walked. Only the completed strict walk carries this tag. + let strict = snapshot.reconciled_epoch() == Some(live.epoch()); + if !installed.uncertain_paths.is_empty() && !strict { + return Err(SiblingLoader::error(format!( + "{CHECKOUT_CHANGED_BEFORE_INSTALL}: membership requires strict reconciliation" + ))); + } + match live.watcher() { + super::snapshot::WatcherState::Healthy => Ok(()), + super::snapshot::WatcherState::Reconciling if strict => Ok(()), + _ => Err(SiblingLoader::error(format!( + "{CHECKOUT_CHANGED_BEFORE_INSTALL}: watcher is not healthy or strictly reconciled" + ))), + } + } } impl FirstLoadDriver for CheckoutDriver { @@ -1599,7 +1629,12 @@ impl FirstLoadDriver for CheckoutDriver { .unwrap_or_else(std::sync::PoisonError::into_inner); live.replay_onto(snapshot, cut) .map(|_| ()) - .map_err(|error| SiblingLoader::error(format!("{error:?}"))) + .map_err(|error| SiblingLoader::error(format!("{error:?}")))?; + let installed = self + .installed + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + self.validate_install_identity(snapshot, cut, &live, &installed) } fn producers(&self, _: &ViewAccess) -> Producers { self.producers.clone() @@ -1765,11 +1800,7 @@ impl FirstLoadDriver for CheckoutDriver { .installed .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); - if cut.seq != installed.seq || !Arc::ptr_eq(&cut.resident, &installed.composed) { - return Err(SiblingLoader::error( - "BaseChanged: installed bundle superseded", - )); - } + self.validate_install_identity(snapshot, cut, &live, &installed)?; let seq = installed.seq + 1; successor.set_watcher(super::snapshot::WatcherState::Healthy); let published = snapshot @@ -2438,6 +2469,71 @@ mod composite_tests { "reader retirement ran while the installed lock was held" ); } + + #[test] + fn install_never_promotes_overflowed_or_stopped_watchers() { + let root = tempfile::tempdir().unwrap(); + let storage = tempfile::tempdir().unwrap(); + let (driver, _, _, access) = fixture(root.path(), storage.path()); + drop( + SiblingLoader::new(driver.clone(), vec![]) + .load(&access) + .unwrap(), + ); + for watcher in [ + super::super::snapshot::WatcherState::Overflowed, + super::super::snapshot::WatcherState::Stopped, + ] { + driver.live.lock().unwrap().set_watcher(watcher); + driver.record_change(RelPath::new(b"file.rs".to_vec()).unwrap()); + let (snapshot, revision) = driver.installed_cut(); + assert!(driver.install(&access, &snapshot, revision).is_err()); + assert_eq!(driver.live.lock().unwrap().watcher(), watcher); + } + } + + #[test] + fn initial_reconciling_state_requires_a_completed_strict_walk() { + let root = tempfile::tempdir().unwrap(); + let storage = tempfile::tempdir().unwrap(); + let (driver, _, _, access) = fixture(root.path(), storage.path()); + assert!(root.path().join("file.rs").is_file()); + let (snapshot, revision) = driver.installed_cut(); + assert!(snapshot.membership().is_empty()); + assert!(driver.install(&access, &snapshot, revision).is_err()); + assert_eq!( + driver.live.lock().unwrap().watcher(), + super::super::snapshot::WatcherState::Reconciling + ); + } + + #[test] + fn directory_uncertainty_survives_non_strict_install_until_a_full_walk() { + let root = tempfile::tempdir().unwrap(); + let storage = tempfile::tempdir().unwrap(); + let (driver, _, _, access) = fixture(root.path(), storage.path()); + let loader = SiblingLoader::new(driver.clone(), vec![]); + drop(loader.load(&access).unwrap()); + let directory = root.path().join("new-directory"); + std::fs::create_dir(&directory).unwrap(); + super::super::intent::WriteIntentListener::record_change( + &*driver, + &super::super::intent::WriteIntent::Directory(directory.clone()), + super::super::intent::WritePhase::Before, + ); + let (snapshot, revision) = driver.installed_cut(); + assert!(driver.install(&access, &snapshot, revision).is_err()); + assert!(driver + .installed_bundle() + .uncertain_paths + .contains(&directory)); + drop( + loader + .refresh(&access, snapshot.generation().clone()) + .unwrap(), + ); + assert!(driver.installed_bundle().uncertain_paths.is_empty()); + } #[test] fn composite_reconcile_omits_file_deleted_after_listing() { let root = tempfile::tempdir().unwrap(); diff --git a/crates/aft/src/views/snapshot.rs b/crates/aft/src/views/snapshot.rs index 04b1b12c64..b854925f88 100644 --- a/crates/aft/src/views/snapshot.rs +++ b/crates/aft/src/views/snapshot.rs @@ -788,6 +788,7 @@ impl LiveDelta { watcher: self.watcher, root_ignore_files: self.root_ignore_files.clone(), install_cut: None, + reconciled_epoch: None, readers: None, }), } @@ -1558,6 +1559,7 @@ pub fn carry_disk_state_only(path: &RelPath, generation: &ComposedGeneration) -> #[derive(Clone, Debug)] struct SnapshotInner { + reconciled_epoch: Option, generation: Arc, delta_version: u64, intent_version: u64, @@ -1854,6 +1856,15 @@ pub struct Snapshot { } impl Snapshot { + pub(crate) fn with_reconciled_epoch(mut self, epoch: u64) -> Self { + Arc::make_mut(&mut self.inner).reconciled_epoch = Some(epoch); + self + } + + pub(crate) fn reconciled_epoch(&self) -> Option { + self.inner.reconciled_epoch + } + pub fn install_cut(&self) -> Option<&InstallCut> { self.inner.install_cut.as_ref() } From de71b83a99ea574fb81767f3e1542d68bc06335f Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Mon, 26 Oct 2026 11:49:24 +0100 Subject: [PATCH 09/19] mason: fix Athena c10/c2/c3 post-publication lifecycle Run the SQLite publication probe after pointer sync but before scheduling generation maintenance. Preserve a landed pointer on callback failure and test a held managed probe connection against real scheduled maintenance. Treat any error after a possible CAS as requiring fresh revalidation, never unpublished rollback. Keep prepared reader leases until install validates; retire obsolete cut-sequence entries only after a successful swap and outside locks. Rebind immutable leases to post-publication query state without reopening data. Add real-driver post-CAS failure and rejected-install cache tests, and assert actual Conflict versus EquivalentWinner outcomes. Co-authored-by: alfonso-aft <289616620+alfonso-aft@users.noreply.github.com> --- crates/aft/src/views/first_load.rs | 246 +++++++++++++++++++---- crates/aft/src/views/manifest_v2.rs | 129 ++++++++++++ crates/aft/src/views/semantic_runtime.rs | 2 + crates/aft/src/views/snapshot.rs | 27 +++ 4 files changed, 369 insertions(+), 35 deletions(-) diff --git a/crates/aft/src/views/first_load.rs b/crates/aft/src/views/first_load.rs index 2be075b70a..a35e55ecf2 100644 --- a/crates/aft/src/views/first_load.rs +++ b/crates/aft/src/views/first_load.rs @@ -1432,12 +1432,17 @@ pub struct CheckoutDriver { observed: std::sync::Mutex, installed: std::sync::Mutex>, live: std::sync::Mutex, - prepared_readers: std::sync::Mutex>>, + prepared_readers: std::sync::Mutex>, retry: std::sync::atomic::AtomicBool, #[cfg(test)] work: std::sync::Mutex, } +struct PreparedReaders { + seq: u64, + readers: Arc, +} + /// Source work only; publication and model counters belong to their stores. #[cfg(test)] #[derive(Clone, Copy, Debug, Default)] @@ -1593,9 +1598,9 @@ impl CheckoutDriver { installed: &InstalledBundle, ) -> Result<(), PlaneError> { if cut.seq != installed.seq || !Arc::ptr_eq(&cut.resident, &installed.composed) { - return Err(SiblingLoader::error( - "BaseChanged: installed bundle superseded", - )); + return Err(SiblingLoader::error(format!( + "{CHECKOUT_CHANGED_BEFORE_INSTALL}: BaseChanged: installed bundle superseded" + ))); } // A prior ignore-file record is not proof that the current membership // epoch was walked. Only the completed strict walk carries this tag. @@ -1629,7 +1634,9 @@ impl FirstLoadDriver for CheckoutDriver { .unwrap_or_else(std::sync::PoisonError::into_inner); live.replay_onto(snapshot, cut) .map(|_| ()) - .map_err(|error| SiblingLoader::error(format!("{error:?}")))?; + .map_err(|error| { + SiblingLoader::error(format!("{CHECKOUT_CHANGED_BEFORE_INSTALL}: {error:?}")) + })?; let installed = self .installed .lock() @@ -1763,7 +1770,9 @@ impl FirstLoadDriver for CheckoutDriver { .prepared_readers .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) - .remove(snapshot.generation().name()); + .get(snapshot.generation().name()) + .map(|prepared| prepared.readers.clone()); + let from_prepared = prepared.is_some(); let own_installed = prepared.is_some() || previously_owned; let readers = match prepared { Some(readers) => readers, @@ -1793,14 +1802,28 @@ impl FirstLoadDriver for CheckoutDriver { .live .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); - let mut successor = live - .replay_onto(snapshot, cut) - .map_err(|error| SiblingLoader::error(format!("{error:?}")))?; + let install_error = |error: PlaneError| { + if from_prepared { + self.retry.store(true, std::sync::atomic::Ordering::Release); + SiblingLoader::error(format!( + "{CHECKOUT_CHANGED_BEFORE_INSTALL}: published installation refused: {}", + error.reason + )) + } else { + error + } + }; + let mut successor = live.replay_onto(snapshot, cut).map_err(|error| { + install_error(SiblingLoader::error(format!( + "{CHECKOUT_CHANGED_BEFORE_INSTALL}: {error:?}" + ))) + })?; let mut installed = self .installed .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); - self.validate_install_identity(snapshot, cut, &live, &installed)?; + self.validate_install_identity(snapshot, cut, &live, &installed) + .map_err(install_error)?; let seq = installed.seq + 1; successor.set_watcher(super::snapshot::WatcherState::Healthy); let published = snapshot @@ -1860,6 +1883,22 @@ impl FirstLoadDriver for CheckoutDriver { // Keep displaced owners alive until both root locks are released. drop(retired_bundle); drop(retired_delta); + let mut retired_prepared = Vec::new(); + { + let mut prepared = self + .prepared_readers + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + prepared.retain(|_, entry| { + if entry.seq < seq { + retired_prepared.push(entry.readers.clone()); + false + } else { + true + } + }); + } + drop(retired_prepared); Ok(()) } fn build_own_generation( @@ -1971,18 +2010,22 @@ impl FirstLoadDriver for CheckoutDriver { // before publication and never run under the installed-bundle mutex. let readers = PlaneReaders::open(access, generation.clone(), &self.adapters)?; let mut published = false; + let mut progress = PublicationProgress::NotAttempted; let mut commit = || { + self.validate_cut(access, snapshot, 0)?; let prepared = prepared .take() .ok_or_else(|| SiblingLoader::error("publication committed twice"))?; + progress = PublicationProgress::MayHaveLanded; store - .commit_v2(prepared, None) + .commit_v2_before_sweep(prepared, None, || store.current_generation_read_only().map(|_| ())) .map_err(|error| SiblingLoader::error(error.to_string())) .and_then(|outcome| match outcome { - super::manifest_v2::PublishV2::Published => { unpublished.complete(); published = true; Ok(()) }, - super::manifest_v2::PublishV2::Conflict { .. } | super::manifest_v2::PublishV2::EquivalentWinner { .. } => { + super::manifest_v2::PublishV2::Published => { progress = PublicationProgress::Published; unpublished.complete(); published = true; Ok(()) }, + outcome @ (super::manifest_v2::PublishV2::Conflict { .. } | super::manifest_v2::PublishV2::EquivalentWinner { .. }) => { + progress = PublicationProgress::NotAttempted; self.retry.store(true, std::sync::atomic::Ordering::Release); - Err(SiblingLoader::error(format!("{CHECKOUT_CHANGED_BEFORE_INSTALL}: publication lost; revalidate a fresh job"))) + Err(SiblingLoader::error(format!("{CHECKOUT_CHANGED_BEFORE_INSTALL}: publication lost ({outcome:?}); revalidate a fresh job"))) } }) }; @@ -1991,31 +2034,52 @@ impl FirstLoadDriver for CheckoutDriver { .read() .unwrap_or_else(std::sync::PoisonError::into_inner) .clone(); - match fence { - Some(fence) => fence.publish(&mut commit)?, - None => commit()?, + let publication = match fence { + Some(fence) => fence.publish(&mut commit), + None => commit(), + }; + drop(commit); + if let Err(error) = publication { + if progress != PublicationProgress::NotAttempted { + unpublished.complete(); + self.retry.store(true, std::sync::atomic::Ordering::Release); + return Err(SiblingLoader::error(format!("{CHECKOUT_CHANGED_BEFORE_INSTALL}: publication may have landed; revalidate: {}", error.reason))); + } + return Err(error); } if !published { return Err(SiblingLoader::error("publication fence did not commit")); } - self.owner - .registry() - .note_publish(self.owner.scope()) - .map_err(|error| SiblingLoader::error(error.to_string()))?; - // Successful publication must also be readable through the registry's - // read-only pointer path. Let SQLite establish its WAL sidecars here; - // skipping this probe changes later opens of a read-only view directory. - let _ = store - .current_generation_read_only() - .map_err(|error| SiblingLoader::error(error.to_string()))?; - for adapter in &self.adapters { - adapter.generation_published(access, &generation)?; - } - self.prepared_readers - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .insert(generation.name().to_owned(), readers); - Ok(generation) + let finish = (|| { + self.owner + .registry() + .note_publish(self.owner.scope()) + .map_err(|error| SiblingLoader::error(error.to_string()))?; + for adapter in &self.adapters { + adapter.generation_published(access, &generation)?; + } + let readers = readers.rebind(access)?; + let retired = self + .prepared_readers + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .insert( + generation.name().to_owned(), + PreparedReaders { + seq: snapshot.install_cut().map_or(0, |cut| cut.seq), + readers, + }, + ); + drop(retired); + Ok(generation) + })(); + finish.map_err(|error: PlaneError| { + self.retry.store(true, std::sync::atomic::Ordering::Release); + SiblingLoader::error(format!( + "{CHECKOUT_CHANGED_BEFORE_INSTALL}: durable publication needs revalidation: {}", + error.reason + )) + }) } } @@ -2330,6 +2394,58 @@ mod composite_tests { } } + struct FailAfterLandedCas; + impl PublishFence for FailAfterLandedCas { + fn publish( + &self, + commit: &mut dyn FnMut() -> Result<(), PlaneError>, + ) -> Result<(), PlaneError> { + commit()?; + Err(SiblingLoader::error("failure after durable CAS")) + } + } + + #[test] + fn real_checkout_post_cas_error_preserves_publication_and_requires_revalidation() { + let root = tempfile::tempdir().unwrap(); + let storage = tempfile::tempdir().unwrap(); + let (driver, _, _, access) = fixture(root.path(), storage.path()); + driver.set_publish_fence(Arc::new(FailAfterLandedCas)); + let error = SiblingLoader::new(driver.clone(), vec![]) + .load(&access) + .unwrap_err(); + assert!( + error.reason.contains("publication may have landed"), + "{error:?}" + ); + assert!(driver.take_revalidated_job()); + assert_eq!(driver.installed_bundle().composed.name(), "empty"); + let store = driver.owner.view_store().unwrap(); + let published = store.current_generation_read_only().unwrap().unwrap(); + let manifest = store.load_manifest_v2(&published).unwrap(); + let blobs = driver.owner.open_store(FamilyPlane::Trigram).unwrap(); + for key in manifest.ready_keys() { + assert!(blobs.get(&key).unwrap().is_some()); + } + driver.set_publish_fence(Arc::new(AcceptPublication)); + drop( + SiblingLoader::new(driver.clone(), vec![]) + .load(&access) + .unwrap(), + ); + assert_ne!(driver.installed_bundle().composed.name(), "empty"); + } + + struct AcceptPublication; + impl PublishFence for AcceptPublication { + fn publish( + &self, + commit: &mut dyn FnMut() -> Result<(), PlaneError>, + ) -> Result<(), PlaneError> { + commit() + } + } + #[test] fn real_checkout_publication_opens_blob_readers_before_cas_and_installs_after() { let root = tempfile::tempdir().unwrap(); @@ -2507,6 +2623,58 @@ mod composite_tests { ); } + #[test] + fn rejected_install_does_not_consume_prepared_readers() { + let root = tempfile::tempdir().unwrap(); + let storage = tempfile::tempdir().unwrap(); + let (driver, _, _, access) = fixture(root.path(), storage.path()); + drop( + SiblingLoader::new(driver.clone(), vec![]) + .load(&access) + .unwrap(), + ); + let (current, revision) = driver.installed_cut(); + let seq = driver.installed_bundle().seq; + let mut cut = current.install_cut().unwrap().clone(); + cut.seq = cut.seq.saturating_sub(1); + let generation = driver + .build_own_generation(&access, ¤t, false) + .unwrap(); + let readers = driver + .prepared_readers + .lock() + .unwrap() + .get(generation.name()) + .unwrap() + .readers + .clone(); + let snapshot = super::super::snapshot::LiveDelta::new(generation.clone()) + .snapshot() + .with_install_cut(cut); + assert!(driver.install(&access, &snapshot, revision).is_err()); + let retained = driver + .prepared_readers + .lock() + .unwrap() + .get(generation.name()) + .unwrap() + .readers + .clone(); + assert!(Arc::ptr_eq(&retained, &readers)); + assert_eq!(driver.installed_bundle().seq, seq); + assert!(driver.take_revalidated_job()); + assert_eq!( + driver + .owner + .view_store() + .unwrap() + .current_generation_read_only() + .unwrap() + .as_deref(), + Some(generation.name()) + ); + } + #[test] fn directory_uncertainty_survives_non_strict_install_until_a_full_walk() { let root = tempfile::tempdir().unwrap(); @@ -2799,6 +2967,14 @@ mod composite_tests { let (snapshot, revision) = driver.installed_cut(); let error = loader.fold(&access, &snapshot, revision).unwrap_err(); assert!(error.reason.contains("publication lost")); + assert!( + error.reason.contains(if equivalent { + "EquivalentWinner" + } else { + "Conflict" + }), + "{error:?}" + ); assert!(driver.take_revalidated_job()); assert_eq!(driver.installed_bundle().seq, before.seq); assert_eq!( diff --git a/crates/aft/src/views/manifest_v2.rs b/crates/aft/src/views/manifest_v2.rs index ac84e324f9..86708e66ff 100644 --- a/crates/aft/src/views/manifest_v2.rs +++ b/crates/aft/src/views/manifest_v2.rs @@ -743,6 +743,18 @@ impl ViewStore { &self, prepared: PreparedV2, observer: Option<&dyn DurabilityObserver>, + ) -> Result { + self.commit_v2_before_sweep(prepared, observer, || Ok(())) + } + + /// Complete publication-time SQLite reads before scheduling retirement. + /// The callback must close its connections before returning, including on + /// error; a callback error does not roll back the already durable pointer. + pub(crate) fn commit_v2_before_sweep( + &self, + prepared: PreparedV2, + observer: Option<&dyn DurabilityObserver>, + after_publish: impl FnOnce() -> Result<()>, ) -> Result { let name = prepared.generation.to_string(); // Keep both the private attempt and the resolved pointer generation @@ -758,7 +770,9 @@ impl ViewStore { super::checkpoint_pointer_after_cas(&self.pointer_path())?; sync_directory(self.view_dir())?; observe(observer, DurabilityStep::PointerSynced); + let completion = after_publish(); super::generation::schedule_generation_sweep(self.clone()); + completion?; Ok(PublishV2::Published) } super::PublishOutcome::Conflict { current_generation } => { @@ -883,6 +897,121 @@ fn write_bytes_once(path: &Path, bytes: &[u8]) -> Result<()> { mod tests { use super::*; + #[test] + fn publication_probe_closes_before_generation_maintenance_is_scheduled() { + use std::sync::atomic::{AtomicBool, Ordering}; + use std::sync::{mpsc, Arc}; + let storage = tempfile::tempdir().unwrap(); + let root = tempfile::tempdir().unwrap(); + let registry = + super::super::registry::FamilyRegistry::open(storage.path(), "probe-order").unwrap(); + let owner = registry + .register_view_with_storage(storage.path(), "worker", root.path()) + .unwrap(); + let view = owner.view_store().unwrap(); + let first_manifest = manifest(); + let first = GenerationName::for_manifest(&first_manifest).unwrap(); + let prepared = view + .prepare_v2(&first, None, &first_manifest, None) + .unwrap(); + assert_eq!( + view.commit_v2(prepared, None).unwrap(), + PublishV2::Published + ); + super::super::wait_for_generation_maintenance_for_test(view.view_dir()); + let mut next_manifest = first_manifest; + next_manifest.header_mut().head_tree = Some("next-tree".into()); + let next = GenerationName::for_manifest(&next_manifest).unwrap(); + let open = Arc::new(AtomicBool::new(false)); + let overlap = Arc::new(AtomicBool::new(false)); + let (started, maintenance_started) = mpsc::channel(); + let observed_open = open.clone(); + let observed_overlap = overlap.clone(); + let _hook = crate::storage_retention::observe_test_hook( + view.view_dir(), + Arc::new(move |step| { + if step == "post-publication-sweep" { + observed_overlap + .fetch_or(observed_open.load(Ordering::SeqCst), Ordering::SeqCst); + let _ = started.send(()); + } + }), + ); + let (entered, probe_opened) = mpsc::channel(); + let (resume, finish_probe) = mpsc::channel(); + let publishing = view.clone(); + let next_for_worker = next.clone(); + let first_name = first.to_string(); + let worker = std::thread::spawn(move || { + let prepared = publishing + .prepare_v2(&next_for_worker, Some(&first_name), &next_manifest, None) + .unwrap(); + publishing.commit_v2_before_sweep(prepared, None, || { + let connection = crate::db::file_identity::IdentityConnection::open_with_flags( + publishing.pointer_path(), + rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY, + "publication probe ordering test", + ) + .unwrap(); + let _: String = connection + .query_row( + "SELECT generation FROM pointer WHERE singleton=1", + [], + |row| row.get(0), + ) + .unwrap(); + open.store(true, Ordering::SeqCst); + entered.send(()).unwrap(); + finish_probe + .recv_timeout(std::time::Duration::from_secs(10)) + .unwrap(); + drop(connection); + open.store(false, Ordering::SeqCst); + Ok(()) + }) + }); + probe_opened + .recv_timeout(std::time::Duration::from_secs(10)) + .unwrap(); + assert!(crate::db::file_identity::open_connections(&view.pointer_path()) > 0); + let scheduled_while_open = maintenance_started + .recv_timeout(std::time::Duration::from_millis(200)) + .is_ok(); + resume.send(()).unwrap(); + assert_eq!(worker.join().unwrap().unwrap(), PublishV2::Published); + super::super::wait_for_generation_maintenance_for_test(view.view_dir()); + assert!( + !scheduled_while_open, + "maintenance started before the publication probe closed" + ); + assert!(!overlap.load(Ordering::SeqCst)); + assert_eq!( + view.current_generation_read_only().unwrap(), + Some(next.to_string()) + ); + assert!(view.load_manifest_v2(&next.to_string()).is_ok()); + } + + #[test] + fn completion_error_does_not_undo_a_landed_pointer() { + let storage = tempfile::tempdir().unwrap(); + let view = ViewStore::open(storage.path(), "completion-error").unwrap(); + let manifest = manifest(); + let name = GenerationName::for_manifest(&manifest).unwrap(); + let prepared = view.prepare_v2(&name, None, &manifest, None).unwrap(); + assert!(view + .commit_v2_before_sweep(prepared, None, || Err(ViewError::InvalidManifest( + "probe failure".into() + ))) + .is_err()); + super::super::wait_for_generation_maintenance_for_test(view.view_dir()); + assert_eq!( + view.current_generation_read_only().unwrap(), + Some(name.to_string()) + ); + assert!(view.load_manifest_v2(&name.to_string()).is_ok()); + } + pub(crate) fn producers() -> Producers { Producers { trigram: "trigram-policy".to_string(), diff --git a/crates/aft/src/views/semantic_runtime.rs b/crates/aft/src/views/semantic_runtime.rs index c1a0cb37c8..7e70672fd5 100644 --- a/crates/aft/src/views/semantic_runtime.rs +++ b/crates/aft/src/views/semantic_runtime.rs @@ -2008,6 +2008,8 @@ impl std::fmt::Display for RefreshError { /// retried is SQLite's busy/locked /// (another connection holds the database for a moment); every other store /// error is surfaced as the checkout's named gap. +/// A publication or installation that may already have committed carries the +/// checkout-change marker so the worker revalidates a fresh job before serving it. pub fn is_transient_reason(reason: &str) -> bool { crate::semantic_index::embedding_failure_is_transient(reason) || [ diff --git a/crates/aft/src/views/snapshot.rs b/crates/aft/src/views/snapshot.rs index b854925f88..30a95a9b00 100644 --- a/crates/aft/src/views/snapshot.rs +++ b/crates/aft/src/views/snapshot.rs @@ -1706,6 +1706,33 @@ impl fmt::Debug for PlaneReaders { } impl PlaneReaders { + /// Refresh per-binding query state after publication without reopening any + /// immutable reader or taking the global reader-cache lock. + pub(crate) fn rebind( + &self, + access: &super::contracts::ViewAccess, + ) -> Result, super::contracts::PlaneError> { + let mut resources = Vec::with_capacity(self.adapters.len()); + for lease in &self.adapters { + if lease.key.1 != access.family() || lease.key.2 != access.scope() { + return Err(super::contracts::PlaneError::loader( + super::contracts::LoaderStage::Installation, + "reader lease belongs to another view", + )); + } + resources.push(lease.adapter.bind_reader( + access, + &self.generation, + lease.resource.clone(), + )?); + } + Ok(Arc::new(Self { + resources, + adapters: self.adapters.clone(), + generation: self.generation.clone(), + })) + } + pub(super) fn resource( &self, adapter: usize, From b0efd2e938d4c55cb3cbb6e4d83b16bf2402a4ad Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Mon, 26 Oct 2026 11:50:08 +0100 Subject: [PATCH 10/19] mason: fence Athena c6 unauthenticated bases until s3 Seed an untrusted persisted base header directly and verify foreign opening refuses it. Together with the existing legacy-publication refusal regression, both accepting entry points remain closed until s3 supplies an authenticated identity object and canonical digest validation. The parent explicitly deferred the persisted identity contract to s3. This change does not invent a digest encoding or enable a v3 writer/resolver. Co-authored-by: alfonso-aft <289616620+alfonso-aft@users.noreply.github.com> --- crates/aft/src/views/read.rs | 55 ++++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/crates/aft/src/views/read.rs b/crates/aft/src/views/read.rs index a49ef05fb2..96e7b7e207 100644 --- a/crates/aft/src/views/read.rs +++ b/crates/aft/src/views/read.rs @@ -606,6 +606,61 @@ pub fn open_foreign_generation( #[cfg(test)] mod producer_tests { + #[test] + fn foreign_open_refuses_unauthenticated_base_headers() { + use crate::views::manifest_v2::{ + BaseHeader, GenerationName, ManifestHeader, ManifestV2, Producers, PublishV2, + }; + let storage = tempfile::tempdir().unwrap(); + let root = tempfile::tempdir().unwrap(); + let registry = + crate::views::registry::FamilyRegistry::open(storage.path(), "unverified-base") + .unwrap(); + let owner = registry + .register_view_with_storage(storage.path(), "worker", root.path()) + .unwrap(); + let producers = Producers { + trigram: "tri".into(), + semantic: None, + callgraph: "graph".into(), + }; + let manifest = ManifestV2::new_v3( + ManifestHeader { + producers: producers.clone(), + head_tree: None, + ignore_fingerprint: None, + segment: None, + }, + Some(BaseHeader { + scope: "not-authenticated".into(), + membership_digest: "11".repeat(32), + identity_digest: "22".repeat(32), + bound_generation: "diagnostic-only".into(), + }), + Default::default(), + ); + // Seed an untrusted persisted header directly, bypassing the owner loader's refusal. + let store = owner.view_store().unwrap(); + 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 + ); + let reader = registry.register_reader("unverified-header").unwrap(); + let error = super::open_foreign_generation(&reader, "worker", &producers).unwrap_err(); + assert!( + error + .to_string() + .contains("durable v3 base resolution is not wired"), + "{error}" + ); + assert_eq!( + store.current_generation_read_only().unwrap(), + Some(name.to_string()) + ); + } + #[test] fn untracked_language_paths_do_not_read_source() { let root = tempfile::tempdir().unwrap(); From cbc8125b5147bc9cd3edf6afd87678cf1f56df22 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Mon, 26 Oct 2026 12:15:23 +0100 Subject: [PATCH 11/19] mason: fix Athena superseded cuts and late publication bases Capture the durable CAS predecessor before a strict walk and carry it through that job's seed installation. A foreign pointer published after the cut cannot become the old job's newly adopted CAS base. Serialize final cut validation/CAS with the installed sequence swap, without holding query locks across I/O. Carry pre-cut content-bound readiness through strict reconciliation rather than losing it when equality elides disk entries. Add regressions for superseded-cut pointer stability, foreign winners after a cut, and equal-content readiness carry; update external-driver result constructors mechanically. Co-authored-by: alfonso-aft <289616620+alfonso-aft@users.noreply.github.com> --- crates/aft/src/views/first_load.rs | 135 +++++++++++++++++- crates/aft/src/views/snapshot.rs | 11 ++ crates/aft/src/views/trigram.rs | 2 + .../integration/per_checkout_callgraph.rs | 2 + 4 files changed, 146 insertions(+), 4 deletions(-) diff --git a/crates/aft/src/views/first_load.rs b/crates/aft/src/views/first_load.rs index a35e55ecf2..75b84942e8 100644 --- a/crates/aft/src/views/first_load.rs +++ b/crates/aft/src/views/first_load.rs @@ -21,6 +21,10 @@ pub struct ReconciledCheckout { pub revision: u64, pub entries: BTreeMap, pub cut: Option, + /// Content-bound readiness already known at the strict cut. + pub readiness: BTreeMap, + /// Outer None delegates CAS to an external driver; Some(None) is an empty pointer. + pub publication_base: Option>, } /// The runtime owner supplies source reads and generation construction. Plane @@ -591,8 +595,14 @@ impl SiblingLoader { for (path, entry) in checkout.entries { delta.apply(path, entry); } + for (path, patch) in checkout.readiness { + delta.set_readiness(path, patch); + } let strict_epoch = checkout.cut.as_ref().map_or(delta.epoch(), |cut| cut.epoch); let mut snapshot = delta.snapshot().with_reconciled_epoch(strict_epoch); + if let Some(base) = checkout.publication_base { + snapshot = snapshot.with_publication_base(base); + } if let Some(cut) = checkout.cut { snapshot = snapshot.with_install_cut(cut); } @@ -808,6 +818,8 @@ mod tests { revision, entries, cut: None, + readiness: Default::default(), + publication_base: None, }) } fn build_own_generation( @@ -1433,6 +1445,9 @@ pub struct CheckoutDriver { installed: std::sync::Mutex>, live: std::sync::Mutex, prepared_readers: std::sync::Mutex>, + // Serialize the final identity check/CAS with the installed sequence swap, + // without holding query locks during I/O or plane-reader retirement. + publication_transition: std::sync::Mutex<()>, retry: std::sync::atomic::AtomicBool, #[cfg(test)] work: std::sync::Mutex, @@ -1517,6 +1532,7 @@ impl CheckoutDriver { })), live: std::sync::Mutex::new(delta), prepared_readers: Default::default(), + publication_transition: Default::default(), retry: Default::default(), #[cfg(test)] work: Default::default(), @@ -1660,6 +1676,13 @@ impl FirstLoadDriver for CheckoutDriver { if super::intent::active(self.owner.root()) { return Err(SiblingLoader::error(CHECKOUT_WRITE_ACTIVE)); } + let publication_base = + match super::ViewStore::existing_dir(self.owner.view_dir().to_path_buf()) { + Some(store) => store + .current_generation_read_only() + .map_err(|error| SiblingLoader::error(error.to_string()))?, + None => None, + }; let (installed, revision) = self.installed_cut(); let pending: std::collections::BTreeSet<_> = installed.pending_intent().cloned().collect(); let previous = self @@ -1750,6 +1773,8 @@ impl FirstLoadDriver for CheckoutDriver { revision, entries, cut: installed.install_cut().cloned(), + readiness: installed.readiness_patches().clone(), + publication_base: Some(publication_base), }) } fn install( @@ -1798,6 +1823,10 @@ impl FirstLoadDriver for CheckoutDriver { } } }; + let publication_guard = self + .publication_transition + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); let mut live = self .live .lock() @@ -1831,7 +1860,7 @@ impl FirstLoadDriver for CheckoutDriver { .published() .cloned() .unwrap_or_default(); - let installed_snapshot = successor + let mut installed_snapshot = successor .snapshot() .with_readers(readers.clone()) .with_install_cut(InstallCut { @@ -1841,6 +1870,11 @@ impl FirstLoadDriver for CheckoutDriver { version: successor.version(), intent_version: successor.snapshot().intent_version(), }); + if snapshot.reconciled_epoch().is_some() { + if let Some(base) = snapshot.publication_base() { + installed_snapshot = installed_snapshot.with_publication_base(base.clone()); + } + } let coverage = FamilyPlane::ALL .into_iter() .map(|plane| { @@ -1879,6 +1913,7 @@ impl FirstLoadDriver for CheckoutDriver { let retired_delta = std::mem::replace(&mut *live, successor); drop(installed); drop(live); + drop(publication_guard); // Last-drop reader close and residency-pin cleanup can perform I/O. // Keep displaced owners alive until both root locks are released. drop(retired_bundle); @@ -1918,9 +1953,12 @@ impl FirstLoadDriver for CheckoutDriver { .owner .view_store() .map_err(|error| SiblingLoader::error(error.to_string()))?; - let base = store - .current_generation() - .map_err(|error| SiblingLoader::error(error.to_string()))?; + let base = match snapshot.publication_base() { + Some(base) => base.clone(), + None => store + .current_generation() + .map_err(|error| SiblingLoader::error(error.to_string()))?, + }; let mut manifest = snapshot .generation() .flatten() @@ -2012,6 +2050,10 @@ impl FirstLoadDriver for CheckoutDriver { let mut published = false; let mut progress = PublicationProgress::NotAttempted; let mut commit = || { + let _publication_guard = self + .publication_transition + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); self.validate_cut(access, snapshot, 0)?; let prepared = prepared .take() @@ -2675,6 +2717,91 @@ mod composite_tests { ); } + #[test] + fn superseded_cut_is_refused_before_its_pointer_can_publish() { + let root = tempfile::tempdir().unwrap(); + let storage = tempfile::tempdir().unwrap(); + let (driver, _, _, access) = fixture(root.path(), storage.path()); + let loader = SiblingLoader::new(driver.clone(), vec![]); + drop(loader.load(&access).unwrap()); + let store = driver.owner.view_store().unwrap(); + let before = store.current_generation_read_only().unwrap(); + std::fs::write(root.path().join("file.rs"), b"candidate B").unwrap(); + driver.record_change(RelPath::new(b"file.rs".to_vec()).unwrap()); + let (candidate, revision) = loader + .reconciled(&access, driver.installed_snapshot().generation().clone()) + .unwrap(); + driver.install(&access, &candidate, revision).unwrap(); + assert!(loader.fold(&access, &candidate, revision).is_err()); + assert_eq!(store.current_generation_read_only().unwrap(), before); + } + + #[test] + fn a_foreign_pointer_after_the_walk_cut_cannot_become_its_cas_base() { + let root = tempfile::tempdir().unwrap(); + let storage = tempfile::tempdir().unwrap(); + let (driver, _, _, access) = fixture(root.path(), storage.path()); + let loader = SiblingLoader::new(driver.clone(), vec![]); + drop(loader.load(&access).unwrap()); + let store = driver.owner.view_store().unwrap(); + let before = store.current_generation_read_only().unwrap(); + std::fs::write(root.path().join("file.rs"), b"candidate B").unwrap(); + driver.record_change(RelPath::new(b"file.rs".to_vec()).unwrap()); + let (candidate, revision) = loader + .reconciled(&access, driver.installed_snapshot().generation().clone()) + .unwrap(); + assert_eq!(candidate.publication_base(), Some(&before)); + let mut foreign = driver.installed_snapshot().generation().flatten().unwrap(); + foreign.header_mut().ignore_fingerprint = Some("foreign-winner".into()); + let name = super::super::manifest_v2::GenerationName::for_manifest(&foreign).unwrap(); + let prepared = store + .prepare_v2(&name, before.as_deref(), &foreign, None) + .unwrap(); + assert_eq!( + store.commit_v2(prepared, None).unwrap(), + super::super::manifest_v2::PublishV2::Published + ); + let error = loader.fold(&access, &candidate, revision).unwrap_err(); + assert!(error.reason.contains("publication lost"), "{error:?}"); + assert_eq!( + store.current_generation_read_only().unwrap(), + Some(name.to_string()) + ); + assert!(driver.take_revalidated_job()); + } + + #[test] + fn strict_walk_preserves_pre_cut_readiness_when_disk_equals_the_base() { + let root = tempfile::tempdir().unwrap(); + let storage = tempfile::tempdir().unwrap(); + let (driver, _, _, access) = fixture(root.path(), storage.path()); + let loader = SiblingLoader::new(driver.clone(), vec![]); + drop(loader.load(&access).unwrap()); + let file = RelPath::new(b"file.rs".to_vec()).unwrap(); + driver.live.lock().unwrap().set_readiness( + file.clone(), + super::super::snapshot::PlanePatch { + content: crate::blob_store::v2::ContentHash::of(b"strict content"), + planes: EntryPlanes { + trigram: Some(super::super::readiness::PlaneState::pending( + "checkout repair", + )), + ..Default::default() + }, + }, + ); + driver.record_change(file.clone()); + let (walk, _) = loader + .reconciled(&access, driver.installed_snapshot().generation().clone()) + .unwrap(); + assert_eq!( + walk.readiness_patches().get(&file).unwrap().planes.trigram, + Some(super::super::readiness::PlaneState::pending( + "checkout repair" + )) + ); + } + #[test] fn directory_uncertainty_survives_non_strict_install_until_a_full_walk() { let root = tempfile::tempdir().unwrap(); diff --git a/crates/aft/src/views/snapshot.rs b/crates/aft/src/views/snapshot.rs index 30a95a9b00..62fd06944a 100644 --- a/crates/aft/src/views/snapshot.rs +++ b/crates/aft/src/views/snapshot.rs @@ -789,6 +789,7 @@ impl LiveDelta { root_ignore_files: self.root_ignore_files.clone(), install_cut: None, reconciled_epoch: None, + publication_base: None, readers: None, }), } @@ -1560,6 +1561,7 @@ pub fn carry_disk_state_only(path: &RelPath, generation: &ComposedGeneration) -> #[derive(Clone, Debug)] struct SnapshotInner { reconciled_epoch: Option, + publication_base: Option>, generation: Arc, delta_version: u64, intent_version: u64, @@ -1883,6 +1885,15 @@ pub struct Snapshot { } impl Snapshot { + pub(crate) fn with_publication_base(mut self, base: Option) -> Self { + Arc::make_mut(&mut self.inner).publication_base = Some(base); + self + } + + pub(crate) fn publication_base(&self) -> Option<&Option> { + self.inner.publication_base.as_ref() + } + pub(crate) fn with_reconciled_epoch(mut self, epoch: u64) -> Self { Arc::make_mut(&mut self.inner).reconciled_epoch = Some(epoch); self diff --git a/crates/aft/src/views/trigram.rs b/crates/aft/src/views/trigram.rs index f31b0e9e4f..4dbbab59e0 100644 --- a/crates/aft/src/views/trigram.rs +++ b/crates/aft/src/views/trigram.rs @@ -491,6 +491,8 @@ impl TrigramDriver { revision: revision(&delta), entries: walk.entries, cut: None, + readiness: delta.readiness().clone(), + publication_base: None, }) } pub fn revision(&self, _: &super::contracts::ViewAccess) -> u64 { diff --git a/crates/aft/tests/integration/per_checkout_callgraph.rs b/crates/aft/tests/integration/per_checkout_callgraph.rs index adbfa4369b..ac6b17949d 100644 --- a/crates/aft/tests/integration/per_checkout_callgraph.rs +++ b/crates/aft/tests/integration/per_checkout_callgraph.rs @@ -277,6 +277,8 @@ impl FirstLoadDriver for Driver { callgraph::attach(&mut live, &self.source, "typescript")?; Ok(ReconciledCheckout { cut: None, + readiness: Default::default(), + publication_base: None, revision: self.revision(access), entries: BTreeMap::from([(RelPath::new(b"fixture.ts").unwrap(), live)]), }) From 611718791f6be2261252f919dbf28fe827513d95 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Mon, 26 Oct 2026 13:23:28 +0100 Subject: [PATCH 12/19] mason: fix Athena plane applicability coverage Count live work only for registered composite planes whose applicability predicate accepts the path. A dirty trigram entry must not invent semantic or callgraph work. Add a direct installed-bundle coverage regression; no routing or producer defaults change. Co-authored-by: alfonso-aft <289616620+alfonso-aft@users.noreply.github.com> --- crates/aft/src/views/first_load.rs | 39 +++++++++++++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/crates/aft/src/views/first_load.rs b/crates/aft/src/views/first_load.rs index 75b84942e8..ebcb8d9c00 100644 --- a/crates/aft/src/views/first_load.rs +++ b/crates/aft/src/views/first_load.rs @@ -1892,7 +1892,11 @@ impl FirstLoadDriver for CheckoutDriver { Some(snapshot.generation()), &installed_snapshot, &Default::default(), - &|_| true, + &|path| { + self.planes.iter().any(|bridge| { + bridge.plane() == plane && bridge.applies_to(path) + }) + }, ) }, ) @@ -2802,6 +2806,39 @@ mod composite_tests { ); } + #[test] + fn coverage_does_not_invent_work_for_unregistered_planes() { + let root = tempfile::tempdir().unwrap(); + let storage = tempfile::tempdir().unwrap(); + let (driver, _, _, access) = fixture(root.path(), storage.path()); + drop( + SiblingLoader::new(driver.clone(), vec![]) + .load(&access) + .unwrap(), + ); + let (current, revision) = driver.installed_cut(); + std::fs::write(root.path().join("file.rs"), b"new bytes").unwrap(); + let mut delta = super::super::snapshot::LiveDelta::new(current.generation().clone()); + delta.apply( + RelPath::new(b"file.rs".to_vec()).unwrap(), + LiveEntry::new(DiskState::of_bytes(b"new bytes"), 1), + ); + let snapshot = delta + .snapshot() + .with_install_cut(current.install_cut().unwrap().clone()); + driver.install(&access, &snapshot, revision).unwrap(); + let installed = driver.installed_bundle(); + for plane in [FamilyPlane::Semantic, FamilyPlane::Callgraph] { + assert_eq!( + installed.coverage[&plane], + super::super::readiness::PlaneReadiness::Ready { + pending: 0, + failed: 0 + } + ); + } + } + #[test] fn directory_uncertainty_survives_non_strict_install_until_a_full_walk() { let root = tempfile::tempdir().unwrap(); From 45a042ebeeca10a47bda193e174b95526780c1c4 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Mon, 26 Oct 2026 13:24:01 +0100 Subject: [PATCH 13/19] mason: reject Athena f4 nonmember composition tombstones A worker deletion must name a member of its inherited base. Reject structural no-op/foreign tombstones and cover the refusal directly. This is membership consistency, not the authenticated base digest validation explicitly deferred to s3. Co-authored-by: alfonso-aft <289616620+alfonso-aft@users.noreply.github.com> --- crates/aft/src/views/snapshot.rs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/crates/aft/src/views/snapshot.rs b/crates/aft/src/views/snapshot.rs index 62fd06944a..043c0bddc0 100644 --- a/crates/aft/src/views/snapshot.rs +++ b/crates/aft/src/views/snapshot.rs @@ -130,6 +130,13 @@ impl ComposedGeneration { )); } delta.ensure_producers(&base.header().producers)?; + for path in delta.tombstones() { + if base.entry(path).is_none() { + return Err(super::ViewError::InvalidManifest( + "a worker tombstone must name an inherited base entry".into(), + )); + } + } let mut overrides = BTreeMap::new(); for (path, patch) in delta.readiness_overrides() { let Some(EntryV2::Regular { .. }) = base.entry(path) else { @@ -2208,6 +2215,13 @@ mod composition_tests { ); } + #[test] + fn a_worker_tombstone_must_name_an_inherited_member() { + let mut delta = worker(); + delta.set(path("not-in-base.rs"), None).unwrap(); + assert!(ComposedGeneration::compose("nonmember-tombstone", delta, base(), None).is_err()); + } + #[test] fn composed_answers_equal_independently_flattened_manifest_for_every_plane() { let base = base(); From 7b7a8145c8ab4a49d564dde949ee9cd378cdcd3e Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Mon, 26 Oct 2026 13:24:50 +0100 Subject: [PATCH 14/19] mason: fix Athena F11 replay and callgraph key coherence Intent and readiness updates are not disk observations. Keep their versioned replay separate, and retain the latest version even for repeated identical observations so a strict walk cannot overwrite a later real read. Require observed content and size to match the manifest before pinning, storing or promoting a callgraph attachment. Add real CheckoutDriver mid-walk revert persistence and direct mismatched-observation regressions, plus replay ordering tests. Existing valid-build rejection fixtures now refresh their installed cut after seed installation; their rejection/panic assertions are unchanged. Co-authored-by: alfonso-aft <289616620+alfonso-aft@users.noreply.github.com> --- crates/aft/src/views/first_load.rs | 210 +++++++++++++++++++++++++++++ crates/aft/src/views/snapshot.rs | 129 +++++++++++++----- 2 files changed, 305 insertions(+), 34 deletions(-) diff --git a/crates/aft/src/views/first_load.rs b/crates/aft/src/views/first_load.rs index ebcb8d9c00..45f9df331b 100644 --- a/crates/aft/src/views/first_load.rs +++ b/crates/aft/src/views/first_load.rs @@ -3227,6 +3227,11 @@ impl CompositePlane for CallgraphBridge { if entry.disk == super::snapshot::DiskState::Absent || !self.applies_to(path) { continue; } + // A concurrent reconcile may have replaced the observation cache. + // Never label manifest content with a key computed for other bytes. + if super::snapshot::DiskState::in_generation(manifest, path) != entry.disk { + continue; + } let attachment = entry .attachments .get(&FamilyPlane::Callgraph) @@ -3331,6 +3336,209 @@ mod callgraph_bridge_tests { } } + fn graph_driver( + root: &std::path::Path, + storage: &std::path::Path, + bridge: &Arc, + plane: Arc, + ) -> (Arc, ViewAccess) { + let registry = + super::super::registry::FamilyRegistry::open(storage, "coherent-graph").unwrap(); + let owner = registry + .register_view_with_storage(storage, "local", root) + .unwrap(); + let driver = Arc::new( + CheckoutDriver::new( + owner.clone(), + Producers { + trigram: "test".into(), + semantic: None, + callgraph: super::super::callgraph::PRODUCER.into(), + }, + None, + Arc::new(Walker), + vec![plane], + ) + .with_adapters(vec![bridge.adapter.clone()]), + ); + (driver, ViewAccess::Owner(owner)) + } + + fn graph_key(bytes: &[u8]) -> crate::blob_store::v2::FamilyKey { + let mut entry = LiveEntry::new(super::super::snapshot::DiskState::of_bytes(bytes), 0); + super::super::callgraph::attach(&mut entry, bytes, "rust").unwrap(); + entry.attachments[&FamilyPlane::Callgraph] + .downcast_ref::() + .unwrap() + .key + } + + struct RevertAfterAttachment { + bridge: Arc, + driver: std::sync::Mutex>, + root: std::path::PathBuf, + armed: std::sync::atomic::AtomicBool, + } + impl CompositePlane for RevertAfterAttachment { + fn plane(&self) -> FamilyPlane { + FamilyPlane::Callgraph + } + fn applies_to(&self, path: &RelPath) -> bool { + self.bridge.applies_to(path) + } + fn attachment( + &self, + path: &RelPath, + bytes: &[u8], + ) -> Result { + let attachment = self.bridge.attachment(path, bytes)?; + if self.armed.swap(false, std::sync::atomic::Ordering::SeqCst) { + std::fs::write( + self.root.join("file.rs"), + b"fn original() {} fn caller() { original(); }", + ) + .unwrap(); + let driver = self.driver.lock().unwrap().upgrade().unwrap(); + driver.record_change(path.clone()); + } + Ok(attachment) + } + fn materialize( + &self, + owner: &super::super::registry::ViewRegistration, + generation: &str, + snapshot: &Snapshot, + observed: &BTreeMap, + manifest: &mut super::super::manifest_v2::ManifestV2, + live: &mut crate::pins::LivePin, + seed_derived: bool, + ) -> Result<(), PlaneError> { + self.bridge.materialize( + owner, + generation, + snapshot, + observed, + manifest, + live, + seed_derived, + ) + } + fn finish_generation( + &self, + owner: &super::super::registry::ViewRegistration, + staging: &str, + published: &str, + ) -> Result<(), PlaneError> { + self.bridge.finish_generation(owner, staging, published) + } + } + + #[test] + fn a_mid_walk_revert_cannot_publish_a_key_for_other_manifest_bytes() { + let a = b"fn original() {} fn caller() { original(); }"; + let b = b"fn replacement() {} fn caller() { replacement(); }"; + let root = tempfile::tempdir().unwrap(); + let storage = tempfile::tempdir().unwrap(); + std::fs::write(root.path().join("file.rs"), a).unwrap(); + let bridge = Arc::new(CallgraphBridge::default()); + let plane = Arc::new(RevertAfterAttachment { + bridge: bridge.clone(), + driver: Default::default(), + root: root.path().to_path_buf(), + armed: false.into(), + }); + let (driver, access) = graph_driver(root.path(), storage.path(), &bridge, plane.clone()); + *plane.driver.lock().unwrap() = Arc::downgrade(&driver); + let loader = SiblingLoader::new(driver.clone(), vec![bridge.adapter.clone()]); + drop(loader.load(&access).unwrap()); + let key_a = graph_key(a); + let key_b = graph_key(b); + assert_ne!(key_a, key_b); + std::fs::write(root.path().join("file.rs"), b).unwrap(); + driver.record_change(RelPath::new(b"file.rs".to_vec()).unwrap()); + plane.armed.store(true, std::sync::atomic::Ordering::SeqCst); + let snapshot = loader + .refresh(&access, driver.installed_snapshot().generation().clone()) + .unwrap(); + let store = driver.owner.view_store().unwrap(); + let current = store.current_generation_read_only().unwrap().unwrap(); + let manifest = store.load_manifest_v2(¤t).unwrap(); + let file = RelPath::new(b"file.rs".to_vec()).unwrap(); + let entry = manifest.get(&file).unwrap(); + let expected = if entry.content() == Some(crate::blob_store::v2::ContentHash::of(a)) { + key_a + } else { + assert_eq!( + entry.content(), + Some(crate::blob_store::v2::ContentHash::of(b)) + ); + key_b + }; + assert_eq!( + entry.plane_state(FamilyPlane::Callgraph), + Some(&super::super::readiness::PlaneState::ready(&expected)) + ); + assert!(snapshot.pending_intent().any(|path| path == &file)); + assert_eq!( + bridge.adapter.readiness(&access, &snapshot), + super::super::readiness::PlaneReadiness::Building + ); + } + + #[test] + fn callgraph_materialization_ignores_an_observation_for_other_content() { + let a = b"fn original() {} fn caller() { original(); }"; + let b = b"fn replacement() {} fn caller() { replacement(); }"; + let root = tempfile::tempdir().unwrap(); + let storage = tempfile::tempdir().unwrap(); + std::fs::write(root.path().join("file.rs"), a).unwrap(); + let bridge = Arc::new(CallgraphBridge::default()); + let (driver, access) = graph_driver(root.path(), storage.path(), &bridge, bridge.clone()); + let snapshot = SiblingLoader::new(driver.clone(), vec![bridge.adapter.clone()]) + .load(&access) + .unwrap() + .snapshot; + let mut manifest = snapshot.generation().flatten().unwrap(); + let file = RelPath::new(b"file.rs".to_vec()).unwrap(); + let mut other = LiveEntry::new(super::super::snapshot::DiskState::of_bytes(b), 1); + other + .attachments + .insert(FamilyPlane::Callgraph, bridge.attachment(&file, b).unwrap()); + let observed = BTreeMap::from([(file.clone(), other)]); + let _assembly = crate::pins::AssemblyPin::create_v2( + &driver.owner, + "guard-build", + &manifest.ready_keys().collect::>(), + ) + .unwrap(); + let mut live = crate::pins::LivePin::create(&driver.owner).unwrap(); + bridge + .materialize( + &driver.owner, + "guard-build", + &snapshot, + &observed, + &mut manifest, + &mut live, + false, + ) + .unwrap(); + assert_eq!( + manifest + .get(&file) + .unwrap() + .plane_state(FamilyPlane::Callgraph), + Some(&super::super::readiness::PlaneState::ready(&graph_key(a))) + ); + assert!(driver + .owner + .open_store(FamilyPlane::Callgraph) + .unwrap() + .get(&graph_key(b)) + .unwrap() + .is_none()); + } + struct RejectAfterBuild { directory: std::path::PathBuf, panic: bool, @@ -3412,6 +3620,7 @@ mod callgraph_bridge_tests { .reconciled(&access, loader.seed(&access).unwrap()) .unwrap(); driver.install(&access, &snapshot, revision).unwrap(); + let snapshot = driver.installed_snapshot(); let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { driver.build_own_generation(&access, &snapshot, false) })); @@ -3481,6 +3690,7 @@ mod callgraph_bridge_tests { let seed = loader.seed(&access).unwrap(); let (seed_snapshot, revision) = loader.reconciled(&access, seed).unwrap(); driver.install(&access, &seed_snapshot, revision).unwrap(); + let seed_snapshot = driver.installed_snapshot(); assert_eq!( driver.installed_state(&access, FamilyPlane::Callgraph).1, vec![root.path().join("file.rs")] diff --git a/crates/aft/src/views/snapshot.rs b/crates/aft/src/views/snapshot.rs index 043c0bddc0..a33014f5d0 100644 --- a/crates/aft/src/views/snapshot.rs +++ b/crates/aft/src/views/snapshot.rs @@ -8,8 +8,8 @@ //! The live delta holds the difference between the checkout's disk and its //! generation. Its invariant: **an entry exists for a path exactly when the //! last observed disk state differs from the generation's entry**. Every -//! mutation is journaled, including one that removes an entry, because the -//! generation switch needs it. +//! disk mutation is journaled, including one that removes an entry. Readiness +//! and write intents are versioned separately: neither observes new disk bytes. //! //! When a new generation `G+1` is published (by this daemon or by another //! one), the successor delta is derived over @@ -567,6 +567,8 @@ pub struct LiveDelta { entries: Arc, readiness: Arc>, journal: Vec, + observation_journal: BTreeMap, + readiness_journal: BTreeMap, intent: Arc>, intent_version: u64, watcher: WatcherState, @@ -629,6 +631,8 @@ impl LiveDelta { entries: Arc::new(Entries::new()), readiness: Arc::new(BTreeMap::new()), journal: Vec::new(), + observation_journal: BTreeMap::new(), + readiness_journal: BTreeMap::new(), intent: Arc::new(BTreeMap::new()), intent_version: 0, watcher: WatcherState::Healthy, @@ -675,16 +679,7 @@ impl LiveDelta { pub fn set_readiness(&mut self, path: RelPath, patch: PlanePatch) { self.version += 1; - let disk = self - .entries - .get(&path) - .map_or_else(|| self.base.disk_state(&path), |entry| entry.disk); - self.journal.retain(|record| record.rel_path != path); - self.journal.push(JournalRecord { - version: self.version, - rel_path: path.clone(), - disk, - }); + self.readiness_journal.insert(path.clone(), self.version); Arc::make_mut(&mut self.readiness).insert(path, patch); } @@ -705,6 +700,8 @@ impl LiveDelta { .get(&rel_path) .map_or_else(|| self.base.disk_state(&rel_path), |entry| entry.disk); self.version += 1; + self.observation_journal + .insert(rel_path.clone(), self.version); let attachments_changed = self.entries.get(&rel_path).is_some_and(|previous| { previous.attachments.len() != entry.attachments.len() || previous.attachments.iter().any(|(plane, attachment)| { @@ -736,16 +733,8 @@ impl LiveDelta { pub fn record_intent(&mut self, rel_path: RelPath) -> u64 { self.intent_version += 1; self.version += 1; - let disk = self - .entries - .get(&rel_path) - .map_or_else(|| self.base.disk_state(&rel_path), |entry| entry.disk); - self.journal.retain(|record| record.rel_path != rel_path); - self.journal.push(JournalRecord { - version: self.version, - rel_path: rel_path.clone(), - disk, - }); + // An intent is not a disk observation. Replaying its old disk value + // would overwrite bytes a later strict walk actually observed. Arc::make_mut(&mut self.intent).insert(rel_path, self.intent_version); self.intent_version } @@ -819,12 +808,18 @@ impl LiveDelta { if !Arc::ptr_eq(&draft.expected_base, &self.base) { return Err(SwitchError::BaseChanged); } - let replay = self - .journal + let mut replay = self + .observation_journal .iter() - .filter(|record| record.version > draft.cut_version) - .map(|record| record.rel_path.clone()) + .filter(|(_, version)| **version > draft.cut_version) + .map(|(path, _)| path.clone()) .collect::>(); + replay.extend( + self.readiness_journal + .iter() + .filter(|(_, version)| **version > draft.cut_version) + .map(|(path, _)| path.clone()), + ); for path in replay { let observed = self.entries.get(&path).cloned(); rederive_path( @@ -848,6 +843,8 @@ impl LiveDelta { // One successor is in flight at a time per root, so nothing older than // the swap is needed again. self.journal.clear(); + self.observation_journal.clear(); + self.readiness_journal.clear(); Ok(()) } @@ -860,6 +857,8 @@ impl LiveDelta { entries: inner.entries.clone(), readiness: inner.readiness.clone(), journal: Vec::new(), + observation_journal: BTreeMap::new(), + readiness_journal: BTreeMap::new(), intent: inner.intent.clone(), intent_version: inner.intent_version, watcher: inner.watcher, @@ -880,19 +879,23 @@ impl LiveDelta { return Err(SwitchError::BaseChanged); } let mut next = Self::from_snapshot(snapshot); - for record in self - .journal + for (path, _) in self + .observation_journal .iter() - .filter(|record| record.version > cut.version) + .filter(|(_, version)| **version > cut.version) { let entry = self .entries - .get(&record.rel_path) + .get(path) .cloned() - .unwrap_or_else(|| carry_disk_state_only(&record.rel_path, &self.base)); - next.apply(record.rel_path.clone(), entry); - if let Some(patch) = self.readiness.get(&record.rel_path) { - next.set_readiness(record.rel_path.clone(), patch.clone()); + .unwrap_or_else(|| carry_disk_state_only(path, &self.base)); + next.apply(path.clone(), entry); + } + for (path, version) in &self.readiness_journal { + if *version > cut.version { + if let Some(patch) = self.readiness.get(path) { + next.set_readiness(path.clone(), patch.clone()); + } } } let intent = Arc::make_mut(&mut next.intent); @@ -908,6 +911,8 @@ impl LiveDelta { next.version = self.version; next.watcher = self.watcher; next.journal.clear(); + next.observation_journal.clear(); + next.readiness_journal.clear(); Ok(next) } } @@ -1326,6 +1331,62 @@ mod mutation_tests { assert_eq!(next.version(), live.version()); } + #[test] + fn intent_and_readiness_replay_do_not_undo_strictly_observed_bytes() { + let mut live = delta(); + let file = RelPath::new(b"file.rs".to_vec()).unwrap(); + live.apply(file.clone(), LiveEntry::new(DiskState::of_bytes(b"A"), 1)); + let cut = InstallCut { + seq: 0, + resident: live.base().clone(), + epoch: live.epoch(), + version: live.version(), + intent_version: 0, + }; + let mut walked = LiveDelta::new(live.base().clone()); + walked.apply(file.clone(), LiveEntry::new(DiskState::of_bytes(b"B"), 2)); + live.record_intent(file.clone()); + live.set_readiness( + file.clone(), + PlanePatch { + content: ContentHash::of(b"A"), + planes: EntryPlanes { + trigram: Some(super::super::readiness::PlaneState::pending("old A fill")), + ..Default::default() + }, + }, + ); + let replayed = live + .replay_onto(&walked.snapshot(), &cut) + .unwrap() + .snapshot(); + assert_eq!(replayed.disk_state(&file), DiskState::of_bytes(b"B")); + assert!(replayed.pending_intent().any(|path| path == &file)); + assert_eq!(replayed.plane_readiness(&file, FamilyPlane::Trigram), None); + } + + #[test] + fn repeated_observation_after_a_cut_still_replays_over_a_walk() { + let mut live = delta(); + let file = RelPath::new(b"file.rs".to_vec()).unwrap(); + live.apply(file.clone(), LiveEntry::new(DiskState::of_bytes(b"A"), 1)); + let cut = InstallCut { + seq: 0, + resident: live.base().clone(), + epoch: live.epoch(), + version: live.version(), + intent_version: 0, + }; + let mut walked = LiveDelta::new(live.base().clone()); + walked.apply(file.clone(), LiveEntry::new(DiskState::of_bytes(b"B"), 2)); + live.apply(file.clone(), LiveEntry::new(DiskState::of_bytes(b"A"), 3)); + let replayed = live + .replay_onto(&walked.snapshot(), &cut) + .unwrap() + .snapshot(); + assert_eq!(replayed.disk_state(&file), DiskState::of_bytes(b"A")); + } + #[test] fn draft_refuses_a_different_base_at_the_same_epoch() { let mut delta = delta(); From 446104e52feed1e092edee7c018fd3d9783886ca Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Mon, 26 Oct 2026 13:49:38 +0100 Subject: [PATCH 15/19] mason: fix Athena C3/C6/C8 held semantic fill lifetime Retain owning run Arcs for every admitted fill, including cache/store/shared hits. Register real fills atomically with view lookup before pin setup or lookup work, and perform pin I/O outside the query-state mutex. Publication trims a new binding's copy only when no fill is active and its state version still matches. Held readers retain their previous fill map, run owners, cache and pin; fresh bindings receive the trimmed copy and obsolete content is not carried forward. Serialize post-CAS state advancement and rebinding with publication ownership, then retire displaced readers outside it. Add real early-fill/publication and never-overlaid held-fill/arena-trim regressions. Update the draft cleanup test to assert current-state trimming while explicitly preserving held reader state. No expected vector values or routing defaults changed; no mutation checks are run. Co-authored-by: alfonso-aft <289616620+alfonso-aft@users.noreply.github.com> --- crates/aft/src/views/contracts.rs | 3 + crates/aft/src/views/first_load.rs | 48 ++-- crates/aft/src/views/readiness.rs | 7 + crates/aft/src/views/semantic.rs | 356 ++++++++++++++++++++++------- 4 files changed, 303 insertions(+), 111 deletions(-) diff --git a/crates/aft/src/views/contracts.rs b/crates/aft/src/views/contracts.rs index 358f1cf194..96824547e9 100644 --- a/crates/aft/src/views/contracts.rs +++ b/crates/aft/src/views/contracts.rs @@ -240,10 +240,13 @@ pub trait PlaneAdapter: Send + Sync { /// Called only after durable pointer publication for this generation succeeded. /// Opening draft readers must not drop live protection for unpublished fills. + /// `current` returns installed disk facts without I/O or building; sample it + /// after capturing a fill-state version so a concurrent completion is not lost. fn generation_published( &self, _access: &ViewAccess, _generation: &Arc, + _current: &dyn Fn() -> Snapshot, ) -> Result<(), PlaneError> { Ok(()) } diff --git a/crates/aft/src/views/first_load.rs b/crates/aft/src/views/first_load.rs index 45f9df331b..c9d2bb12c8 100644 --- a/crates/aft/src/views/first_load.rs +++ b/crates/aft/src/views/first_load.rs @@ -2053,6 +2053,7 @@ impl FirstLoadDriver for CheckoutDriver { let readers = PlaneReaders::open(access, generation.clone(), &self.adapters)?; let mut published = false; let mut progress = PublicationProgress::NotAttempted; + let mut retired_readers = Vec::new(); let mut commit = || { let _publication_guard = self .publication_transition @@ -2067,7 +2068,20 @@ impl FirstLoadDriver for CheckoutDriver { .commit_v2_before_sweep(prepared, None, || store.current_generation_read_only().map(|_| ())) .map_err(|error| SiblingLoader::error(error.to_string())) .and_then(|outcome| match outcome { - super::manifest_v2::PublishV2::Published => { progress = PublicationProgress::Published; unpublished.complete(); published = true; Ok(()) }, + super::manifest_v2::PublishV2::Published => { + progress = PublicationProgress::Published; + unpublished.complete(); + published = true; + self.owner.registry().note_publish(self.owner.scope()).map_err(|error| SiblingLoader::error(error.to_string()))?; + for adapter in &self.adapters { + adapter.generation_published(access, &generation, &|| self.installed_snapshot())?; + } + let rebound = readers.rebind(access)?; + let retired = self.prepared_readers.lock().unwrap_or_else(std::sync::PoisonError::into_inner) + .insert(generation.name().to_owned(), PreparedReaders { seq: snapshot.install_cut().map_or(0, |cut| cut.seq), readers: rebound }); + if let Some(retired) = retired { retired_readers.push(retired.readers); } + Ok(()) + }, outcome @ (super::manifest_v2::PublishV2::Conflict { .. } | super::manifest_v2::PublishV2::EquivalentWinner { .. }) => { progress = PublicationProgress::NotAttempted; self.retry.store(true, std::sync::atomic::Ordering::Release); @@ -2085,6 +2099,7 @@ impl FirstLoadDriver for CheckoutDriver { None => commit(), }; drop(commit); + drop(retired_readers); if let Err(error) = publication { if progress != PublicationProgress::NotAttempted { unpublished.complete(); @@ -2096,36 +2111,7 @@ impl FirstLoadDriver for CheckoutDriver { if !published { return Err(SiblingLoader::error("publication fence did not commit")); } - let finish = (|| { - self.owner - .registry() - .note_publish(self.owner.scope()) - .map_err(|error| SiblingLoader::error(error.to_string()))?; - for adapter in &self.adapters { - adapter.generation_published(access, &generation)?; - } - let readers = readers.rebind(access)?; - let retired = self - .prepared_readers - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .insert( - generation.name().to_owned(), - PreparedReaders { - seq: snapshot.install_cut().map_or(0, |cut| cut.seq), - readers, - }, - ); - drop(retired); - Ok(generation) - })(); - finish.map_err(|error: PlaneError| { - self.retry.store(true, std::sync::atomic::Ordering::Release); - SiblingLoader::error(format!( - "{CHECKOUT_CHANGED_BEFORE_INSTALL}: durable publication needs revalidation: {}", - error.reason - )) - }) + Ok(generation) } } diff --git a/crates/aft/src/views/readiness.rs b/crates/aft/src/views/readiness.rs index acbe97cc11..682aeeab10 100644 --- a/crates/aft/src/views/readiness.rs +++ b/crates/aft/src/views/readiness.rs @@ -119,6 +119,13 @@ impl FillMap { self.keys.values() } + /// Use on a new binding's copy: older readers can still need retired fills. + pub(super) fn retain_current(&mut self, snapshot: &super::snapshot::Snapshot) { + self.keys.retain(|(path, content, _), _| { + matches!(snapshot.disk_state(path), DiskState::Present { content: current, .. } if current == *content) + }); + } + /// Drops fills the given manifest already records as `Ready`. pub fn trim_folded(&mut self, manifest: &ComposedGeneration) { self.keys.retain(|(path, content, plane), key| { diff --git a/crates/aft/src/views/semantic.rs b/crates/aft/src/views/semantic.rs index 1f627a8370..9bcf5c5ef9 100644 --- a/crates/aft/src/views/semantic.rs +++ b/crates/aft/src/views/semantic.rs @@ -499,10 +499,13 @@ type CacheKey = (String, [u8; 32], u64, bool); #[derive(Default)] struct ViewState { fill: FillMap, + // Ready fills can precede their first overlay; the arena's own reference + // alone is evictable, so each visible fill retains its immutable run. + fill_runs: BTreeMap>, fill_version: u64, failed: BTreeMap<(RelPath, ContentHash), String>, tracker: FailureTracker, - live: Option, + live: Option>>, active_fills: usize, cache: Option<(CacheKey, Arc)>, } @@ -644,6 +647,17 @@ impl SemanticPlane { ) } + fn active_view(&self, access: &ViewAccess) -> (Arc>, ActiveFill) { + // Publication cannot fork the current state between lookup and registration. + let mut views = lock(&self.views); + let view = views + .entry((access.family().to_owned(), access.scope().to_owned())) + .or_default() + .clone(); + let active = ActiveFill::new(&view); + (view, active) + } + fn query_view(&self, access: &ViewAccess, snapshot: &Snapshot) -> Arc> { self.query_view_for(access.family(), access.scope(), snapshot) } @@ -995,21 +1009,25 @@ impl SemanticPlane { let resident = self .pinned_resident(access.family(), access.scope(), snapshot) .ok_or_else(|| plane_error("semantic generation not resident"))?; - let view = self.view(&access); + let (view, _active_fill) = self.active_view(&access); let mut report = FillReport::default(); // 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 mut items = { + let needs_pin = lock(&view).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 mut state = lock(&view); if state.live.is_none() { - state.live = Some( - crate::pins::LivePin::create(owner) - .map_err(|error| plane_error(error.to_string()))?, - ); + state.live = Some(pin.clone()); } - self.work_items(&state, &resident, snapshot, true) - }; + drop(state); + drop(pin); + } + let mut items = self.work_items(&lock(&view), &resident, snapshot, true); report.queued = items.len(); super::io::WORK .semantic_fills @@ -1100,10 +1118,10 @@ impl SemanticPlane { else { continue; }; - if arena.get(&key).is_some() { + if let Some(run) = arena.get(&key) { report.resident_hits += 1; - ready.push((item.clone(), key)); - } else if arena + ready.push((item.clone(), key, run)); + } else if let Some(run) = arena .load( &store.reader(), &key, @@ -1112,10 +1130,9 @@ impl SemanticPlane { &self.producer, ) .map_err(plane_error)? - .is_some() { report.stored_hits += 1; - ready.push((item.clone(), key)); + ready.push((item.clone(), key, run)); } else if let Some(claim) = { #[cfg(test)] run_before_claim_for_test(); @@ -1124,10 +1141,10 @@ impl SemanticPlane { // A previous owner can finish and release its claim between // our store miss and acquiring this claim. Recheck under the // claim or a completed key would be embedded a second time. - if arena.get(&key).is_some() { + if let Some(run) = arena.get(&key) { report.resident_hits += 1; - ready.push((item.clone(), key)); - } else if arena + ready.push((item.clone(), key, run)); + } else if let Some(run) = arena .load( &store.reader(), &key, @@ -1136,10 +1153,9 @@ impl SemanticPlane { &self.producer, ) .map_err(plane_error)? - .is_some() { report.stored_hits += 1; - ready.push((item.clone(), key)); + ready.push((item.clone(), key, run)); } else { claimed.push((item.clone(), relative.clone(), group, claim)); } @@ -1208,7 +1224,7 @@ impl SemanticPlane { // the linear pin-write bound. Unattempted keys are harmless protection. let keys = ready .iter() - .map(|(_, key)| *key) + .map(|(_, key, _)| *key) .chain(to_embed.iter().map(|(_, _, _, claim)| *claim.key())) .chain( chunks @@ -1218,7 +1234,6 @@ impl SemanticPlane { ) .collect::>(); let store_started = Instant::now(); - let _active_fill = ActiveFill::new(&view); protect(&view, &keys)?; report.timing.store_write += store_started.elapsed(); let mut pending = to_embed.into_iter().zip(chunks).peekable(); @@ -1357,9 +1372,9 @@ impl SemanticPlane { ); } run.strip_embed_text(); - arena.install(key, run); + let run = arena.install(key, run); report.embedded_keys += 1; - ready.push((item, key)); + ready.push((item, key, run)); } Ok(PutOrTouch::Quarantined) => report.failed += 1, // Another process stored a payload under the @@ -1371,7 +1386,7 @@ impl SemanticPlane { if let Some(hook) = lock(&self.after_conflict).take() { hook(); } - if arena + if let Some(run) = arena .load( &store.reader(), &key, @@ -1380,10 +1395,9 @@ impl SemanticPlane { &self.producer, ) .map_err(plane_error)? - .is_some() { report.stored_hits += 1; - ready.push((item, key)); + ready.push((item, key, run)); } else { // The winning row can disappear before the // reload. Keep the work pending and visible @@ -1448,8 +1462,9 @@ impl SemanticPlane { arena.wait_released(&key, budget.shared_wait); report.timing.shared_wait += wait_started.elapsed(); let lookup_started = Instant::now(); - if arena.get(&key).is_some() - || arena + let run = match arena.get(&key) { + Some(run) => Some(run), + None => arena .load( &store.reader(), &key, @@ -1457,11 +1472,11 @@ impl SemanticPlane { &payload_producer, &self.producer, ) - .map_err(plane_error)? - .is_some() - { + .map_err(plane_error)?, + }; + if let Some(run) = run { report.shared_waits += 1; - ready.push((item, key)); + ready.push((item, key, run)); } report.timing.file_lookup += lookup_started.elapsed(); } @@ -1648,7 +1663,11 @@ fn chunk_store_error(error: crate::blob_store::v2::StoreError, report: &mut Fill fn admit_ready( view: &Mutex, store: &FamilyStore, - ready: Vec<(WorkItem, FamilyKey)>, + ready: Vec<( + WorkItem, + FamilyKey, + Arc, + )>, producer: &str, current: &dyn Fn() -> Snapshot, report: &mut FillReport, @@ -1656,7 +1675,7 @@ fn admit_ready( if ready.is_empty() { return Ok(()); } - let keys = ready.iter().map(|(_, key)| *key).collect::>(); + let keys = ready.iter().map(|(_, key, _)| *key).collect::>(); protect(view, &keys)?; // GC may have won before the live-pin write. Do not admit a missing row. let swept = store @@ -1668,7 +1687,7 @@ fn admit_ready( let now = current(); let mut state = lock(view); let installed_before = report.installed; - for (item, key) in ready { + for (item, key, run) in ready { if swept.contains(&key) { continue; } @@ -1679,7 +1698,10 @@ fn admit_ready( live.as_ref(), Some(now.generation()), ) { - Admission::Installed => report.installed += 1, + Admission::Installed => { + state.fill_runs.insert(key, run); + report.installed += 1; + } Admission::DroppedContent | Admission::DroppedProducer => report.dropped += 1, } } @@ -1691,12 +1713,14 @@ fn admit_ready( } fn protect(view: &Mutex, keys: &[FamilyKey]) -> Result<(), PlaneError> { - match lock(view).live.as_mut() { - Some(pin) => pin - .protect(keys) - .map_err(|error| plane_error(error.to_string())), - None => Err(plane_error("semantic fill has no live pin")), - } + let pin = lock(view) + .live + .clone() + .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())); + result } #[cfg(test)] @@ -2057,9 +2081,23 @@ impl SemanticPlane { pub fn unbind(&self, access: &ViewAccess) { let family = access.family().to_owned(); let scope = access.scope().to_owned(); - lock(&self.views).remove(&(family.clone(), scope.clone())); - lock(&self.residents).retain(|(f, s, _), _| *f != family || *s != scope); - for arena in lock(&self.arenas).values() { + let retired_view = lock(&self.views).remove(&(family.clone(), scope.clone())); + let mut retired_residents = Vec::new(); + { + let mut residents = lock(&self.residents); + residents.retain(|(f, s, _), resident| { + if *f == family && *s == scope { + retired_residents.push(resident.clone()); + false + } else { + true + } + }); + } + drop(retired_view); + drop(retired_residents); + let arenas = lock(&self.arenas).values().cloned().collect::>(); + for arena in arenas { arena.trim(); } } @@ -2119,45 +2157,88 @@ impl PlaneAdapter for SemanticPlane { &self, access: &ViewAccess, generation: &Arc, + current: &dyn Fn() -> Snapshot, ) -> Result<(), PlaneError> { - if let ViewAccess::Owner(_) = access { - let view = self.view(access); + let ViewAccess::Owner(owner) = access else { + return Ok(()); + }; + let view = self.view(access); + let (version, mut fill, mut runs, failed) = { let mut state = lock(&view); - let before = state.fill.len(); - state.fill.trim_folded(generation); - if state.fill.len() != before { - state.fill_version += 1; - } - let keep = state - .fill - .keys() - .map(FamilyKey::to_hex) - .collect::>(); - if keep.is_empty() && state.active_fills == 0 { - // Chunk-cache keys have no manifest edge. Once no fill needs - // this pin, release its files instead of rewriting an empty list. - state.live.take(); + if state.active_fills != 0 { return Ok(()); } - if let Some(pin) = state.live.as_mut() { - let published = generation - .ready_keys() - .iter() - .map(FamilyKey::to_hex) - .collect(); - pin.trim_published(&published, |key| keep.contains(key)) - .map_err(|error| plane_error(error.to_string()))?; + if state.fill.is_empty() { + let retired = state.live.take(); + drop(state); + drop(retired); + return Ok(()); } + ( + state.fill_version, + state.fill.clone(), + state.fill_runs.clone(), + state.failed.clone(), + ) + }; + let before = fill.len(); + fill.trim_folded(generation); + fill.retain_current(¤t()); + if fill.len() == before { + return Ok(()); } + let keep = fill.keys().copied().collect::>(); + runs.retain(|key, _| keep.contains(key)); + let pin = if keep.is_empty() { + None + } else { + let mut pin = crate::pins::LivePin::create(owner) + .map_err(|error| plane_error(error.to_string()))?; + pin.protect(&keep.iter().copied().collect::>()) + .map_err(|error| plane_error(error.to_string()))?; + Some(Arc::new(Mutex::new(pin))) + }; + // Held readers keep the previous maps, run owners, cache and pin. Only + // new bindings see the trimmed copy, and pin I/O happens outside state locks. + let key = (access.family().to_owned(), access.scope().to_owned()); + let retired = { + let mut views = lock(&self.views); + if views + .get(&key) + .is_some_and(|existing| Arc::ptr_eq(existing, &view)) + { + let mut state = lock(&view); + if state.active_fills == 0 && state.fill_version == version { + let replacement = Arc::new(Mutex::new(ViewState { + fill, + fill_runs: runs, + fill_version: version + 1, + failed, + tracker: std::mem::take(&mut state.tracker), + live: pin.clone(), + active_fills: 0, + cache: None, + })); + views.insert(key, replacement) + } else { + None + } + } else { + None + } + }; + drop(retired); + drop(pin); Ok(()) } fn release_generation(&self, access: &ViewAccess, generation: &str) { - lock(&self.residents).remove(&( + let retired = lock(&self.residents).remove(&( access.family().to_owned(), access.scope().to_owned(), generation.to_owned(), )); + drop(retired); self.arena(access.family()).trim(); } @@ -2433,6 +2514,12 @@ mod tests { )) } + fn live_pin_path(view: &Mutex) -> std::path::PathBuf { + let pin = lock(view).live.clone().unwrap(); + let path = lock(&pin).keys_path().to_path_buf(); + path + } + #[test] fn opening_draft_readers_keeps_unpublished_fill_protection() { let storage = tempfile::tempdir().unwrap(); @@ -2462,9 +2549,11 @@ mod tests { .admit(&completion, &plane.producer.id(), Some(&disk), None), Admission::Installed ); - state.live = Some(crate::pins::LivePin::create(&checkout.owner).unwrap()); - state.live.as_mut().unwrap().protect(&[key]).unwrap(); + state.live = Some(Arc::new(Mutex::new( + crate::pins::LivePin::create(&checkout.owner).unwrap(), + ))); } + protect(&view, &[key]).unwrap(); let mut manifest = super::super::manifest_v2::ManifestV2::new(snapshot.generation().header().clone()); manifest @@ -2483,12 +2572,17 @@ mod tests { let draft = Arc::new(ComposedGeneration::new("unpublished-draft", manifest, None)); let _draft_reader = plane.open_reader(&checkout.access, &draft).unwrap(); assert_eq!(lock(&view).fill.len(), 1); - assert!(lock(&view).live.as_ref().unwrap().keys_path().exists()); + assert!(live_pin_path(&view).exists()); + assert_eq!(lock(&plane.view(&checkout.access)).fill.len(), 1); plane - .generation_published(&checkout.access, &draft) + .generation_published(&checkout.access, &draft, &|| snapshot.clone()) .unwrap(); - assert!(lock(&view).fill.is_empty()); - assert!(lock(&view).live.is_none()); + assert!(lock(&plane.view(&checkout.access)).fill.is_empty()); + assert_eq!( + lock(&view).fill.len(), + 1, + "held reader state was trimmed in place" + ); } #[test] @@ -2500,14 +2594,18 @@ mod tests { let snapshot = checkout.load(); let view = plane.view(&checkout.access); let active = ActiveFill::new(&view); - lock(&view).live = Some(crate::pins::LivePin::create(&checkout.owner).unwrap()); + lock(&view).live = Some(Arc::new(Mutex::new( + crate::pins::LivePin::create(&checkout.owner).unwrap(), + ))); let key = plane .producer .key(b"pending bytes", &RelPath::new(b"new.rs".to_vec()).unwrap()); protect(&view, &[key]).unwrap(); - let path = lock(&view).live.as_ref().unwrap().keys_path().to_path_buf(); + let path = live_pin_path(&view); plane - .generation_published(&checkout.access, snapshot.generation()) + .generation_published(&checkout.access, snapshot.generation(), &|| { + snapshot.clone() + }) .unwrap(); assert!( path.exists(), @@ -2515,7 +2613,9 @@ mod tests { ); drop(active); plane - .generation_published(&checkout.access, snapshot.generation()) + .generation_published(&checkout.access, snapshot.generation(), &|| { + snapshot.clone() + }) .unwrap(); assert!( !path.exists(), @@ -2523,6 +2623,102 @@ mod tests { ); } + #[test] + fn a_real_fill_registers_before_lookup_and_publication_cleanup() { + let storage = tempfile::tempdir().unwrap(); + let root = tempfile::tempdir().unwrap(); + write_tree(root.path(), FILES); + let plane = new_plane(storage.path(), "model-a"); + let checkout = ready_checkout(storage.path(), root.path(), &plane); + write_tree( + root.path(), + &[("src/alpha.rs", "fn early_fill_changed() {}\n")], + ); + checkout + .driver + .record_absolute_change(&root.path().join("src/alpha.rs")); + let snapshot = checkout.load(); + let checking = plane.clone(); + let access = checkout.access.clone(); + let during = snapshot.clone(); + before_claim_for_test(move || { + let state = checking.query_view(&access, &during); + assert!( + lock(&state).active_fills > 0, + "publication could remove the setup pin before fill registration" + ); + checking + .generation_published(&access, during.generation(), &|| during.clone()) + .unwrap(); + assert!(lock(&state).live.is_some()); + }); + assert_eq!( + checkout + .fill(&snapshot, &Model::default(), "model-a") + .installed, + 1 + ); + } + + #[test] + fn held_filled_snapshot_survives_publication_and_trim_without_prior_overlay() { + let storage = tempfile::tempdir().unwrap(); + let root = tempfile::tempdir().unwrap(); + write_tree(root.path(), FILES); + let plane = new_plane(storage.path(), "model-a"); + let checkout = ready_checkout(storage.path(), root.path(), &plane); + let edited = "pub fn alpha_replaced() -> u8 { 7 }\n"; + write_tree(root.path(), &[("src/alpha.rs", edited)]); + checkout + .driver + .record_absolute_change(&root.path().join("src/alpha.rs")); + let held = checkout.load(); + assert_eq!( + checkout.fill(&held, &Model::default(), "model-a").installed, + 1 + ); + let file = RelPath::new(b"src/alpha.rs".to_vec()).unwrap(); + let key = plane.producer.key(edited.as_bytes(), &file); + let arena = plane.arena("family"); + let payload_work = (arena.decode_count(), arena.store_reads()); + let pin_path = live_pin_path(&plane.query_view(&checkout.access, &held)); + arena.trim(); + assert!( + arena.get(&key).is_some(), + "a never-overlaid admitted fill had no strong owner" + ); + let published = checkout.load(); + assert!(lock(&plane.view(&checkout.access)).fill.is_empty()); + assert!( + pin_path.exists(), + "publication removed protection still needed by a held reader" + ); + let answer = checkout.query(&held, "model-a", "alpha"); + assert!( + answer.complete(), + "held fill was retired before its reader: {answer:?}" + ); + assert_eq!( + rows(root.path(), &answer.results), + cold(root.path(), "model-a", "alpha") + ); + assert_eq!( + (arena.decode_count(), arena.store_reads()), + payload_work, + "old reader reopened store rows" + ); + drop(published); + plane.unbind(&checkout.access); + drop(checkout); + arena.trim(); + assert!(arena.get(&key).is_some()); + assert!(pin_path.exists()); + drop(held); + arena.trim(); + assert!(arena.get(&key).is_none()); + assert!(!pin_path.exists()); + } + #[test] fn admission_propagates_semantic_payload_decode_errors() { let storage = tempfile::tempdir().unwrap(); From 160b0e2b633e4b77cd7f28c169c808ff242e7563 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Mon, 26 Oct 2026 13:52:38 +0100 Subject: [PATCH 16/19] mason: complete Athena c10 per-view probe maintenance exclusion Coordinate probe and sweep activity per normalized view key, including sweeps already in flight. Coordination mutexes are released before filesystem/SQLite work; publishers do not take the existing I/O-held sweep mutex, and ordinary no-probe commits retain their old scheduling path. Add deterministic tests that hold a real managed SQLite probe while same-view maintenance waits and another view progresses, and that a probe waits for an older admitted sweep. This is the explicitly granted generation.rs extension; no global exclusion, root-query lock I/O, or mutation controls. Co-authored-by: alfonso-aft <289616620+alfonso-aft@users.noreply.github.com> --- crates/aft/src/views/generation.rs | 205 +++++++++++++++++++++++++++- crates/aft/src/views/manifest_v2.rs | 18 ++- 2 files changed, 219 insertions(+), 4 deletions(-) diff --git a/crates/aft/src/views/generation.rs b/crates/aft/src/views/generation.rs index 3d694f18db..0520263bf2 100644 --- a/crates/aft/src/views/generation.rs +++ b/crates/aft/src/views/generation.rs @@ -99,6 +99,97 @@ fn unlink_generation_file(path: &Path) -> Option { static GENERATION_SWEEPS: OnceLock>> = OnceLock::new(); static GENERATION_SWEEP_SETTLED: std::sync::Condvar = std::sync::Condvar::new(); +#[derive(Default)] +struct ProbeSweepState { + sweeps: usize, + probe: bool, + waiting_probes: usize, +} + +#[derive(Default)] +struct ProbeSweepGate { + state: Mutex, + changed: std::sync::Condvar, +} + +struct ProbeSweepPermit { + gate: Arc, + probe: bool, +} + +impl Drop for ProbeSweepPermit { + fn drop(&mut self) { + let mut state = self + .gate + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if self.probe { + state.probe = false; + } else { + state.sweeps -= 1; + } + drop(state); + self.gate.changed.notify_all(); + } +} + +fn probe_sweep_gate(path: &Path) -> Arc { + type Key = Vec; + static GATES: OnceLock>>> = OnceLock::new(); + let key = crate::production_storage::comparison_key(path, cfg!(windows)); + let mut gates = GATES + .get_or_init(Default::default) + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + gates.retain(|_, gate| gate.strong_count() != 0); + if let Some(gate) = gates.get(&key).and_then(Weak::upgrade) { + return gate; + } + let gate = Arc::new(ProbeSweepGate::default()); + gates.insert(key, Arc::downgrade(&gate)); + gate +} + +fn enter_probe_or_sweep(path: &Path, probe: bool) -> ProbeSweepPermit { + let gate = probe_sweep_gate(path); + let mut state = gate + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if probe { + state.waiting_probes += 1; + } + while state.probe + || if probe { + state.sweeps != 0 + } else { + state.waiting_probes != 0 + } + { + state = gate + .changed + .wait(state) + .unwrap_or_else(std::sync::PoisonError::into_inner); + } + if probe { + state.waiting_probes -= 1; + state.probe = true; + } else { + state.sweeps += 1; + } + drop(state); + ProbeSweepPermit { gate, probe } +} + +/// Coordinate only this view's probe and cleanup activity. The coordination +/// mutex is released before all filesystem/SQLite work. Call after dropping +/// registry and pointer transactions so an older sweep can finish admission. +pub(super) fn with_publication_probe(view_dir: &Path, read: impl FnOnce() -> T) -> T { + let _permit = enter_probe_or_sweep(view_dir, true); + read() +} + pub(super) fn schedule_generation_sweep(view: ViewStore) { let path = view.view_dir().to_path_buf(); let jobs = GENERATION_SWEEPS.get_or_init(|| Mutex::new(HashMap::new())); @@ -546,6 +637,9 @@ impl ViewStore { &self, registry: Option<&super::registry::FamilyRegistry>, ) -> Result { + let _activity = enter_probe_or_sweep(self.view_dir(), false); + #[cfg(test)] + crate::storage_retention::test_hook(self.view_dir(), "generation-sweep-admitted"); let lock = sweep_lock(self.view_dir()); let _serialized = lock .lock() @@ -1255,8 +1349,8 @@ fn checkpoint_lock(path: &Path) -> Arc> { // Two cleanups of one view in this process would each see the other's claim // as a competing writer and yield, possibly leaving both incomplete. Run them -// one after another instead. This lock is in-process only and never held by a -// publisher, so it cannot delay publication. +// one after another instead. Publishers do not take this I/O-held mutex; their +// read-only probes use the short-held activity coordinator above. fn sweep_lock(path: &Path) -> Arc> { static SWEEP_LOCKS: OnceLock>>>> = OnceLock::new(); let mut locks = SWEEP_LOCKS @@ -1899,6 +1993,113 @@ pub(super) fn copy_all_pages(backup: &rusqlite::backup::Backup<'_, '_>) -> Resul mod tests { use super::*; + #[test] + fn a_live_probe_blocks_only_maintenance_for_its_own_view() { + use std::sync::mpsc; + let storage = tempfile::tempdir().unwrap(); + let same = ViewStore::open(storage.path(), "probe-same").unwrap(); + let other = ViewStore::open(storage.path(), "probe-other").unwrap(); + let (same_started, same_work) = mpsc::channel(); + let (other_started, other_work) = mpsc::channel(); + let _same_hook = crate::storage_retention::observe_test_hook( + same.view_dir(), + Arc::new(move |step| { + if step == "generation-sweep-admitted" { + let _ = same_started.send(()); + } + }), + ); + let _other_hook = crate::storage_retention::observe_test_hook( + other.view_dir(), + Arc::new(move |step| { + if step == "generation-sweep-admitted" { + let _ = other_started.send(()); + } + }), + ); + let (entered, opened) = mpsc::channel(); + let (resume, finish) = mpsc::channel(); + let probing = same.clone(); + let probe = std::thread::spawn(move || { + with_publication_probe(probing.view_dir(), || { + let connection = crate::db::file_identity::IdentityConnection::open_with_flags( + probing.pointer_path(), + rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY, + "held publication probe", + ) + .unwrap(); + let _: String = connection + .query_row( + "SELECT generation FROM pointer WHERE singleton=1", + [], + |row| row.get(0), + ) + .unwrap(); + entered.send(()).unwrap(); + finish.recv_timeout(Duration::from_secs(10)).unwrap(); + drop(connection); + }) + }); + opened.recv_timeout(Duration::from_secs(10)).unwrap(); + schedule_generation_sweep(same.clone()); + schedule_generation_sweep(other.clone()); + let other_progressed = other_work.recv_timeout(Duration::from_secs(5)).is_ok(); + let same_progressed = same_work.recv_timeout(Duration::from_millis(200)).is_ok(); + resume.send(()).unwrap(); + probe.join().unwrap(); + wait_for_generation_sweep_for_test(same.view_dir()); + wait_for_generation_sweep_for_test(other.view_dir()); + assert!( + other_progressed, + "a probe blocked an unrelated view's maintenance" + ); + assert!( + !same_progressed, + "same-view maintenance ran while the SQLite probe was open" + ); + } + + #[test] + fn a_probe_waits_for_an_already_running_same_view_sweep() { + use std::sync::mpsc; + let storage = tempfile::tempdir().unwrap(); + let view = ViewStore::open(storage.path(), "older-sweep").unwrap(); + let pause_once = Arc::new(std::sync::atomic::AtomicBool::new(true)); + let (entered, running) = mpsc::channel(); + let (resume, finish) = mpsc::channel(); + let finish = Arc::new(Mutex::new(finish)); + let _hook = crate::storage_retention::observe_test_hook( + view.view_dir(), + Arc::new(move |step| { + if step == "generation-sweep-admitted" + && pause_once.swap(false, std::sync::atomic::Ordering::SeqCst) + { + entered.send(()).unwrap(); + finish + .lock() + .unwrap() + .recv_timeout(Duration::from_secs(10)) + .unwrap(); + } + }), + ); + schedule_generation_sweep(view.clone()); + running.recv_timeout(Duration::from_secs(10)).unwrap(); + let (probe_entered, probe_work) = mpsc::channel(); + let probing = view.clone(); + let probe = std::thread::spawn(move || { + with_publication_probe(probing.view_dir(), || probe_entered.send(()).unwrap()) + }); + let started_early = probe_work.recv_timeout(Duration::from_millis(200)).is_ok(); + resume.send(()).unwrap(); + probe.join().unwrap(); + wait_for_generation_sweep_for_test(view.view_dir()); + assert!( + !started_early, + "publication probe overlapped an older active sweep" + ); + } + fn assert_file_set_removed(path: &Path) { for suffix in ["", "-wal", "-shm", "-journal"] { assert!( diff --git a/crates/aft/src/views/manifest_v2.rs b/crates/aft/src/views/manifest_v2.rs index 86708e66ff..09bcf46e44 100644 --- a/crates/aft/src/views/manifest_v2.rs +++ b/crates/aft/src/views/manifest_v2.rs @@ -744,7 +744,7 @@ impl ViewStore { prepared: PreparedV2, observer: Option<&dyn DurabilityObserver>, ) -> Result { - self.commit_v2_before_sweep(prepared, observer, || Ok(())) + self.commit_v2_completion(prepared, observer, || Ok(()), false) } /// Complete publication-time SQLite reads before scheduling retirement. @@ -755,6 +755,16 @@ impl ViewStore { prepared: PreparedV2, observer: Option<&dyn DurabilityObserver>, after_publish: impl FnOnce() -> Result<()>, + ) -> Result { + self.commit_v2_completion(prepared, observer, after_publish, true) + } + + fn commit_v2_completion( + &self, + prepared: PreparedV2, + observer: Option<&dyn DurabilityObserver>, + after_publish: impl FnOnce() -> Result<()>, + coordinate_probe: bool, ) -> Result { let name = prepared.generation.to_string(); // Keep both the private attempt and the resolved pointer generation @@ -770,7 +780,11 @@ impl ViewStore { super::checkpoint_pointer_after_cas(&self.pointer_path())?; sync_directory(self.view_dir())?; observe(observer, DurabilityStep::PointerSynced); - let completion = after_publish(); + let completion = if coordinate_probe { + super::generation::with_publication_probe(self.view_dir(), after_publish) + } else { + after_publish() + }; super::generation::schedule_generation_sweep(self.clone()); completion?; Ok(PublishV2::Published) From b4c3e00b76331ba9d3efa4c68bc9e6540748b3e6 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Mon, 26 Oct 2026 13:52:59 +0100 Subject: [PATCH 17/19] mason: assert Athena early-fill race fixture is reached Record execution of the real pre-claim hook so a resident/store hit cannot silently bypass the active-fill registration assertion. This is an ordinary fixture coverage assertion, not a break or mutation control. Co-authored-by: alfonso-aft <289616620+alfonso-aft@users.noreply.github.com> --- crates/aft/src/views/semantic.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/crates/aft/src/views/semantic.rs b/crates/aft/src/views/semantic.rs index 9bcf5c5ef9..09812c236c 100644 --- a/crates/aft/src/views/semantic.rs +++ b/crates/aft/src/views/semantic.rs @@ -2641,7 +2641,10 @@ mod tests { let checking = plane.clone(); let access = checkout.access.clone(); let during = snapshot.clone(); + let reached = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let observed = reached.clone(); before_claim_for_test(move || { + observed.store(true, std::sync::atomic::Ordering::SeqCst); let state = checking.query_view(&access, &during); assert!( lock(&state).active_fills > 0, @@ -2658,6 +2661,10 @@ mod tests { .installed, 1 ); + assert!( + reached.load(std::sync::atomic::Ordering::SeqCst), + "the fixture did not reach the real pre-claim interleaving" + ); } #[test] From 0b6c95a9d92d159f5287ab13ef1545edc9b689c2 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Mon, 26 Oct 2026 14:13:52 +0100 Subject: [PATCH 18/19] mason: recognize the harness prototype's completed strict walk The new watcher/membership fence correctly requires a cut-bound completed walk, but the marker-gated 4a prototype already performs full stable content and membership verification without going through SiblingLoader::reconciled. Tag that verified cut only after its walk succeeds. Keep selector conditions and all existing test expectations unchanged. The first final views run passed 532 tests and exposed five prototype-selection failures; rerun only that affected views filter and the pending Windows compile after this compatibility correction. No mutations or full-suite rerun. Co-authored-by: alfonso-aft <289616620+alfonso-aft@users.noreply.github.com> --- crates/aft/src/views/semantic_runtime.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/crates/aft/src/views/semantic_runtime.rs b/crates/aft/src/views/semantic_runtime.rs index 7e70672fd5..4083c2542d 100644 --- a/crates/aft/src/views/semantic_runtime.rs +++ b/crates/aft/src/views/semantic_runtime.rs @@ -352,6 +352,7 @@ impl CheckoutSemantic { ) })?; let cut = self.driver.installed_snapshot().install_cut()?.clone(); + let cut_epoch = cut.epoch; let snapshot = LiveDelta::new(Arc::clone(&base.generation)) .snapshot() .with_install_cut(cut); @@ -404,6 +405,9 @@ impl CheckoutSemantic { crate::slog_info!("semantic base reference strict fallback root={} reason=membership members={} base={}", self.root.display(), members.len(), manifest.entries().count()); return None; } + // The stable content-and-membership walk above completes this cut's + // reconciliation; selecting a cached base alone would not establish it. + let snapshot = snapshot.with_reconciled_epoch(cut_epoch); if let Err(error) = self.driver.install(&self.access, &snapshot, revision) { crate::slog_info!( "semantic base reference strict fallback root={} reason=install error={error}", From 50cb08b3abd5c36a271e1b034350c8525ec48b07 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Mon, 26 Oct 2026 14:33:18 +0100 Subject: [PATCH 19/19] release notes and search-quality descriptor for views-4-2-composition-install Co-authored-by: alfonso-aft <289616620+alfonso-aft@users.noreply.github.com> --- .../land-views-4-2-composition-install.json | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 benchmarks/aft-search/slice-descriptors/land-views-4-2-composition-install.json diff --git a/benchmarks/aft-search/slice-descriptors/land-views-4-2-composition-install.json b/benchmarks/aft-search/slice-descriptors/land-views-4-2-composition-install.json new file mode 100644 index 0000000000..ebd574711e --- /dev/null +++ b/benchmarks/aft-search/slice-descriptors/land-views-4-2-composition-install.json @@ -0,0 +1,8 @@ +{ + "targeted_mechanism": "none", + "kind": "harness", + "slice_class": "non_ranking", + "fixtures": [ + "harness-goldens" + ] +}