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" + ] +} 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..b8c6f3f83a 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 { @@ -227,23 +228,27 @@ 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( - (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(), }), ); + 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 @@ -252,15 +257,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 +279,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..96824547e9 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,49 @@ pub trait PlaneAdapter: Send + Sync { fn open_generation( &self, access: &ViewAccess, - generation: &Arc, + 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); + /// 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(()) + } + /// 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..c9d2bb12c8 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,34 @@ use super::RelPath; 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 /// 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 +74,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 +112,356 @@ 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. + /// 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> { + Err(binding_unavailable()) + } + fn install_replayed(&mut self) -> Result<(), PlaneError> { + Err(binding_unavailable()) + } + fn schedule_revalidated(&mut self) -> Result<(), PlaneError> { + Err(binding_unavailable()) + } + /// 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, + "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 mut progress = PublicationProgress::NotAttempted; + let result: Result = (|| { + binding.cut()?; + binding.acquire()?; + binding.build_and_flush()?; + binding.open_readers()?; + 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); + } + } + binding.referrer_published()?; + binding.install_replayed()?; + Ok(BindingOutcome::Installed) + })(); + 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: {}; {} failed: {}", + error.reason, + if recovering { + "published recovery" + } else { + "terminal cleanup" + }, + 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()); + 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")); + } + + struct Binding { + steps: Vec<&'static str>, + fail: Option<&'static str>, + outcome: PublishV2, + intent: Option, + } + 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")?; + 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") + } + 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, + progress: &mut PublicationProgress, + ) -> Result { + self.verify_held()?; + *progress = PublicationProgress::MayHaveLanded; + 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")?; + 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_phase_appropriate_failure_recovery() { + 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, + intent: None, + }; + 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, + intent: None, + }; + assert!(publish_and_install(&mut binding).is_err(), "{step}"); + let mut prefix = expected[..=index].to_vec(); + if *step != "terminal-cleanup" { + prefix.push(if index >= 5 { + "published-recovery" + } else { + "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, + intent: None, + }; + 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" + ] + ); + } + } + + #[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 /// 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 +501,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 +534,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 +548,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 +563,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 +576,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 +584,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 +595,18 @@ impl SiblingLoader { for (path, entry) in checkout.entries { delta.apply(path, entry); } - return Ok((delta.snapshot(), checkout.revision)); + 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); + } + return Ok((snapshot, checkout.revision)); } Err(Self::stage_error( LoaderStage::Reconciliation, @@ -231,7 +617,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 +645,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 +704,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 +719,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 +814,20 @@ 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, + readiness: Default::default(), + publication_base: 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 +952,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 +1172,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 +1265,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,11 +1442,22 @@ 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>, + // 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, } +struct PreparedReaders { + seq: u64, + readers: Arc, +} + /// Source work only; publication and model counters belong to their stores. #[cfg(test)] #[derive(Clone, Copy, Debug, Default)] @@ -1054,6 +1470,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 +1493,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 +1519,21 @@ 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(), + publication_transition: Default::default(), + retry: Default::default(), #[cfg(test)] work: Default::default(), } @@ -1107,28 +1555,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> { @@ -1143,9 +1605,60 @@ 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(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. + 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 { + 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!("{CHECKOUT_CHANGED_BEFORE_INSTALL}: {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() } @@ -1163,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 @@ -1249,40 +1769,175 @@ impl FirstLoadDriver for CheckoutDriver { entries: entries.clone(), stats, }; - Ok(ReconciledCheckout { revision, entries }) + Ok(ReconciledCheckout { + revision, + entries, + cut: installed.install_cut().cloned(), + readiness: installed.readiness_patches().clone(), + publication_base: Some(publication_base), + }) } 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) + .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, + 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 publication_guard = self + .publication_transition + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let mut live = self + .live + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + 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); - 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())?; + 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 + .generation() + .published() + .cloned() + .unwrap_or_default(); + let mut 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(), + }); + if snapshot.reconciled_epoch().is_some() { + if let Some(base) = snapshot.publication_base() { + installed_snapshot = installed_snapshot.with_publication_base(base.clone()); } } - let old_name = installed.snapshot.generation().name().to_owned(); - installed.snapshot = snapshot.clone(); - 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); - } + let 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(snapshot.generation()), + &installed_snapshot, + &Default::default(), + &|path| { + self.planes.iter().any(|bridge| { + bridge.plane() == plane && bridge.applies_to(path) + }) + }, + ) + }, + ) + }) + .collect(); + 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); + 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); + 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( @@ -1290,8 +1945,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); @@ -1299,10 +1957,16 @@ impl FirstLoadDriver for CheckoutDriver { .owner .view_store() .map_err(|error| SiblingLoader::error(error.to_string()))?; - let base = store - .current_generation() + 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() .map_err(|error| SiblingLoader::error(error.to_string()))?; - let mut manifest = snapshot.generation().manifest().clone(); for (path, entry) in snapshot.live_entries() { let value = match entry.disk { super::snapshot::DiskState::Absent => None, @@ -1376,47 +2040,77 @@ 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 progress = PublicationProgress::NotAttempted; + let mut retired_readers = Vec::new(); 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() .ok_or_else(|| SiblingLoader::error("publication committed twice"))?; + progress = PublicationProgress::MayHaveLanded; store - .commit_v2(prepared, None) - .map(|outcome| { - if outcome == super::manifest_v2::PublishV2::Published { + .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; + 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); + Err(SiblingLoader::error(format!("{CHECKOUT_CHANGED_BEFORE_INSTALL}: publication lost ({outcome:?}); revalidate a fresh job"))) } }) - .map_err(|error| SiblingLoader::error(error.to_string())) }; let fence = self .publish_fence .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); + drop(retired_readers); + 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()))?; - 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 - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .prepared_generation = Some(generation.name().to_owned()); Ok(generation) } } @@ -1465,11 +2159,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 { .. }) )) { @@ -1499,7 +2193,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::*; @@ -1634,36 +2328,560 @@ mod composite_tests { )); (driver, walker, plane, ViewAccess::Owner(owner)) } - #[test] - fn composite_reconcile_omits_file_deleted_after_listing() { - let root = tempfile::tempdir().unwrap(); - let storage = tempfile::tempdir().unwrap(); - let (driver, walker, _, access) = fixture(root.path(), storage.path()); - let loader = SiblingLoader::new(driver.clone(), vec![]); - let before = loader.load(&access).unwrap(); - assert_eq!(before.snapshot.membership().len(), 1); - walker.delete_after_listing.store(true, Ordering::SeqCst); - driver.record_change(RelPath::new(b"file.rs".to_vec()).unwrap()); - let after = loader.load(&access).unwrap(); - assert!(after.snapshot.membership().is_empty()); - assert!(after.pending_planes.is_empty()); - assert_ne!( - before.snapshot.generation().name(), - after.snapshot.generation().name() - ); + + struct RetirePauseAdapter { + armed: AtomicBool, + entered: std::sync::mpsc::Sender<()>, + resume: std::sync::Mutex>, } - #[test] - fn composite_driver_hashes_same_bytes_installs_own_generation_and_reports_intent() { - let root = tempfile::tempdir().unwrap(); - let storage = tempfile::tempdir().unwrap(); - let (driver, _, _, access) = fixture(root.path(), storage.path()); - let outcome = SiblingLoader::new(driver.clone(), vec![]) - .load(&access) - .unwrap(); - let path = RelPath::new(b"file.rs".to_vec()).unwrap(); - assert_eq!( - outcome.snapshot.disk_state(&path), + 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(()) + } + } + + 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(); + 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 + } + 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 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 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 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 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(); + 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(); + let storage = tempfile::tempdir().unwrap(); + let (driver, walker, _, access) = fixture(root.path(), storage.path()); + let loader = SiblingLoader::new(driver.clone(), vec![]); + let before = loader.load(&access).unwrap(); + assert_eq!(before.snapshot.membership().len(), 1); + walker.delete_after_listing.store(true, Ordering::SeqCst); + driver.record_change(RelPath::new(b"file.rs".to_vec()).unwrap()); + let after = loader.load(&access).unwrap(); + assert!(after.snapshot.membership().is_empty()); + assert!(after.pending_planes.is_empty()); + assert_ne!( + before.snapshot.generation().name(), + after.snapshot.generation().name() + ); + } + + #[test] + fn composite_driver_hashes_same_bytes_installs_own_generation_and_reports_intent() { + let root = tempfile::tempdir().unwrap(); + let storage = tempfile::tempdir().unwrap(); + let (driver, _, _, access) = fixture(root.path(), storage.path()); + let outcome = SiblingLoader::new(driver.clone(), vec![]) + .load(&access) + .unwrap(); + let path = RelPath::new(b"file.rs".to_vec()).unwrap(); + assert_eq!( + outcome.snapshot.disk_state(&path), DiskState::of_bytes(b"strict content") ); assert!(outcome.snapshot.generation().name().starts_with("g2")); @@ -1729,24 +2947,202 @@ 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!( + error.reason.contains(if equivalent { + "EquivalentWinner" + } else { + "Conflict" + }), + "{error:?}" + ); + 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. @@ -1817,6 +3213,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) @@ -1862,7 +3263,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 +3312,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; @@ -1917,6 +3322,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, @@ -1998,6 +3606,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) })); @@ -2067,6 +3676,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")] @@ -2097,6 +3707,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" + ); } } @@ -2140,10 +3792,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. @@ -2152,19 +3806,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> { @@ -2310,12 +3970,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 +4012,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/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 1db5714382..09bcf46e44 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) } } @@ -557,6 +743,28 @@ impl ViewStore { &self, prepared: PreparedV2, observer: Option<&dyn DurabilityObserver>, + ) -> Result { + self.commit_v2_completion(prepared, observer, || Ok(()), false) + } + + /// 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 { + 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 @@ -572,7 +780,13 @@ impl ViewStore { super::checkpoint_pointer_after_cas(&self.pointer_path())?; sync_directory(self.view_dir())?; observe(observer, DurabilityStep::PointerSynced); + 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) } super::PublishOutcome::Conflict { current_generation } => { @@ -697,6 +911,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(), @@ -943,6 +1272,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..96e7b7e207 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)), @@ -601,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(); @@ -649,6 +709,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..682aeeab10 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. @@ -119,13 +119,20 @@ 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: &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 +146,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 +156,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 +175,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 +190,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 +207,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 +221,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 +237,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 +251,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 +270,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 +296,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 +306,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..09812c236c 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> { @@ -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 @@ -494,13 +499,30 @@ 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)>, } +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); @@ -625,6 +647,87 @@ 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) + } + + 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(&( @@ -653,9 +756,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 +771,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 +838,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 +918,16 @@ impl SemanticPlane { return None; }; (applies_to(&path) + && (matches!(snapshot.source(&path), Source::Live(_)) + || snapshot + .readiness_patches() + .get(&path) + .is_some_and(|patch| { + patch.content == content && patch.planes.semantic.is_some() + }) + || snapshot + .generation() + .owns_work(&path, FamilyPlane::Semantic)) && (Self::summary_repair_pending( state, resident, @@ -872,23 +1007,27 @@ impl SemanticPlane { return Err(plane_error("view belongs to another storage root")); } let resident = self - .resident(&access, snapshot.generation().name()) + .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 @@ -979,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, @@ -991,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(); @@ -1003,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, @@ -1015,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)); } @@ -1087,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 @@ -1235,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 @@ -1249,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, @@ -1258,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 @@ -1326,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, @@ -1335,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(); } @@ -1526,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, @@ -1534,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 @@ -1546,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; } @@ -1555,9 +1696,12 @@ fn admit_ready( &Completion { item, key }, producer, live.as_ref(), - Some(now.generation().manifest()), + 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, } } @@ -1569,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)] @@ -1731,10 +1877,10 @@ impl SemanticPlane { snapshot: &Snapshot, ) -> Result { let resident = self - .resident(access, snapshot.generation().name()) + .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 @@ -1818,7 +1964,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, @@ -1855,8 +2001,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())) @@ -1865,6 +2012,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 @@ -1901,10 +2061,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().name()) 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, @@ -1914,15 +2074,30 @@ 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(); - 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(); } } @@ -1942,55 +2117,128 @@ 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(), - ); - 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()); - } - if let ViewAccess::Owner(_) = access { - let view = self.view(access); + let reader = Arc::new(SemanticReader { resident, state }); + Ok(Some(reader)) + } + + fn generation_published( + &self, + access: &ViewAccess, + generation: &Arc, + current: &dyn Fn() -> Snapshot, + ) -> Result<(), PlaneError> { + 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.manifest()); - if state.fill.len() != before { - state.fill_version += 1; + if state.active_fills != 0 { + return Ok(()); } - let keep = state - .fill - .keys() - .map(FamilyKey::to_hex) - .collect::>(); - if let Some(pin) = state.live.as_mut() { - pin.trim(|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(); } @@ -2012,18 +2260,10 @@ impl SemanticPlane { snapshot: &Snapshot, repair_summaries: bool, ) -> PlaneReadiness { - let key = ( - family.to_owned(), - scope.to_owned(), - snapshot.generation().name().to_owned(), - ); - 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; @@ -2274,6 +2514,218 @@ 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(); + 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(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 + .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)); + let _draft_reader = plane.open_reader(&checkout.access, &draft).unwrap(); + assert_eq!(lock(&view).fill.len(), 1); + assert!(live_pin_path(&view).exists()); + assert_eq!(lock(&plane.view(&checkout.access)).fill.len(), 1); + plane + .generation_published(&checkout.access, &draft, &|| snapshot.clone()) + .unwrap(); + assert!(lock(&plane.view(&checkout.access)).fill.is_empty()); + assert_eq!( + lock(&view).fill.len(), + 1, + "held reader state was trimmed in place" + ); + } + + #[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(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 = live_pin_path(&view); + plane + .generation_published(&checkout.access, snapshot.generation(), &|| { + snapshot.clone() + }) + .unwrap(); + assert!( + path.exists(), + "publication must not release a pin still needed by a fill" + ); + drop(active); + plane + .generation_published(&checkout.access, snapshot.generation(), &|| { + snapshot.clone() + }) + .unwrap(); + assert!( + !path.exists(), + "the idle pin may be released after publication" + ); + } + + #[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(); + 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, + "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 + ); + assert!( + reached.load(std::sync::atomic::Ordering::SeqCst), + "the fixture did not reach the real pre-claim interleaving" + ); + } + + #[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(); @@ -2311,7 +2763,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()); } @@ -2570,7 +3022,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() @@ -2629,7 +3081,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() @@ -2638,6 +3090,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(); @@ -2832,6 +3296,82 @@ 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 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] fn small_budget_fill_resumes_after_second_batch_pause_with_aligned_rows() { use crate::semantic_index::{retry_lane_pause, with_lane_pause_clock, PauseRetryScope}; @@ -3844,8 +4384,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 { .. }) )); @@ -3871,6 +4410,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(); @@ -3923,8 +4552,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/semantic_runtime.rs b/crates/aft/src/views/semantic_runtime.rs index 2aa6f29099..4083c2542d 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,11 @@ impl CheckoutSemantic { &producer, ) })?; - let snapshot = LiveDelta::new(Arc::clone(&base.generation)).snapshot(); + 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); if !base_ref_ready(&snapshot) { return None; } @@ -365,7 +368,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 = @@ -402,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}", @@ -2006,6 +2012,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/semantic_runtime_tests.rs b/crates/aft/src/views/semantic_runtime_tests.rs index 807c907b01..808ce60b98 100644 --- a/crates/aft/src/views/semantic_runtime_tests.rs +++ b/crates/aft/src/views/semantic_runtime_tests.rs @@ -12,6 +12,220 @@ 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 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}; @@ -1075,7 +1289,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 +1302,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 +1319,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 +2010,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 +2426,8 @@ fn pending_semantic(runtime: &CheckoutSemantic) -> usize { runtime .installed() .generation() - .manifest() + .flatten() + .unwrap() .entries() .filter(|(_, entry)| { entry @@ -2312,7 +2531,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 +2542,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 +2552,8 @@ fn large_semantic_materialization_batches_pins_and_touches() { runtime .installed() .generation() - .manifest() + .flatten() + .unwrap() .to_json_bytes() .unwrap(), manifest @@ -2405,14 +2627,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 +2649,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..a33014f5d0 100644 --- a/crates/aft/src/views/snapshot.rs +++ b/crates/aft/src/views/snapshot.rs @@ -8,12 +8,12 @@ //! 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 -//! `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,420 @@ 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)?; + 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 { + 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_and(|entry| { + delta_owns_plane( + self.base.as_ref().and_then(|base| base.entry(path)), + entry, + plane, + ) + }) + || 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) { + 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 + { + 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 + .readiness_overrides() + .iter() + .map(|(path, patch)| (path.clone(), patch.clone())) + .collect(); + for (path, mut patch) in overrides { + 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)?; + } + 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 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, + planes: &mut EntryPlanes, + fill: &super::readiness::FillMap, +) { + for plane in FamilyPlane::ALL { + apply_plane_fill(path, content, plane, planes.get_mut(plane), fill); } } @@ -159,19 +545,30 @@ 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, + observation_journal: BTreeMap, + readiness_journal: BTreeMap, intent: Arc>, intent_version: u64, watcher: WatcherState, @@ -186,10 +583,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 +599,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,13 +623,16 @@ 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(), + observation_journal: BTreeMap::new(), + readiness_journal: BTreeMap::new(), intent: Arc::new(BTreeMap::new()), intent_version: 0, watcher: WatcherState::Healthy, @@ -244,7 +647,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 +666,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 +677,16 @@ impl LiveDelta { self.entries.get(rel_path) } + pub fn set_readiness(&mut self, path: RelPath, patch: PlanePatch) { + self.version += 1; + self.readiness_journal.insert(path.clone(), self.version); + Arc::make_mut(&mut self.readiness).insert(path, patch); + } + + pub fn readiness(&self) -> &BTreeMap { + &self.readiness + } + pub fn journal(&self) -> &[JournalRecord] { &self.journal } @@ -284,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)| { @@ -314,6 +732,9 @@ 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; + // 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 } @@ -345,6 +766,7 @@ impl LiveDelta { epoch: self.epoch, version: self.version, entries: Arc::clone(&self.entries), + readiness: Arc::clone(&self.readiness), } } @@ -357,9 +779,14 @@ 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, + reconciled_epoch: None, + publication_base: None, + readers: None, }), } } @@ -378,12 +805,21 @@ impl LiveDelta { current: self.epoch, }); } - let replay = self - .journal + if !Arc::ptr_eq(&draft.expected_base, &self.base) { + return Err(SwitchError::BaseChanged); + } + 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( @@ -394,14 +830,91 @@ 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(); + self.observation_journal.clear(); + self.readiness_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(), + observation_journal: BTreeMap::new(), + readiness_journal: BTreeMap::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 (path, _) in self + .observation_journal + .iter() + .filter(|(_, version)| **version > cut.version) + { + let entry = self + .entries + .get(path) + .cloned() + .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); + 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(); + next.observation_journal.clear(); + next.readiness_journal.clear(); + Ok(next) + } } impl LiveEntries for LiveDelta { @@ -412,15 +925,28 @@ 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)] mod mutation_tests { use super::*; + use crate::views::contracts::{PlaneAdapter, PlaneError, ViewAccess}; 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 +962,454 @@ mod mutation_tests { ))) } + struct LeaseAdapter { + 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 + } + 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 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); + 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 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, + }); + *state = ReaderSlotState::Live { + token: replacement.token, + lease: Arc::downgrade(&replacement), + }; + drop(state); + 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 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(); + 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 +1546,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 +1622,319 @@ 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, + reconciled_epoch: Option, + publication_base: Option>, + 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 { + // 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, + slot: Arc, + token: u64, + adapter: Arc, + access: super::contracts::ViewAccess, + resource: Option, + generation: Arc, +} + +impl Drop for AdapterLease { + fn drop(&mut self) { + 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>>; + +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(); + 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 { + /// 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, + 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, + adapters: &[Arc], + ) -> (Arc, Vec) { + 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()); + readers.resources.extend(open.resources.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(), + resources: 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 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); + }; + let resource = + adapter.bind_reader(access, &readers.generation, lease.resource.clone())?; + readers.adapters.push(lease); + readers.resources.push(resource); + } + Ok(Arc::new(readers)) + } } /// Where a path's answer comes from in one snapshot. Exactly one source per @@ -651,7 +1953,39 @@ pub struct Snapshot { } impl Snapshot { - pub fn generation(&self) -> &Arc { + 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 + } + + pub(crate) fn reconciled_epoch(&self) -> Option { + self.inner.reconciled_epoch + } + + 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 +2023,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 +2063,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 +2101,537 @@ 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 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 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(); + 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)); + let snapshot = live.snapshot(); + for plane in FamilyPlane::ALL { + 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 + } + ); + } + } + } + + #[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() { + 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(); + 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..4dbbab59e0 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,9 @@ impl TrigramDriver { Ok(super::first_load::ReconciledCheckout { revision: revision(&delta), entries: walk.entries, + cut: None, + readiness: delta.readiness().clone(), + publication_base: None, }) } pub fn revision(&self, _: &super::contracts::ViewAccess) -> u64 { @@ -499,7 +503,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 +741,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 +811,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 +836,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 +853,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 +892,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 +928,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..ac6b17949d 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,9 @@ 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, + readiness: Default::default(), + publication_base: None, revision: self.revision(access), entries: BTreeMap::from([(RelPath::new(b"fixture.ts").unwrap(), live)]), }) @@ -323,7 +331,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 +472,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 +488,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(