From a63e21e9e7940d7a9b6d7a49eaaa9938e04dd73f Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Wed, 26 Aug 2026 00:30:08 +0000 Subject: [PATCH 01/23] feat(runtime): issue weak read-only database leases --- .../src/db/connection/registry.rs | 23 +++++++++++++++---- .../src/db/connection/tests.rs | 4 +++- 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/crates/tracedecay-runtime-core/src/db/connection/registry.rs b/crates/tracedecay-runtime-core/src/db/connection/registry.rs index e46172912d..54d2a3e1cb 100644 --- a/crates/tracedecay-runtime-core/src/db/connection/registry.rs +++ b/crates/tracedecay-runtime-core/src/db/connection/registry.rs @@ -575,12 +575,27 @@ impl DatabaseOwnerWeakLeaseIssuerV1 { /// is ready. The lifecycle lock covers both readiness validation and /// client-token issuance, so retirement fencing and issuance linearize. pub fn issue_lease(&self) -> Result { + self.issue_client_lease(None) + } + + /// Issues a fresh independently counted read-only client while the exact + /// owner remains ready. A writable owner is narrowed for this client; an + /// already read-only owner preserves its published access policy. + pub fn issue_read_only_lease(&self) -> Result { + self.issue_client_lease(Some(DatabaseAccessMode::ReadOnly)) + } + + fn issue_client_lease( + &self, + access: Option, + ) -> Result { let state = self .state .upgrade() .ok_or(DatabaseOwnerWeakLeaseIssuerErrorV1::Unavailable)?; - DatabaseOwnerV1::issue_client_lease_from_state(&state, state.access).map_err(|error| { - match error { + let access = access.unwrap_or(state.access); + DatabaseOwnerV1::issue_client_lease_from_state(&state, access).map_err( + |error| match error { DatabaseOwnerErrorV1::RetirementFenced => { DatabaseOwnerWeakLeaseIssuerErrorV1::Retiring } @@ -592,8 +607,8 @@ impl DatabaseOwnerWeakLeaseIssuerV1 { | DatabaseOwnerErrorV1::Runtime(_) => { DatabaseOwnerWeakLeaseIssuerErrorV1::Unavailable } - } - }) + }, + ) } /// Exact non-retaining Store identity selected when this issuer was diff --git a/crates/tracedecay-runtime-core/src/db/connection/tests.rs b/crates/tracedecay-runtime-core/src/db/connection/tests.rs index 5e4865b21f..074ef28751 100644 --- a/crates/tracedecay-runtime-core/src/db/connection/tests.rs +++ b/crates/tracedecay-runtime-core/src/db/connection/tests.rs @@ -1455,9 +1455,11 @@ async fn read_write_owner_can_issue_independent_read_only_clients_without_escala let read_write = owner.issue_lease().unwrap(); let read_only = owner.issue_read_only_lease().unwrap(); + let weak_read_only = owner.weak_lease_issuer().issue_read_only_lease().unwrap(); let read_only_clone = read_only.clone(); assert!(read_write.is_writable()); assert!(!read_only.is_writable()); + assert!(!weak_read_only.is_writable()); assert!(!read_only_clone.is_writable()); assert!(Arc::ptr_eq(&read_write.inner, &read_only.inner)); assert!(read_only.write_authority().is_err()); @@ -1494,7 +1496,7 @@ async fn read_write_owner_can_issue_independent_read_only_clients_without_escala assert!(refusal.blockers().iter().any(|blocker| matches!( blocker, crate::store_runtime::registry::StoreRuntimeRetirementBlocker::ClientLeases { - count: 2, + count: 3, .. } ))); From e745afd7bad0c0a331d912a5b9852074eced8784 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Wed, 26 Aug 2026 02:48:59 +0000 Subject: [PATCH 02/23] fix(graph): warm derived memory graph in background --- .../src/db/connection/graph_binding.rs | 3 + src/daemon/store_runtime/session_registry.rs | 126 ++++++++- .../session_registry/code_graph.rs | 88 ++++-- .../code_graph/graph_attachment.rs | 107 ++++++-- .../code_graph/memory_runtime.rs | 76 ++++-- .../code_graph/sealed_publication_tests.rs | 4 +- .../session_registry/code_reads.rs | 256 +++++++++++------- .../memory_graph_reconciliation_tasks.rs | 59 +++- .../store_runtime/session_registry/mounts.rs | 169 +++++++++--- .../session_registry/retained_hook_tasks.rs | 72 +++++ .../store_runtime/session_registry/tests.rs | 128 ++++++++- ...ified_graph_runtime_port_contract_tests.rs | 85 ++++-- .../concurrency.rs | 5 +- .../mount_scope.rs | 6 +- 14 files changed, 931 insertions(+), 253 deletions(-) diff --git a/crates/tracedecay-runtime-core/src/db/connection/graph_binding.rs b/crates/tracedecay-runtime-core/src/db/connection/graph_binding.rs index a9090ab734..ab9a094f85 100644 --- a/crates/tracedecay-runtime-core/src/db/connection/graph_binding.rs +++ b/crates/tracedecay-runtime-core/src/db/connection/graph_binding.rs @@ -97,6 +97,9 @@ impl Database { &self, ) -> std::result::Result { + if !self.is_writable() { + return Err(MemoryGraphRuntimeOperationErrorV1::Unbound); + } let bound = self .inner .memory_graph_runtime diff --git a/src/daemon/store_runtime/session_registry.rs b/src/daemon/store_runtime/session_registry.rs index 998ec99d32..a7f458b94d 100644 --- a/src/daemon/store_runtime/session_registry.rs +++ b/src/daemon/store_runtime/session_registry.rs @@ -27,7 +27,7 @@ use super::resolver::{ use crate::daemon::profile_identity::LocalProfileIdentityAuthorityV1; use crate::db::{ Database, DatabaseAccessMode, DatabaseAuthority, DatabaseOwnerV1, - MemoryGraphReconciliationTaskOwnerV1, + DatabaseOwnerWeakLeaseIssuerV1, MemoryGraphReconciliationTaskOwnerV1, }; use crate::errors::{Result, TraceDecayError}; use crate::global_db::{RegisteredGlobalDbLeaseV1, RegisteredGlobalDbOwnerV1}; @@ -297,8 +297,128 @@ impl ProjectSessionRetirementOwnerV1 { } struct MemoryStoreOwnerV1 { - graph_runtime: Arc, - reconciliation: MemoryGraphReconciliationTaskOwnerV1, + database: DatabaseOwnerWeakLeaseIssuerV1, + graph: Arc>, + graph_open_task_key: String, +} + +enum MemoryGraphAttachmentStateV1 { + Warming { + database: Option, + }, + Attached { + runtime: Arc, + reconciliation: Option, + error: Option, + }, + Detached { + database: DatabaseOwnerV1, + error: String, + }, +} + +impl MemoryStoreOwnerV1 { + fn issue_database_lease(&self) -> Result { + self.database.issue_lease().map_err(|error| { + session_registry_error( + "issue retained memory database client", + format!("{error:?}"), + ) + }) + } + + fn issue_database_read_only_lease(&self) -> Result { + self.database.issue_read_only_lease().map_err(|error| { + session_registry_error( + "issue retained memory read-only database client", + format!("{error:?}"), + ) + }) + } + + fn graph_runtime(&self) -> Option> { + let state = self + .graph + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + match &*state { + MemoryGraphAttachmentStateV1::Attached { runtime, .. } => Some(Arc::clone(runtime)), + MemoryGraphAttachmentStateV1::Warming { .. } + | MemoryGraphAttachmentStateV1::Detached { .. } => None, + } + } + + fn reconciliation_owner(&self) -> Option { + let state = self + .graph + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + match &*state { + MemoryGraphAttachmentStateV1::Attached { reconciliation, .. } => reconciliation.clone(), + MemoryGraphAttachmentStateV1::Warming { .. } + | MemoryGraphAttachmentStateV1::Detached { .. } => None, + } + } + + fn reconciliation_owner_and_attachment( + &self, + ) -> Option<( + MemoryGraphReconciliationTaskOwnerV1, + Arc>, + )> { + self.reconciliation_owner() + .map(|owner| (owner, Arc::clone(&self.graph))) + } + + fn clear_reconciliation_owner( + graph: &StdMutex, + retired: &MemoryGraphReconciliationTaskOwnerV1, + ) { + let mut state = graph + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if let MemoryGraphAttachmentStateV1::Attached { reconciliation, .. } = &mut *state + && reconciliation + .as_ref() + .is_some_and(|owner| owner.same_coordinator(retired)) + { + *reconciliation = None; + } + } + + fn reserve_database_retirement( + &self, + ) -> std::result::Result { + let state = self + .graph + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + match &*state { + MemoryGraphAttachmentStateV1::Attached { runtime, .. } => runtime + .reserve_database_retirement() + .map_err(|error| error.to_string()), + MemoryGraphAttachmentStateV1::Detached { database, .. } => database + .reserve_retirement() + .map_err(|error| format!("{error:?}")), + MemoryGraphAttachmentStateV1::Warming { database } => database + .as_ref() + .ok_or_else(|| "memory graph attachment is still warming".to_owned())? + .reserve_retirement() + .map_err(|error| format!("{error:?}")), + } + } + + fn graph_error(&self) -> Option { + let state = self + .graph + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + match &*state { + MemoryGraphAttachmentStateV1::Attached { error, .. } => error.clone(), + MemoryGraphAttachmentStateV1::Detached { error, .. } => Some(error.clone()), + MemoryGraphAttachmentStateV1::Warming { .. } => None, + } + } } struct RemoteNodeStoreOwnerV1 { diff --git a/src/daemon/store_runtime/session_registry/code_graph.rs b/src/daemon/store_runtime/session_registry/code_graph.rs index 1fa50515f1..00960b0972 100644 --- a/src/daemon/store_runtime/session_registry/code_graph.rs +++ b/src/daemon/store_runtime/session_registry/code_graph.rs @@ -113,7 +113,7 @@ impl GraphCancellation for MaintenanceGraphCancellationV1 { struct GraphPublicationProbeV1 { request_cancellation: Arc, - lifecycle_cancelled: Arc, + lifecycle_cancellation: Arc, deadline_at: Instant, cancellation: RuntimeCancellationIdentityV1, deadline: RuntimeDeadlineV1, @@ -130,9 +130,7 @@ impl RuntimeRequestProbeV1 for GraphPublicationProbeV1 { } fn interruption(&self) -> Option { - if self.request_cancellation.is_cancelled() - || self.lifecycle_cancelled.load(Ordering::Acquire) - { + if self.request_cancellation.is_cancelled() || self.lifecycle_cancellation.is_cancelled() { Some(RuntimeInterruptionV1::Cancelled) } else if Instant::now() >= self.deadline_at { Some(RuntimeInterruptionV1::DeadlineExceeded) @@ -154,6 +152,31 @@ impl RuntimeRequestProbeV1 for GraphPublicationProbeV1 { } } +struct CombinedAtomicGraphCancellationV1 { + local: Arc, + registry: Option>, +} + +impl GraphCancellation for CombinedAtomicGraphCancellationV1 { + fn is_cancelled(&self) -> bool { + self.local.load(Ordering::Acquire) + || self + .registry + .as_ref() + .is_some_and(|cancelled| cancelled.load(Ordering::Acquire)) + } +} + +fn graph_lifecycle_cancellation( + local: &Arc, + registry: Option<&Arc>, +) -> Arc { + Arc::new(CombinedAtomicGraphCancellationV1 { + local: Arc::clone(local), + registry: registry.map(Arc::clone), + }) +} + pub(crate) struct RetainedCodeGraphRuntimeV1 { graph_registry: tracedecay_graph_db::GraphDbRegistry, graph_manifest_provider: Arc, @@ -189,6 +212,7 @@ pub(crate) struct RetainedVerifiedGraphRuntimeV1 { operation_admission: Mutex, publication_gate: Mutex<()>, lifecycle_cancelled: Arc, + registry_lifecycle_cancelled: Arc, } enum MemoryGraphOperationAdmissionV1 { @@ -215,17 +239,6 @@ impl RetainedVerifiedGraphRuntimeV1 { }) } - pub(crate) fn issue_database_read_only_lease( - &self, - ) -> std::result::Result { - self.require_operation_admission()?; - self.database.issue_read_only_lease().map_err(|error| { - GraphDbError::unavailable(format!( - "memory database owner cannot issue a read-only client: {error:?}" - )) - }) - } - pub(crate) fn take_store_graph_retirement_target( &self, ) -> std::result::Result { @@ -300,6 +313,12 @@ impl RetainedVerifiedGraphRuntimeV1 { let _publication = self.publication_gate.lock().map_err(|_| { GraphDbError::unavailable("verified graph publication gate is poisoned") })?; + if request_cancelled.load(Ordering::Acquire) + || self.lifecycle_cancelled.load(Ordering::Acquire) + || self.registry_lifecycle_cancelled.load(Ordering::Acquire) + { + return Err(GraphDbError::Cancelled); + } let database = self.issue_database_lease()?; let mut storage = database .graph_publication_storage() @@ -321,7 +340,10 @@ impl RetainedVerifiedGraphRuntimeV1 { ); let probe = GraphPublicationProbeV1 { request_cancellation: Arc::clone(&request_cancellation), - lifecycle_cancelled: Arc::clone(&self.lifecycle_cancelled), + lifecycle_cancellation: graph_lifecycle_cancellation( + &self.lifecycle_cancelled, + Some(&self.registry_lifecycle_cancelled), + ), deadline_at, cancellation: cancellation_identity.clone(), deadline: deadline_identity.clone(), @@ -387,7 +409,10 @@ impl RetainedVerifiedGraphRuntimeV1 { -> std::result::Result { let publish_probe = GraphPublicationProbeV1 { request_cancellation: Arc::clone(&request_cancellation), - lifecycle_cancelled: Arc::clone(&self.lifecycle_cancelled), + lifecycle_cancellation: graph_lifecycle_cancellation( + &self.lifecycle_cancelled, + Some(&self.registry_lifecycle_cancelled), + ), deadline_at, cancellation: publish_cancellation_identity.clone(), deadline: publish_deadline_identity.clone(), @@ -518,6 +543,11 @@ impl RetainedVerifiedGraphRuntimeV1 { projection: &GraphProjectionIdentity, read_control: FactReadControl, ) -> std::result::Result, GraphDbError> { + if self.lifecycle_cancelled.load(Ordering::Acquire) + || self.registry_lifecycle_cancelled.load(Ordering::Acquire) + { + return Err(GraphDbError::Cancelled); + } let database = self.issue_database_lease()?; let mut storage = database .graph_publication_storage() @@ -543,7 +573,10 @@ impl RetainedVerifiedGraphRuntimeV1 { Arc::new(FactReadGraphCancellationV1(read_control)); let probe = GraphPublicationProbeV1 { request_cancellation: Arc::clone(&request_cancellation), - lifecycle_cancelled: Arc::clone(&self.lifecycle_cancelled), + lifecycle_cancellation: graph_lifecycle_cancellation( + &self.lifecycle_cancelled, + Some(&self.registry_lifecycle_cancelled), + ), deadline_at, cancellation: cancellation_identity.clone(), deadline: deadline_identity.clone(), @@ -721,7 +754,7 @@ impl RetainedCodeGraphRuntimeV1 { request_cancellation: Arc::new(AtomicGraphCancellationV1::new(Arc::clone( &request_cancelled, ))), - lifecycle_cancelled: Arc::clone(&self.lifecycle_cancelled), + lifecycle_cancellation: graph_lifecycle_cancellation(&self.lifecycle_cancelled, None), deadline_at, cancellation: cancellation_identity.clone(), deadline: deadline_identity.clone(), @@ -854,7 +887,10 @@ impl RetainedCodeGraphRuntimeV1 { ); let probe = GraphPublicationProbeV1 { request_cancellation: Arc::clone(&request_cancellation), - lifecycle_cancelled: Arc::clone(&self.lifecycle_cancelled), + lifecycle_cancellation: graph_lifecycle_cancellation( + &self.lifecycle_cancelled, + None, + ), deadline_at, cancellation: cancellation_identity.clone(), deadline: deadline_identity.clone(), @@ -1160,7 +1196,7 @@ impl RetainedCodeGraphRuntimeV1 { }; let probe = GraphPublicationProbeV1 { request_cancellation: Arc::clone(&cancellation), - lifecycle_cancelled: Arc::clone(&self.lifecycle_cancelled), + lifecycle_cancellation: graph_lifecycle_cancellation(&self.lifecycle_cancelled, None), deadline_at: deadline, cancellation: cancellation_identity.clone(), deadline: deadline_identity.clone(), @@ -1348,7 +1384,10 @@ impl DaemonSessionRuntimeRegistryV1 { Arc::new(MaintenanceGraphCancellationV1(cancellation.clone())); let probe = GraphPublicationProbeV1 { request_cancellation: Arc::clone(&request_cancellation), - lifecycle_cancelled: Arc::clone(&self.graph_lifecycle_cancelled), + lifecycle_cancellation: graph_lifecycle_cancellation( + &self.graph_lifecycle_cancelled, + None, + ), deadline_at, cancellation: cancellation_identity.clone(), deadline: deadline_identity.clone(), @@ -1434,7 +1473,10 @@ impl DaemonSessionRuntimeRegistryV1 { Arc::new(MaintenanceGraphCancellationV1(cancellation.clone())); let probe = GraphPublicationProbeV1 { request_cancellation: Arc::clone(&request_cancellation), - lifecycle_cancelled: Arc::clone(&self.graph_lifecycle_cancelled), + lifecycle_cancellation: graph_lifecycle_cancellation( + &self.graph_lifecycle_cancelled, + None, + ), deadline_at, cancellation: cancellation_identity.clone(), deadline: deadline_identity.clone(), diff --git a/src/daemon/store_runtime/session_registry/code_graph/graph_attachment.rs b/src/daemon/store_runtime/session_registry/code_graph/graph_attachment.rs index 1629861b54..e6e29cdbe1 100644 --- a/src/daemon/store_runtime/session_registry/code_graph/graph_attachment.rs +++ b/src/daemon/store_runtime/session_registry/code_graph/graph_attachment.rs @@ -3,7 +3,7 @@ use std::sync::atomic::AtomicBool; use std::time::Instant; use tracedecay_graph_db::{ - GraphDbOwnerAttachmentV1, GraphDbOwnerRegistrationV1, GraphDbRegistration, + GraphCancellation, GraphDbOwnerAttachmentV1, GraphDbOwnerRegistrationV1, GraphDbRegistration, }; use tracedecay_runtime_core::store_runtime::registry::{ CanonicalGraphStoreOwnerRetirementTargetV1, StoreRuntimeKey, StoreRuntimeRegistry, @@ -25,6 +25,59 @@ pub(in crate::daemon::store_runtime::session_registry) async fn open_session_rel ) -> Result<( GraphDbOwnerAttachmentV1, CanonicalGraphStoreOwnerRetirementTargetV1, +)> { + open_session_relation_owner_with_cancellation( + registry, + graph_registry, + incarnation, + shard_id, + Arc::new(AtomicGraphCancellationV1::new(Arc::clone( + lifecycle_cancelled, + ))), + Arc::new(AtomicGraphCancellationV1::new(Arc::clone( + lifecycle_cancelled, + ))), + ) + .await +} + +pub(in crate::daemon::store_runtime::session_registry) async fn open_session_relation_owner_for_task( + registry: &StoreRuntimeRegistry, + graph_registry: &tracedecay_graph_db::GraphDbRegistry, + lifecycle_cancelled: &Arc, + cancellation: tracedecay_usecases::observation::ObservationCancellation, + incarnation: StoreIncarnationV1, + shard_id: StoreShardIdV1, +) -> Result<( + GraphDbOwnerAttachmentV1, + CanonicalGraphStoreOwnerRetirementTargetV1, +)> { + open_session_relation_owner_with_cancellation( + registry, + graph_registry, + incarnation, + shard_id, + Arc::new(GraphOpenTaskCancellationV1 { + lifecycle: Arc::clone(lifecycle_cancelled), + operation: cancellation, + }), + Arc::new(AtomicGraphCancellationV1::new(Arc::clone( + lifecycle_cancelled, + ))), + ) + .await +} + +async fn open_session_relation_owner_with_cancellation( + registry: &StoreRuntimeRegistry, + graph_registry: &tracedecay_graph_db::GraphDbRegistry, + incarnation: StoreIncarnationV1, + shard_id: StoreShardIdV1, + cancellation: Arc, + lifecycle_cancellation: Arc, +) -> Result<( + GraphDbOwnerAttachmentV1, + CanonicalGraphStoreOwnerRetirementTargetV1, )> { let key = StoreRuntimeKey::new(shard_id, incarnation); let (store_attachment, store_target) = @@ -48,19 +101,25 @@ pub(in crate::daemon::store_runtime::session_registry) async fn open_session_rel format!("{failure:?}"), ) })?; - let registration = registration(lifecycle_cancelled, operation); - // Owner publication consumes the sole Store attachment. Keep that final - // transition synchronous: task cancellation cannot otherwise detach the - // blocking join while the resolver owns the attachment, stranding the - // map's Ready owner without a retryable graph authority. - let graph = graph_registry - .resolve_owner_attachment(GraphDbOwnerRegistrationV1 { - operation: registration, - authority_attachment: Box::new(store_attachment), + let registration = registration(cancellation, lifecycle_cancellation, operation); + let graph_registry = graph_registry.clone(); + // Grafeo SingleFile restore is blocking CPU and allocation work. Retain + // the join in the daemon-owned task while moving the resolver off Tokio's + // cooperative workers; cancellation remains visible inside the native + // load through the exact registration authority. + let graph = tokio::task::spawn_blocking(move || { + hotpath::measure_block!("daemon.store.memory_graph.open", { + graph_registry.resolve_owner_attachment(GraphDbOwnerRegistrationV1 { + operation: registration, + authority_attachment: Box::new(store_attachment), + }) }) - .map_err(|error| { - session_registry_error("open session relation graph owner", error.to_string()) - })?; + }) + .await + .map_err(|error| session_registry_error("join session relation graph open", error.to_string()))? + .map_err(|error| { + session_registry_error("open session relation graph owner", error.to_string()) + })?; Ok((graph, store_target)) } @@ -98,17 +157,25 @@ impl super::RetainedVerifiedGraphRuntimeV1 { } fn registration( - lifecycle_cancelled: &Arc, + cancellation: Arc, + lifecycle_cancellation: Arc, authority: Arc, ) -> GraphDbRegistration { GraphDbRegistration { authority_lease: authority, - cancellation: Arc::new(AtomicGraphCancellationV1::new(Arc::clone( - lifecycle_cancelled, - ))), - lifecycle_cancellation: Arc::new(AtomicGraphCancellationV1::new(Arc::clone( - lifecycle_cancelled, - ))), + cancellation, + lifecycle_cancellation, deadline: Instant::now() + GRAPH_OPEN_DEADLINE, } } + +struct GraphOpenTaskCancellationV1 { + lifecycle: Arc, + operation: tracedecay_usecases::observation::ObservationCancellation, +} + +impl GraphCancellation for GraphOpenTaskCancellationV1 { + fn is_cancelled(&self) -> bool { + self.lifecycle.load(std::sync::atomic::Ordering::Acquire) || self.operation.is_cancelled() + } +} diff --git a/src/daemon/store_runtime/session_registry/code_graph/memory_runtime.rs b/src/daemon/store_runtime/session_registry/code_graph/memory_runtime.rs index 8cbc65fcba..26293ce5a3 100644 --- a/src/daemon/store_runtime/session_registry/code_graph/memory_runtime.rs +++ b/src/daemon/store_runtime/session_registry/code_graph/memory_runtime.rs @@ -115,40 +115,76 @@ impl tracedecay_runtime_core::store_runtime::VerifiedGraphRuntimePortV1 } impl DaemonSessionRuntimeRegistryV1 { + #[cfg(test)] pub(crate) async fn retain_memory_graph_runtime( &self, shard_id: StoreShardIdV1, database: crate::db::DatabaseOwnerV1, ) -> Result { + Self::retain_memory_graph_runtime_for_task( + self.identity.clone(), + self.registry.clone(), + self.graph_registry.clone(), + Arc::clone(&self.graph_lifecycle_cancelled), + self.incarnation, + shard_id, + database, + tracedecay_usecases::observation::ObservationCancellation::default(), + ) + .await + .map_err(|failure| failure.error) + } + + pub(crate) async fn retain_memory_graph_runtime_for_task( + identity: super::super::LocalProfileIdentityAuthorityV1, + registry: tracedecay_runtime_core::store_runtime::registry::StoreRuntimeRegistry, + graph_registry: tracedecay_graph_db::GraphDbRegistry, + graph_lifecycle_cancelled: Arc, + incarnation: tracedecay_store::StoreIncarnationV1, + shard_id: StoreShardIdV1, + database: crate::db::DatabaseOwnerV1, + cancellation: tracedecay_usecases::observation::ObservationCancellation, + ) -> std::result::Result { if !matches!( &shard_id.scope, StoreShardScopeV1::Project { .. } | StoreShardScopeV1::ProfileMemory - ) || shard_id.brain_id != *self.identity.brain_id() - || shard_id.profile_id != *self.identity.profile_id() + ) || shard_id.brain_id != *identity.brain_id() + || shard_id.profile_id != *identity.profile_id() { - return Err(session_registry_error( - "retain verified memory graph authority", - "memory graph scope does not match the active profile authority".to_owned(), - )); + return Err(MemoryGraphRuntimeOpenFailureV1 { + database, + error: session_registry_error( + "retain verified memory graph authority", + "memory graph scope does not match the active profile authority".to_owned(), + ), + }); } if database.registered_binding().shard_id != shard_id { - return Err(session_registry_error( - "retain verified memory graph authority", - "memory graph shard does not match the retained relational runtime".to_owned(), - )); + return Err(MemoryGraphRuntimeOpenFailureV1 { + database, + error: session_registry_error( + "retain verified memory graph authority", + "memory graph shard does not match the retained relational runtime".to_owned(), + ), + }); } let relational_binding = database.registered_binding().clone(); let relational_verified_locator = database.registered_verified_locator().clone(); - let (graph, store_target) = super::graph_attachment::open_session_relation_owner( - &self.registry, - &self.graph_registry, - &self.graph_lifecycle_cancelled, - self.incarnation, + let opened = super::graph_attachment::open_session_relation_owner_for_task( + ®istry, + &graph_registry, + &graph_lifecycle_cancelled, + cancellation, + incarnation, shard_id, ) - .await?; + .await; + let (graph, store_target) = match opened { + Ok(opened) => opened, + Err(error) => return Err(MemoryGraphRuntimeOpenFailureV1 { database, error }), + }; Ok(RetainedVerifiedGraphRuntimeV1 { - graph_registry: self.graph_registry.clone(), + graph_registry, database, graph, store_target: Mutex::new(Some(store_target)), @@ -157,6 +193,12 @@ impl DaemonSessionRuntimeRegistryV1 { operation_admission: Mutex::new(super::MemoryGraphOperationAdmissionV1::Ready), publication_gate: Mutex::new(()), lifecycle_cancelled: Arc::new(AtomicBool::new(false)), + registry_lifecycle_cancelled: graph_lifecycle_cancelled, }) } } + +pub(crate) struct MemoryGraphRuntimeOpenFailureV1 { + pub(crate) database: crate::db::DatabaseOwnerV1, + pub(crate) error: crate::errors::TraceDecayError, +} diff --git a/src/daemon/store_runtime/session_registry/code_graph/sealed_publication_tests.rs b/src/daemon/store_runtime/session_registry/code_graph/sealed_publication_tests.rs index 9e18ec0ad7..f1df567f6f 100644 --- a/src/daemon/store_runtime/session_registry/code_graph/sealed_publication_tests.rs +++ b/src/daemon/store_runtime/session_registry/code_graph/sealed_publication_tests.rs @@ -72,7 +72,9 @@ fn with_publication_context( ); let probe = GraphPublicationProbeV1 { request_cancellation, - lifecycle_cancelled: Arc::new(AtomicBool::new(false)), + lifecycle_cancellation: Arc::new(AtomicGraphCancellationV1::new(Arc::new( + AtomicBool::new(false), + ))), deadline_at: Instant::now() + Duration::from_secs(30), cancellation: cancellation.clone(), deadline: deadline.clone(), diff --git a/src/daemon/store_runtime/session_registry/code_reads.rs b/src/daemon/store_runtime/session_registry/code_reads.rs index f6638bac22..3b20d7bd6b 100644 --- a/src/daemon/store_runtime/session_registry/code_reads.rs +++ b/src/daemon/store_runtime/session_registry/code_reads.rs @@ -41,19 +41,9 @@ impl DaemonSessionRuntimeRegistryV1 { Some(super::ProjectRuntimeOwnerStateV1::Ready(owners)) => { if let Some(owner) = owners.memory.as_ref() { let database = match &access { - DatabaseAccessMode::ReadWrite => { - owner.graph_runtime.issue_database_lease() - } - DatabaseAccessMode::ReadOnly => { - owner.graph_runtime.issue_database_read_only_lease() - } - } - .map_err(|error| { - session_registry_error( - "issue retained project graph database client", - error.to_string(), - ) - })?; + DatabaseAccessMode::ReadWrite => owner.issue_database_lease(), + DatabaseAccessMode::ReadOnly => owner.issue_database_read_only_lease(), + }?; if !same_canonical_path(database.canonical_database_path(), &database_path) { return Err(session_registry_error( @@ -448,27 +438,104 @@ impl DaemonSessionRuntimeRegistryV1 { { return retirement.commit_ready_or_remove(); } - let operation_admission = retirement - .memory()? - .graph_runtime - .reserve_operation_retirement() - .map_err(|error| { - session_registry_error( - "reserve project memory graph operation admission", - error.to_string(), + let graph_open_task_key = retirement.memory()?.graph_open_task_key.clone(); + self.retained_hook_tasks + .retire("memory-graph-open", &graph_open_task_key) + .await + .map_err(|error| session_registry_error("retire memory graph open task", error))?; + let Some(graph_runtime) = retirement.memory()?.graph_runtime() else { + let graph_error = retirement.memory()?.graph_error(); + let target = retirement + .memory()? + .reserve_database_retirement() + .map_err(|error| { + session_registry_error("reserve project memory database retirement", error) + })? + .into_store_retirement_target() + .map_err(|error| { + session_registry_error( + "compose project memory database retirement target", + format!("{error:?}"), + ) + })?; + let mut store_reservation = match self.registry.reserve_retirement_batch(vec![target]) { + tracedecay_runtime_core::store_runtime::registry::StoreRuntimeRetirementResult::Reserved( + reservation, + ) => reservation, + tracedecay_runtime_core::store_runtime::registry::StoreRuntimeRetirementResult::Blocked( + refusal, + ) => { + return Err(session_registry_error( + "reserve detached project memory Store retirement", + format!( + "blockers={:?}; graph_unavailable={}", + refusal.blockers(), + graph_error.unwrap_or_else(|| "warming".to_owned()) + ), + )); + } + }; + let store = match store_reservation.commit() { + Ok(commit) => commit, + Err(error) => { + return match store_reservation.cancel() { + Ok(targets) => { + drop(targets); + Err(session_registry_error( + "commit detached project memory Store retirement", + format!("{error:?}"), + )) + } + Err(cancel_error) => { + retirement.commit_fault( + super::ProjectRuntimeRetirementFaultV1::StoreStart(error.clone()), + )?; + Err(session_registry_error( + "commit detached project memory Store retirement", + format!("commit={error:?}; cancel={cancel_error:?}"), + )) + } + }; + } + }; + let store_closed = store.outcomes().iter().all(|outcome| { + matches!( + outcome, + tracedecay_runtime_core::store_runtime::registry::StoreRuntimeRetirementOutcome::Closed { .. } ) - })?; + }); + retirement.commit_without_memory()?; + return if store_closed { + Ok(()) + } else { + Err(session_registry_error( + "retire detached project memory runtime", + "project memory Store retirement reached a terminal failure".to_owned(), + )) + }; + }; + let operation_admission = + graph_runtime + .reserve_operation_retirement() + .map_err(|error| { + session_registry_error( + "reserve project memory graph operation admission", + error.to_string(), + ) + })?; let reconciliation = retirement .memory()? - .reconciliation - .reserve_retirement() - .map_err(|blocker| { - session_registry_error( - "reserve project memory reconciliation retirement", - format!("{blocker:?}"), - ) - })?; - let graph_target = retirement.memory()?.graph_runtime.graph_retirement_target(); + .reconciliation_owner() + .map(|owner| { + owner.reserve_retirement().map_err(|blocker| { + session_registry_error( + "reserve project memory reconciliation retirement", + format!("{blocker:?}"), + ) + }) + }) + .transpose()?; + let graph_target = graph_runtime.graph_retirement_target(); let mut graph_reservation = self .graph_registry .reserve_retirement_batch(vec![graph_target]) @@ -480,17 +547,10 @@ impl DaemonSessionRuntimeRegistryV1 { })?; let store_target = { let owner = retirement.memory()?; - let database = owner - .graph_runtime - .reserve_database_retirement() - .map_err(|error| { - session_registry_error( - "reserve project memory database retirement", - error.to_string(), - ) - })?; - let graph = owner - .graph_runtime + let database = owner.reserve_database_retirement().map_err(|error| { + session_registry_error("reserve project memory database retirement", error.clone()) + })?; + let graph = graph_runtime .take_store_graph_retirement_target() .map_err(|error| { session_registry_error( @@ -503,8 +563,7 @@ impl DaemonSessionRuntimeRegistryV1 { Err(refusal) => { let (error, database, graph) = refusal.into_parts(); drop(database); - owner - .graph_runtime + graph_runtime .restore_store_graph_retirement_target(graph) .map_err(|restore_error| { session_registry_error( @@ -545,9 +604,7 @@ impl DaemonSessionRuntimeRegistryV1 { "Store refusal lost the paired database/graph owner handoff".to_owned(), ) })?; - retirement - .memory()? - .graph_runtime + graph_runtime .restore_store_graph_retirement_target(target.cancel_to_ready_graph_target()) .map_err(|error| { session_registry_error( @@ -561,61 +618,64 @@ impl DaemonSessionRuntimeRegistryV1 { )); } }; - let reconciliation = match reconciliation.commit_and_wait().await { - Ok(terminal) => terminal, - Err(error) => { - let mut targets = store_reservation.cancel().map_err(|cancel_error| { - session_registry_error( - "cancel project memory Store retirement after reconciliation start refusal", - format!("{cancel_error:?}"), - ) - })?; - let target = targets.pop().ok_or_else(|| { - session_registry_error( - "recover project memory Store retirement after reconciliation start refusal", - "Store cancellation omitted the exact retirement target".to_owned(), - ) - })?; - if !targets.is_empty() { - return Err(session_registry_error( - "recover project memory Store retirement after reconciliation start refusal", - "Store cancellation returned an unexpected target count".to_owned(), - )); - } - let target = target.into_database_graph_owner_handoff().map_err(|_| { - session_registry_error( - "recover project memory Store retirement after reconciliation start refusal", - "Store cancellation lost the paired database/graph owner handoff".to_owned(), - ) - })?; - retirement - .memory()? - .graph_runtime - .restore_store_graph_retirement_target(target.cancel_to_ready_graph_target()) - .map_err(|restore_error| { + if let Some(reconciliation) = reconciliation { + let reconciliation = match reconciliation.commit_and_wait().await { + Ok(terminal) => terminal, + Err(error) => { + let mut targets = store_reservation.cancel().map_err(|cancel_error| { session_registry_error( - "restore project memory graph Store target", - restore_error.to_string(), + "cancel project memory Store retirement after reconciliation start refusal", + format!("{cancel_error:?}"), + ) + })?; + let target = targets.pop().ok_or_else(|| { + session_registry_error( + "recover project memory Store retirement after reconciliation start refusal", + "Store cancellation omitted the exact retirement target".to_owned(), ) })?; + if !targets.is_empty() { + return Err(session_registry_error( + "recover project memory Store retirement after reconciliation start refusal", + "Store cancellation returned an unexpected target count".to_owned(), + )); + } + let target = target.into_database_graph_owner_handoff().map_err(|_| { + session_registry_error( + "recover project memory Store retirement after reconciliation start refusal", + "Store cancellation lost the paired database/graph owner handoff".to_owned(), + ) + })?; + graph_runtime + .restore_store_graph_retirement_target( + target.cancel_to_ready_graph_target(), + ) + .map_err(|restore_error| { + session_registry_error( + "restore project memory graph Store target", + restore_error.to_string(), + ) + })?; + return Err(session_registry_error( + "start project memory reconciliation retirement", + format!("{error:?}"), + )); + } + }; + if !matches!( + reconciliation, + tracedecay_runtime_core::db::MemoryGraphReconciliationRetirementTerminalV1::CancelledAndJoined + ) { + operation_admission.commit(); + retirement.commit_fault(super::ProjectRuntimeRetirementFaultV1::Reconciliation( + reconciliation, + ))?; return Err(session_registry_error( - "start project memory reconciliation retirement", - format!("{error:?}"), + "retire project memory reconciliation", + "memory reconciliation reached a terminal failure after admission closed" + .to_owned(), )); } - }; - if !matches!( - reconciliation, - tracedecay_runtime_core::db::MemoryGraphReconciliationRetirementTerminalV1::CancelledAndJoined - ) { - operation_admission.commit(); - retirement.commit_fault(super::ProjectRuntimeRetirementFaultV1::Reconciliation( - reconciliation, - ))?; - return Err(session_registry_error( - "retire project memory reconciliation", - "memory reconciliation reached a terminal failure after admission closed".to_owned(), - )); } let graph = match graph_reservation.commit( Arc::new(tracedecay_graph_db::NeverCancelled), @@ -661,9 +721,7 @@ impl DaemonSessionRuntimeRegistryV1 { .to_owned(), ) })?; - retirement - .memory()? - .graph_runtime + graph_runtime .restore_store_graph_retirement_target(handoff.cancel_to_ready_graph_target()) .map_err(|restore_error| { session_registry_error( diff --git a/src/daemon/store_runtime/session_registry/memory_graph_reconciliation_tasks.rs b/src/daemon/store_runtime/session_registry/memory_graph_reconciliation_tasks.rs index a0c3efa0f9..f2bd0b4e20 100644 --- a/src/daemon/store_runtime/session_registry/memory_graph_reconciliation_tasks.rs +++ b/src/daemon/store_runtime/session_registry/memory_graph_reconciliation_tasks.rs @@ -13,8 +13,9 @@ impl DaemonSessionRuntimeRegistryV1 { .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) .as_ref() + && let Some(reconciliation) = owner.reconciliation_owner() { - owners.push(owner.reconciliation.clone()); + owners.push(reconciliation); } let projects = self .project_owners @@ -34,8 +35,10 @@ impl DaemonSessionRuntimeRegistryV1 { | super::ProjectRuntimeOwnerStateV1::Recovering | super::ProjectRuntimeOwnerStateV1::Retiring => None, }; - if let Some(owner) = memory { - owners.push(owner.reconciliation.clone()); + if let Some(owner) = memory + && let Some(reconciliation) = owner.reconciliation_owner() + { + owners.push(reconciliation); } } owners @@ -87,20 +90,48 @@ impl DaemonSessionRuntimeRegistryV1 { &self, shard_id: &StoreShardIdV1, ) -> Result<()> { - if let tracedecay_store::StoreShardScopeV1::Project { project_id } = &shard_id.scope { - return self.retire_project_memory_graph(project_id).await; - } - let owner = self - .profile_memory - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .as_ref() - .map(|owner| owner.reconciliation.clone()); - let Some(owner) = owner else { + let owner = match &shard_id.scope { + tracedecay_store::StoreShardScopeV1::Project { project_id } => { + let projects = self + .project_owners + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let memory = match projects.get(project_id) { + Some(super::ProjectRuntimeOwnerStateV1::Ready(project)) => { + project.memory.as_ref() + } + Some(super::ProjectRuntimeOwnerStateV1::RecoveryRequired(recovery)) => { + recovery.memory.as_ref() + } + Some(super::ProjectRuntimeOwnerStateV1::Faulted(faulted)) => { + faulted.retained.memory.as_ref() + } + Some( + super::ProjectRuntimeOwnerStateV1::Opening + | super::ProjectRuntimeOwnerStateV1::ReplacingSessions + | super::ProjectRuntimeOwnerStateV1::Recovering + | super::ProjectRuntimeOwnerStateV1::Retiring, + ) + | None => None, + }; + memory.and_then(super::MemoryStoreOwnerV1::reconciliation_owner_and_attachment) + } + tracedecay_store::StoreShardScopeV1::ProfileMemory => self + .profile_memory + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .as_ref() + .and_then(super::MemoryStoreOwnerV1::reconciliation_owner_and_attachment), + _ => None, + }; + let Some((owner, attachment)) = owner else { return Ok(()); }; match owner.shutdown().await { - Ok(MemoryGraphReconciliationRetirementTerminalV1::CancelledAndJoined) => Ok(()), + Ok(MemoryGraphReconciliationRetirementTerminalV1::CancelledAndJoined) => { + super::MemoryStoreOwnerV1::clear_reconciliation_owner(&attachment, &owner); + Ok(()) + } Ok(terminal) => Err(session_registry_error( "retire memory graph reconciliation task", format!("terminal state: {terminal:?}"), diff --git a/src/daemon/store_runtime/session_registry/mounts.rs b/src/daemon/store_runtime/session_registry/mounts.rs index c73d7c24fc..e748cb5575 100644 --- a/src/daemon/store_runtime/session_registry/mounts.rs +++ b/src/daemon/store_runtime/session_registry/mounts.rs @@ -21,14 +21,14 @@ use super::remote_recovery::{ use super::{ DaemonSessionRuntimeRegistryV1, Database, DatabaseAccessMode, LifecycleShardRuntimePublisher, LocalProfileIdentityAuthorityV1, LocalProfileStoreAuthorityV1, - LocalProjectEnrollmentAuthorityV1, LocalStoreRuntimeResolverV1, MemoryStoreOwnerV1, - ProfileAuthorityPinResult, ProjectRuntimeOwnerAdmissionV1, ProjectRuntimeOwnerStateV1, - RegisteredGlobalDbLeaseV1, RegisteredGlobalDbOwnerV1, RegisteredSchemaConvergenceMaintenance, - RegisteredSessionOwnerV1, RemoteNodeStoreOwnerV1, Result, RetainedHookTasks, - StoreRuntimeClientLease, StoreRuntimeOpenRequest, StoreRuntimeOpenResult, StoreRuntimeRegistry, - StoreRuntimeResolver, open_runtime, open_runtime_with_presence, - register_registered_schema_installer, registry_open_error, runtime_incarnation, - session_registry_error, + LocalProjectEnrollmentAuthorityV1, LocalStoreRuntimeResolverV1, MemoryGraphAttachmentStateV1, + MemoryStoreOwnerV1, ProfileAuthorityPinResult, ProjectRuntimeOwnerAdmissionV1, + ProjectRuntimeOwnerStateV1, RegisteredGlobalDbLeaseV1, RegisteredGlobalDbOwnerV1, + RegisteredSchemaConvergenceMaintenance, RegisteredSessionOwnerV1, RemoteNodeStoreOwnerV1, + Result, RetainedHookTasks, StoreRuntimeClientLease, StoreRuntimeOpenRequest, + StoreRuntimeOpenResult, StoreRuntimeRegistry, StoreRuntimeResolver, open_runtime, + open_runtime_with_presence, register_registered_schema_installer, registry_open_error, + runtime_incarnation, session_registry_error, }; use crate::errors::TraceDecayError; @@ -395,27 +395,105 @@ impl DaemonSessionRuntimeRegistryV1 { ) })?; crate::db::migrations::ensure_schema_current(&database).await?; - let graph_runtime = Arc::new( - self.retain_memory_graph_runtime(shard_id.clone(), owner) - .await?, - ); - let graph_port: Arc< - dyn tracedecay_runtime_core::store_runtime::VerifiedGraphRuntimePortV1, - > = graph_runtime.clone(); - database.bind_memory_graph_runtime(graph_port)?; - super::code_graph::schedule_bound_memory_graph_reconciliation(&database)?; - let reconciliation = database - .memory_graph_reconciliation_task_owner() - .ok_or_else(|| { - session_registry_error( - "publish memory runtime owner", - "memory graph reconciliation owner was not installed".to_owned(), + let database_issuer = owner.weak_lease_issuer(); + let graph = Arc::new(std::sync::Mutex::new( + MemoryGraphAttachmentStateV1::Warming { + database: Some(owner), + }, + )); + let graph_open_task_key = format!("{shard_id:?}"); + let task_graph = Arc::clone(&graph); + let task_database = database.clone(); + let identity = self.identity.clone(); + let registry = self.registry.clone(); + let graph_registry = self.graph_registry.clone(); + let graph_lifecycle_cancelled = Arc::clone(&self.graph_lifecycle_cancelled); + let incarnation = self.incarnation; + let task_shard_id = shard_id.clone(); + let retained = self.retained_hook_tasks.retain( + "memory-graph-open", + &graph_open_task_key, + move |cancellation| async move { + let owner = { + let mut state = task_graph + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + match &mut *state { + MemoryGraphAttachmentStateV1::Warming { database } => database.take(), + MemoryGraphAttachmentStateV1::Attached { .. } + | MemoryGraphAttachmentStateV1::Detached { .. } => None, + } + }; + let Some(owner) = owner else { + return; + }; + let opened = DaemonSessionRuntimeRegistryV1::retain_memory_graph_runtime_for_task( + identity, + registry, + graph_registry, + graph_lifecycle_cancelled, + incarnation, + task_shard_id, + owner, + cancellation, ) - })?; + .await; + let state = match opened { + Ok(runtime) => { + let runtime = Arc::new(runtime); + let graph_port: Arc< + dyn tracedecay_runtime_core::store_runtime::VerifiedGraphRuntimePortV1, + > = runtime.clone(); + let activation = task_database + .bind_memory_graph_runtime(graph_port) + .and_then(|()| { + super::code_graph::schedule_bound_memory_graph_reconciliation( + &task_database, + ) + }); + let reconciliation = activation + .as_ref() + .ok() + .and_then(|()| task_database.memory_graph_reconciliation_task_owner()); + let error = activation.err().map(|error| error.to_string()).or_else(|| { + reconciliation.is_none().then(|| { + "memory graph reconciliation owner was not installed".to_owned() + }) + }); + MemoryGraphAttachmentStateV1::Attached { + runtime, + reconciliation, + error, + } + } + Err(failure) => MemoryGraphAttachmentStateV1::Detached { + database: failure.database, + error: failure.error.to_string(), + }, + }; + *task_graph + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = state; + }, + ); + if !retained { + let mut state = graph + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if let MemoryGraphAttachmentStateV1::Warming { database } = &mut *state + && let Some(database) = database.take() + { + *state = MemoryGraphAttachmentStateV1::Detached { + database, + error: "memory graph open task admission is closed".to_owned(), + }; + } + } Ok(( MemoryStoreOwnerV1 { - graph_runtime, - reconciliation, + database: database_issuer, + graph, + graph_open_task_key, }, Arc::new(database), )) @@ -431,16 +509,12 @@ impl DaemonSessionRuntimeRegistryV1 { .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); mounted.as_ref().map(|owner| { - owner - .graph_runtime - .issue_database_lease() - .map(Arc::new) - .map_err(|error| { - session_registry_error( - "issue profile memory database client", - error.to_string(), - ) - }) + owner.issue_database_lease().map(Arc::new).map_err(|error| { + session_registry_error( + "issue profile memory database client", + error.to_string(), + ) + }) }) }; if let Some(database) = existing { @@ -809,8 +883,9 @@ impl DaemonSessionRuntimeRegistryV1 { .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) .take() + && let Some(runtime) = owner.graph_runtime() { - retain_identity(owner.graph_runtime.graph_store_identity()); + retain_identity(runtime.graph_store_identity()); } if let Some(owner) = self .profile_sessions @@ -830,8 +905,10 @@ impl DaemonSessionRuntimeRegistryV1 { for state in projects.values_mut() { match state { ProjectRuntimeOwnerStateV1::Ready(owners) => { - if let Some(memory) = owners.memory.take() { - retain_identity(memory.graph_runtime.graph_store_identity()); + if let Some(memory) = owners.memory.take() + && let Some(runtime) = memory.graph_runtime() + { + retain_identity(runtime.graph_store_identity()); } if let Some(sessions) = owners.sessions.take() { retain_identity(( @@ -841,8 +918,10 @@ impl DaemonSessionRuntimeRegistryV1 { } } ProjectRuntimeOwnerStateV1::RecoveryRequired(recovery) => { - if let Some(memory) = recovery.memory.take() { - retain_identity(memory.graph_runtime.graph_store_identity()); + if let Some(memory) = recovery.memory.take() + && let Some(runtime) = memory.graph_runtime() + { + retain_identity(runtime.graph_store_identity()); } if let Some(sessions) = recovery.sessions.take() { retain_identity(( @@ -858,8 +937,10 @@ impl DaemonSessionRuntimeRegistryV1 { } } ProjectRuntimeOwnerStateV1::Faulted(faulted) => { - if let Some(memory) = faulted.retained.memory.take() { - retain_identity(memory.graph_runtime.graph_store_identity()); + if let Some(memory) = faulted.retained.memory.take() + && let Some(runtime) = memory.graph_runtime() + { + retain_identity(runtime.graph_store_identity()); } if let Some(sessions) = faulted.retained.sessions.take() { retain_identity(( @@ -1099,7 +1180,6 @@ impl DaemonSessionRuntimeRegistryV1 { Some(ProjectRuntimeOwnerStateV1::Ready(owners)) => { if let Some(owner) = owners.memory.as_ref() { owner - .graph_runtime .issue_database_lease() .map(Arc::new) .map_err(|error| { @@ -1197,7 +1277,6 @@ impl DaemonSessionRuntimeRegistryV1 { Some(ProjectRuntimeOwnerStateV1::Ready(owners)) => { if let Some(owner) = owners.memory.as_ref() { owner - .graph_runtime .issue_database_read_only_lease() .map(Some) .map_err(|error| { diff --git a/src/daemon/store_runtime/session_registry/retained_hook_tasks.rs b/src/daemon/store_runtime/session_registry/retained_hook_tasks.rs index d9ea644a05..f9be96f88d 100644 --- a/src/daemon/store_runtime/session_registry/retained_hook_tasks.rs +++ b/src/daemon/store_runtime/session_registry/retained_hook_tasks.rs @@ -92,6 +92,26 @@ impl RetainedHookTasks { } } + pub(super) async fn retire(&self, provider: &str, session_id: &str) -> Result<(), String> { + let key = format!("{provider}\0{session_id}"); + let task = { + let mut state = self + .state + .lock() + .map_err(|_| "retained hook task state lock is poisoned".to_owned())?; + state.tasks.remove(&key) + }; + let Some(task) = task else { + return Ok(()); + }; + task.cancellation.cancel(); + match task.handle.await { + Ok(()) => Ok(()), + Err(error) if error.is_cancelled() => Ok(()), + Err(error) => Err(format!("retained hook task join failed: {error}")), + } + } + pub(super) async fn shutdown(&self) -> Result<(), String> { let tasks = { let mut state = self @@ -262,4 +282,56 @@ mod tests { .expect("shutdown task remains joinable") .expect("retained hook tasks shut down cleanly"); } + + #[tokio::test] + async fn retiring_one_task_cancels_and_joins_only_that_key() { + let tasks = Arc::new(RetainedHookTasks::new()); + let first_started = Arc::new(Notify::new()); + let first_cancelled = Arc::new(Notify::new()); + let first_release = Arc::new(Notify::new()); + let second_cancelled = Arc::new(AtomicBool::new(false)); + assert!(tasks.retain("memory-graph", "project-1", { + let started = Arc::clone(&first_started); + let cancelled = Arc::clone(&first_cancelled); + let release = Arc::clone(&first_release); + move |cancellation| async move { + started.notify_one(); + while !cancellation.is_cancelled() { + tokio::task::yield_now().await; + } + cancelled.notify_one(); + release.notified().await; + } + })); + assert!(tasks.retain("memory-graph", "project-2", { + let second_cancelled = Arc::clone(&second_cancelled); + move |cancellation| async move { + while !cancellation.is_cancelled() { + tokio::task::yield_now().await; + } + second_cancelled.store(true, Ordering::Release); + } + })); + first_started.notified().await; + + let retire = tokio::spawn({ + let tasks = Arc::clone(&tasks); + async move { tasks.retire("memory-graph", "project-1").await } + }); + first_cancelled.notified().await; + assert!(!retire.is_finished(), "retirement must join its task"); + assert!(!second_cancelled.load(Ordering::Acquire)); + + first_release.notify_one(); + retire + .await + .expect("retirement task remains joinable") + .expect("one retained task retires cleanly"); + assert!(tasks.retain("memory-graph", "project-3", |_| async {})); + assert!(!second_cancelled.load(Ordering::Acquire)); + + tasks.begin_shutdown(); + tasks.shutdown().await.expect("remaining tasks shut down"); + assert!(second_cancelled.load(Ordering::Acquire)); + } } diff --git a/src/daemon/store_runtime/session_registry/tests.rs b/src/daemon/store_runtime/session_registry/tests.rs index 07e36cebf6..39b7925929 100644 --- a/src/daemon/store_runtime/session_registry/tests.rs +++ b/src/daemon/store_runtime/session_registry/tests.rs @@ -953,9 +953,21 @@ async fn project_graph_runtime_publishes_recovers_and_fails_closed() { .expect("manifest"); let idempotency = GraphIdempotencyKey::new("idempotency.generic-test.1").expect("idempotency"); - let published = project_database - .issue_memory_graph_runtime_operation() - .expect("project graph publication operation") + let publication_operation = tokio::time::timeout(std::time::Duration::from_secs(2), async { + loop { + match project_database.issue_memory_graph_runtime_operation() { + Ok(operation) => break operation, + Err(crate::db::MemoryGraphRuntimeOperationErrorV1::Unbound) + | Err(crate::db::MemoryGraphRuntimeOperationErrorV1::Unavailable) => { + tokio::task::yield_now().await; + } + Err(error) => panic!("project graph attachment failed: {error:?}"), + } + } + }) + .await + .expect("project graph attachment becomes available"); + let published = publication_operation .runtime() .publish_verified_manifest( &manifest, @@ -1119,6 +1131,116 @@ async fn linked_worktree_generations_share_the_project_graph_runtime() { ); } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn corrupt_derived_graph_preserves_relational_owner_lifecycle() { + let temporary = tempfile::tempdir().expect("temporary project parent"); + let root = temporary + .path() + .canonicalize() + .expect("canonical fixture root"); + let profile_root = root.join("profile"); + let project_root = root.join("project"); + std::fs::create_dir_all(&project_root).expect("project root"); + gix::init(&project_root).expect("initialize project repository"); + let identity = crate::daemon::profile_identity::load_or_create(&profile_root) + .expect("durable profile identity"); + let project_id = ProjectId::new("project.derived-graph-corrupt").expect("project id"); + crate::storage::pin_fixture_repository_identity(&project_root, project_id.as_str()) + .expect("project enrollment"); + let _database_scope = crate::db::enter_daemon_database_scope( + &profile_root, + 23, + "corrupt derived graph project open", + ) + .expect("daemon database scope"); + let project_store_root = profile_root.join("projects/project.derived-graph-corrupt"); + let database_path = project_store_root.join(crate::config::db_filename(&project_store_root)); + std::fs::create_dir_all(database_path.parent().expect("database parent")) + .expect("database directory"); + + let first_registry = DaemonSessionRuntimeRegistryV1::open(identity.clone()) + .await + .expect("first session runtime registry"); + first_registry + .project_sessions(project_id.clone(), [project_root.clone()]) + .await + .expect("register project authority"); + let authority = DatabaseAuthority::for_runtime( + &database_path, + "seed project store before derived graph corruption", + ) + .expect("project database authority"); + let first_database = first_registry + .project_graph( + &project_root, + project_id.clone(), + database_path.clone(), + authority, + DatabaseAccessMode::ReadWrite, + ) + .await + .expect("initial project graph publication"); + let graph_path = first_database.database_path().with_extension("grafeo"); + drop(first_database); + first_registry.cancel_terminal_tasks(); + first_registry + .shutdown_terminal_tasks() + .await + .expect("first terminal tasks shut down"); + first_registry.cancel_memory_graph_reconciliation_tasks(); + first_registry + .shutdown_memory_graph_reconciliation_tasks() + .await + .expect("first reconciliation shuts down"); + first_registry + .close_retained_graph_runtimes_for_shutdown() + .await + .expect("first graph runtime closes"); + drop(first_registry); + + std::fs::write(&graph_path, b"corrupt derived graph").expect("corrupt derived graph file"); + + let reopened_registry = DaemonSessionRuntimeRegistryV1::open(identity) + .await + .expect("reopened session runtime registry"); + reopened_registry + .project_sessions(project_id.clone(), [project_root.clone()]) + .await + .expect("restore project authority"); + let reopened_authority = DatabaseAuthority::for_runtime( + &database_path, + "reopen project store with corrupt derived graph", + ) + .expect("reopened project database authority"); + let reopened = tokio::time::timeout( + std::time::Duration::from_secs(2), + reopened_registry.project_graph( + &project_root, + project_id.clone(), + database_path.clone(), + reopened_authority, + DatabaseAccessMode::ReadWrite, + ), + ) + .await + .expect("relational project open must not wait for derived graph recovery") + .expect("relational project open remains available"); + assert_eq!(reopened.database_path(), database_path); + assert!(matches!( + reopened.issue_memory_graph_runtime_operation(), + Err(crate::db::MemoryGraphRuntimeOperationErrorV1::Unbound) + | Err(crate::db::MemoryGraphRuntimeOperationErrorV1::Unavailable) + )); + drop(reopened); + tokio::time::timeout( + std::time::Duration::from_secs(2), + reopened_registry.retire_project_memory_graph(&project_id), + ) + .await + .expect("relational owner retirement must not wait for derived graph recovery") + .expect("detached relational owner retires cleanly"); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn read_only_project_graph_reuses_daemon_publication_without_write_authority() { let temporary = tempfile::tempdir().expect("temporary project parent"); diff --git a/src/daemon/store_runtime/session_registry/verified_graph_runtime_port_contract_tests.rs b/src/daemon/store_runtime/session_registry/verified_graph_runtime_port_contract_tests.rs index cabfd63cfc..d188076fa1 100644 --- a/src/daemon/store_runtime/session_registry/verified_graph_runtime_port_contract_tests.rs +++ b/src/daemon/store_runtime/session_registry/verified_graph_runtime_port_contract_tests.rs @@ -75,6 +75,7 @@ impl ContractFixture { .project_memory(project_id.clone(), roots.clone()) .await .expect("project graph database"); + drop(await_mounted_graph_operation(&project_database).await); let sessions = self .registry .project_sessions(project_id.clone(), roots) @@ -167,6 +168,25 @@ fn mounted_graph_operation( .expect("mounted database issues one graph operation") } +async fn await_mounted_graph_operation( + database: &crate::db::Database, +) -> crate::db::MemoryGraphRuntimeOperationV1 { + tokio::time::timeout(std::time::Duration::from_secs(2), async { + loop { + match database.issue_memory_graph_runtime_operation() { + Ok(operation) => break operation, + Err(crate::db::MemoryGraphRuntimeOperationErrorV1::Unbound) + | Err(crate::db::MemoryGraphRuntimeOperationErrorV1::Unavailable) => { + tokio::task::yield_now().await; + } + Err(error) => panic!("memory graph attachment failed: {error:?}"), + } + } + }) + .await + .expect("memory graph attachment becomes available") +} + fn publish_through_database( database: &crate::db::Database, manifest: &GraphGenerationManifest, @@ -195,7 +215,7 @@ fn snapshot_through_database( } #[tokio::test] -async fn runtime_binding_is_absent_before_bind_and_rejects_double_bind() { +async fn runtime_binding_is_absent_before_bind_and_rebinds_idempotently() { let fixture = ContractFixture::new("binding").await; let project_id = project_id("binding"); let (project_database, sessions) = fixture.mount_unbound(&project_id).await; @@ -208,9 +228,10 @@ async fn runtime_binding_is_absent_before_bind_and_rejects_double_bind() { sessions.bind_project_graph_runtime(runtime.clone()).is_ok(), "first graph proxy binding" ); - let rejected = sessions - .bind_project_graph_runtime(runtime.clone()) - .expect_err("second graph proxy binding must be rejected"); + assert!( + sessions.bind_project_graph_runtime(runtime.clone()).is_ok(), + "binding the exact same graph proxy is idempotent" + ); let first = mounted_graph_operation(&project_database); let second = mounted_graph_operation(&project_database); @@ -232,11 +253,6 @@ async fn runtime_binding_is_absent_before_bind_and_rejects_double_bind() { bound.relational_verified_locator(), runtime.relational_verified_locator() ); - assert_eq!(rejected.relational_binding(), runtime.relational_binding()); - assert_eq!( - rejected.relational_verified_locator(), - runtime.relational_verified_locator() - ); } #[tokio::test] @@ -271,7 +287,7 @@ async fn memory_graph_operations_remain_isolated_by_exact_relational_identity() .profile_memory() .await .expect("profile memory database"); - let profile = mounted_graph_operation(&profile_database); + let profile = await_mounted_graph_operation(&profile_database).await; assert_ne!( first.runtime().relational_binding(), profile.runtime().relational_binding() @@ -482,6 +498,10 @@ async fn exact_shard_retirement_leaves_sibling_live_and_remounts_fresh() { let first_manifest = manifest(&first_projection, "retire-first", "1"); let second_manifest = manifest(&second_projection, "retire-second", "1"); let first_shard = first_database.registered_binding().shard_id.clone(); + let first_proxy = first_database + .memory_graph_runtime() + .expect("first graph runtime proxy"); + drop(first_database); fixture .registry @@ -489,22 +509,28 @@ async fn exact_shard_retirement_leaves_sibling_live_and_remounts_fresh() { .await .expect("retire exact first-shard reconciliation owner"); assert!(matches!( - reconcile_through_database(&first_database, &first_manifest, key("retire-first")), + reconcile_through_trait(&first_proxy, &first_manifest, key("retire-first")), Err(GraphDbError::Cancelled) )); reconcile_through_database(&second_database, &second_manifest, key("retire-second")) .expect("sibling reconciliation remains live"); + fixture + .registry + .retire_project_memory_graph(&first_id) + .await + .expect("retire the exact first project memory owner"); fixture .registry .drop_project_runtime_caches(&first_id) .await; - drop((first_database, first_sessions)); + drop(first_sessions); let remounted = fixture .registry .project_memory(first_id.clone(), fixture.project_roots(&first_id)) .await .expect("same project remounts with a fresh lifecycle"); + drop(await_mounted_graph_operation(&remounted).await); reconcile_through_database(&remounted, &first_manifest, key("retire-first-remounted")) .expect("remounted reconciliation lifecycle is fresh"); } @@ -538,6 +564,11 @@ async fn exact_shard_retirement_closes_retained_graph_after_root_is_absent() { #[tokio::test] async fn session_relation_close_refusal_restores_route_and_retry_closes_exact_graph() { let fixture = ContractFixture::new("session-relation-close-retry").await; + let session_sync = Arc::new(crate::daemon::session_sync::DaemonSessionSyncService::default()); + fixture + .registry + .install_session_sync_service(&session_sync) + .expect("install session sync lifecycle authority"); let project_id = project_id("session-relation-close-retry"); let (_project_database, external_old_sessions) = fixture.mount_unbound(&project_id).await; let old_binding = external_old_sessions.binding().clone(); @@ -549,7 +580,7 @@ async fn session_relation_close_refusal_restores_route_and_retry_closes_exact_gr .expect_err("external old session facade must refuse graph close"); match refusal { TraceDecayError::Database { operation, message } => { - assert_eq!(operation, "close graph runtime"); + assert_eq!(operation, "reserve project session graph retirement"); assert!( message.contains("graph database conflict"), "unexpected close refusal: {message}" @@ -636,6 +667,7 @@ async fn project_and_profile_memory_verified_heads_survive_registry_restart() { .project_memory(project_id.clone(), [project_root.clone()]) .await .expect("project memory database"); + drop(await_mounted_graph_operation(&project_database).await); let project_projection = projection("verified-restart-project"); let project_manifest = manifest(&project_projection, "verified-restart-project", "1"); let project_snapshot = publish_through_database( @@ -652,6 +684,7 @@ async fn project_and_profile_memory_verified_heads_survive_registry_restart() { .profile_memory() .await .expect("profile memory database"); + drop(await_mounted_graph_operation(&profile_database).await); let profile_projection = projection("verified-restart-profile"); let profile_manifest = manifest(&profile_projection, "verified-restart-profile", "1"); let profile_snapshot = publish_through_database( @@ -675,6 +708,7 @@ async fn project_and_profile_memory_verified_heads_survive_registry_restart() { .project_memory(project_id.clone(), [project_root]) .await .expect("restarted project memory database"); + drop(await_mounted_graph_operation(&restarted_project_database).await); let recovered_project = snapshot_through_database(&restarted_project_database, &project_projection) .expect("recover project verified head") @@ -685,6 +719,7 @@ async fn project_and_profile_memory_verified_heads_survive_registry_restart() { .profile_memory() .await .expect("restarted profile memory database"); + drop(await_mounted_graph_operation(&restarted_profile_database).await); let recovered_profile = snapshot_through_database(&restarted_profile_database, &profile_projection) .expect("recover profile verified head") @@ -903,18 +938,24 @@ async fn registry_drop_cancels_retained_trait_runtime_operations() { let projection = projection("lifecycle-cancellation"); let manifest = manifest(&projection, "lifecycle-cancellation", "1"); let operation = mounted_graph_operation(&database); + let lifecycle_cancelled = Arc::clone(&fixture.registry.graph_lifecycle_cancelled); drop(fixture); + assert!( + lifecycle_cancelled.load(std::sync::atomic::Ordering::Acquire), + "registry drop must cancel the shared graph lifecycle" + ); - assert!(matches!( - publish_through_trait( - operation.runtime(), - &manifest, - key("lifecycle-cancellation"), - false, - ), - Err(GraphDbError::Cancelled) - )); + let publication = publish_through_trait( + operation.runtime(), + &manifest, + key("lifecycle-cancellation"), + false, + ); + assert!( + matches!(publication, Err(GraphDbError::Cancelled)), + "unexpected post-drop publication outcome: {publication:?}" + ); assert!(matches!( reconcile_through_trait( operation.runtime(), diff --git a/src/daemon/store_runtime/session_registry/verified_graph_runtime_port_contract_tests/concurrency.rs b/src/daemon/store_runtime/session_registry/verified_graph_runtime_port_contract_tests/concurrency.rs index c592252785..e193b9e6a2 100644 --- a/src/daemon/store_runtime/session_registry/verified_graph_runtime_port_contract_tests/concurrency.rs +++ b/src/daemon/store_runtime/session_registry/verified_graph_runtime_port_contract_tests/concurrency.rs @@ -3,8 +3,8 @@ use std::sync::{Arc, Barrier}; use tracedecay_graph_db::{GraphDbError, GraphGenerationManifest, VerifiedGraphSnapshot}; use super::{ - ContractFixture, key, manifest, project_id, projection, reconcile_through_trait, - snapshot_through_trait, + ContractFixture, await_mounted_graph_operation, key, manifest, project_id, projection, + reconcile_through_trait, snapshot_through_trait, }; fn reconcile_pair( @@ -94,6 +94,7 @@ async fn project_and_profile_ports_serialize_exact_replay_and_changed_input_conf .profile_memory() .await .expect("profile memory database"); + drop(await_mounted_graph_operation(&profile_database).await); assert_concurrent_replay_and_conflict(project_database, "project"); assert_concurrent_replay_and_conflict(profile_database, "profile"); } diff --git a/src/daemon/store_runtime/session_registry/verified_graph_runtime_port_contract_tests/mount_scope.rs b/src/daemon/store_runtime/session_registry/verified_graph_runtime_port_contract_tests/mount_scope.rs index 5d33eab676..436a5af745 100644 --- a/src/daemon/store_runtime/session_registry/verified_graph_runtime_port_contract_tests/mount_scope.rs +++ b/src/daemon/store_runtime/session_registry/verified_graph_runtime_port_contract_tests/mount_scope.rs @@ -12,7 +12,7 @@ use tracedecay_usecases::memory::{ MemoryApplication, ProjectMemoryFactAddRequest, ProjectMemoryFactAddRequestOutcome, }; -use super::{ContractFixture, project_id}; +use super::{ContractFixture, await_mounted_graph_operation, project_id}; use crate::daemon::profile_identity; use crate::errors::TraceDecayError; use crate::store::DatabaseFactStore; @@ -57,9 +57,7 @@ async fn writable_project_and_profile_mounts_bind_exact_relational_authority() { .profile_memory() .await .expect("profile memory database"); - let profile_operation = profile_database - .issue_memory_graph_runtime_operation() - .expect("profile memory graph operation"); + let profile_operation = await_mounted_graph_operation(&profile_database).await; assert_eq!( profile_operation.runtime().relational_binding(), profile_database.registered_binding() From 820163c4e2e2cba148c9d0ff1b605baf489b1f64 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Wed, 26 Aug 2026 03:49:28 +0000 Subject: [PATCH 03/23] fix(runtime): restore session graphs in background --- src/daemon/store_runtime/session_registry.rs | 302 ++++++++++++++---- .../session_registry/code_graph.rs | 20 +- .../code_graph/graph_attachment.rs | 2 +- .../session_registry/code_reads.rs | 3 +- .../store_runtime/session_registry/mounts.rs | 141 ++++---- .../remote_recovery/publication.rs | 39 +-- .../store_runtime/session_registry/tests.rs | 64 ++++ 7 files changed, 403 insertions(+), 168 deletions(-) diff --git a/src/daemon/store_runtime/session_registry.rs b/src/daemon/store_runtime/session_registry.rs index a7f458b94d..af516d7214 100644 --- a/src/daemon/store_runtime/session_registry.rs +++ b/src/daemon/store_runtime/session_registry.rs @@ -64,9 +64,140 @@ struct SessionGraphOwnerV1 { store_target: CanonicalGraphStoreOwnerRetirementTargetV1, } +enum SessionGraphAttachmentStateV1 { + Warming, + Attached { owner: Option }, + Detached { error: String }, +} + struct RegisteredSessionOwnerV1 { database: RegisteredGlobalDbOwnerV1, - relation_graph: SessionGraphOwnerV1, + relation_graph: Arc>, + graph_settled: Arc, + graph_open_task_key: String, +} + +impl RegisteredSessionOwnerV1 { + fn with_attached_graph( + database: RegisteredGlobalDbOwnerV1, + relation_graph: SessionGraphOwnerV1, + graph_open_task_key: String, + ) -> Self { + Self { + database, + relation_graph: Arc::new(StdMutex::new(SessionGraphAttachmentStateV1::Attached { + owner: Some(relation_graph), + })), + graph_settled: Arc::new(tokio::sync::Notify::new()), + graph_open_task_key, + } + } + + fn issue_lease(&self, scope: SessionRelationScope) -> Result { + let database = self.database.issue_lease().map_err(|error| { + session_registry_error( + "issue registered session database client", + format!("{error:?}"), + ) + })?; + let state = self + .relation_graph + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let SessionGraphAttachmentStateV1::Attached { owner: Some(owner) } = &*state else { + return Ok(database); + }; + let graph = owner.graph.issue_lease().map_err(|error| { + session_registry_error( + "issue registered session relation graph client", + error.to_string(), + ) + })?; + database + .bind_session_relation_graph( + scope, + graph, + owner.graph.binding().clone(), + owner.graph.verified_locator().clone(), + ) + .map_err(|_| { + session_registry_error( + "bind issued registered session relation graph", + "issued graph client did not match the exact registered session owner" + .to_owned(), + ) + })?; + Ok(database) + } + + fn graph_unavailable_reason(&self) -> String { + let state = self + .relation_graph + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + match &*state { + SessionGraphAttachmentStateV1::Warming => { + "Project session relation graph is warming".to_owned() + } + SessionGraphAttachmentStateV1::Detached { error } => { + format!("Project session relation graph is unavailable: {error}") + } + SessionGraphAttachmentStateV1::Attached { owner: None } => { + "Project session relation graph is reserved for retirement".to_owned() + } + SessionGraphAttachmentStateV1::Attached { owner: Some(_) } => { + "Project session relation graph attachment changed during retirement admission" + .to_owned() + } + } + } + + fn into_retirement(self) -> std::result::Result { + let relation_graph = { + let mut state = self + .relation_graph + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + match &mut *state { + SessionGraphAttachmentStateV1::Attached { owner } => owner.take(), + SessionGraphAttachmentStateV1::Warming + | SessionGraphAttachmentStateV1::Detached { .. } => None, + } + }; + let Some(relation_graph) = relation_graph else { + return Err(self); + }; + let SessionGraphOwnerV1 { + graph, + store_target, + } = relation_graph; + Ok(ProjectSessionRetirementOwnerV1 { + database: self.database, + graph, + store_target: Some(store_target), + graph_open_task_key: self.graph_open_task_key, + }) + } + + fn take_graph_store_identity( + &self, + ) -> Option<( + tracedecay_store::StoreRuntimeBindingV1, + tracedecay_store::VerifiedStoreLocatorV1, + )> { + let mut state = self + .relation_graph + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let SessionGraphAttachmentStateV1::Attached { owner } = &mut *state else { + return None; + }; + let owner = owner.take()?; + Some(( + owner.graph.binding().clone(), + owner.graph.verified_locator().clone(), + )) + } } /// A canonical project owner map may be passed to recovery orchestration, but @@ -104,6 +235,44 @@ impl ProjectRuntimeOwnerRegistryV1 { .collect()) } + async fn wait_for_session_graph(&self, project_id: &ProjectId) -> Result<()> { + let (relation_graph, graph_settled) = { + let entries = self.lock().map_err(|_| { + session_registry_error( + "await project session relation graph", + "project runtime owner map lock is poisoned".to_owned(), + ) + })?; + let Some(ProjectRuntimeOwnerStateV1::Ready(owners)) = entries.get(project_id) else { + return Err(TraceDecayError::project_route( + "project_runtime_replacing_sessions", + true, + "Project session runtime is not accepting graph settlement", + )); + }; + let Some(owner) = owners.sessions.as_ref() else { + return Ok(()); + }; + ( + Arc::clone(&owner.relation_graph), + Arc::clone(&owner.graph_settled), + ) + }; + loop { + let notified = graph_settled.notified(); + let warming = matches!( + &*relation_graph + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner), + SessionGraphAttachmentStateV1::Warming + ); + if !warming { + return Ok(()); + } + notified.await; + } + } + fn reserve_session_replacement( &self, project_id: &ProjectId, @@ -132,6 +301,22 @@ impl ProjectRuntimeOwnerRegistryV1 { ); return Ok(None); }; + let sessions = match ProjectSessionRetirementOwnerV1::from_ready(sessions) { + Ok(sessions) => sessions, + Err(sessions) => { + let reason = sessions.graph_unavailable_reason(); + owners.sessions = Some(sessions); + entries.insert( + project_id.clone(), + ProjectRuntimeOwnerStateV1::Ready(owners), + ); + return Err(TraceDecayError::project_route( + "project_session_graph_warming", + true, + reason, + )); + } + }; entries.insert( project_id.clone(), ProjectRuntimeOwnerStateV1::ReplacingSessions, @@ -139,7 +324,7 @@ impl ProjectRuntimeOwnerRegistryV1 { Ok(Some(ProjectSessionReplacementReservationV1 { owners: self.clone(), project_id: project_id.clone(), - sessions: Some(ProjectSessionRetirementOwnerV1::from_ready(sessions)), + sessions: Some(sessions), memory: owners.memory, recovery_proof: None, armed: true, @@ -237,23 +422,14 @@ struct ProjectSessionRetirementOwnerV1 { database: RegisteredGlobalDbOwnerV1, graph: GraphDbOwnerAttachmentV1, store_target: Option, + graph_open_task_key: String, } impl ProjectSessionRetirementOwnerV1 { - fn from_ready(owner: RegisteredSessionOwnerV1) -> Self { - let RegisteredSessionOwnerV1 { - database, - relation_graph, - } = owner; - let SessionGraphOwnerV1 { - graph, - store_target, - } = relation_graph; - Self { - database, - graph, - store_target: Some(store_target), - } + fn from_ready( + owner: RegisteredSessionOwnerV1, + ) -> std::result::Result { + owner.into_retirement() } fn into_ready(self) -> Result { @@ -265,10 +441,14 @@ impl ProjectSessionRetirementOwnerV1 { })?; Ok(RegisteredSessionOwnerV1 { database: self.database, - relation_graph: SessionGraphOwnerV1 { - graph: self.graph, - store_target, - }, + relation_graph: Arc::new(StdMutex::new(SessionGraphAttachmentStateV1::Attached { + owner: Some(SessionGraphOwnerV1 { + graph: self.graph, + store_target, + }), + })), + graph_settled: Arc::new(tokio::sync::Notify::new()), + graph_open_task_key: self.graph_open_task_key, }) } @@ -1292,12 +1472,14 @@ impl ProjectSessionReplacementReservationV1 { database, graph, store_target, + graph_open_task_key, } = session; let Some(store_target) = store_target else { self.sessions = Some(ProjectSessionRetirementOwnerV1 { database, graph, store_target: None, + graph_open_task_key, }); return Err(session_registry_error( "retain recovered project session candidate", @@ -1314,13 +1496,14 @@ impl ProjectSessionReplacementReservationV1 { self.project_id.clone(), ProjectRuntimeOwnerStateV1::RecoveryRequired(ProjectSessionRecoveryRequiredV1 { sessions: None, - candidate_sessions: Some(RegisteredSessionOwnerV1 { + candidate_sessions: Some(RegisteredSessionOwnerV1::with_attached_graph( database, - relation_graph: SessionGraphOwnerV1 { + SessionGraphOwnerV1 { graph, store_target, }, - }), + graph_open_task_key, + )), memory: self.memory.take(), phase: ProjectSessionRecoveryPhaseV1::Terminal(proof), }), @@ -1630,35 +1813,9 @@ impl ProjectSessionCandidateActivationV1 { "project session candidate activation terminal proof is invalid".to_owned(), )); } - let database = candidate.database.issue_lease().map_err(|error| { - session_registry_error( - "issue recovered project session database client", - format!("{error:?}"), - ) - })?; - let graph = candidate - .relation_graph - .graph - .issue_lease() - .map_err(|error| { - session_registry_error( - "issue recovered project relation graph client", - error.to_string(), - ) - })?; - database - .bind_session_relation_graph( - SessionRelationScope::project_sessions(self.project_id.clone()), - graph, - candidate.relation_graph.graph.binding().clone(), - candidate.relation_graph.graph.verified_locator().clone(), - ) - .map_err(|_| { - session_registry_error( - "bind recovered project relation graph client", - "issued graph client did not match the exact recovered candidate".to_owned(), - ) - })?; + let database = candidate.issue_lease(SessionRelationScope::project_sessions( + self.project_id.clone(), + ))?; Ok(( database, candidate.database.weak_lease_issuer(), @@ -1876,6 +2033,26 @@ impl ProjectSessionRecoveryReservationV1 { "project session recovery map fence disappeared".to_owned(), )); } + let candidate = match ProjectSessionRetirementOwnerV1::from_ready(candidate) { + Ok(candidate) => candidate, + Err(candidate) => { + entries.insert( + self.project_id.clone(), + ProjectRuntimeOwnerStateV1::Recovering, + ); + drop(entries); + self.recovery = Some(ProjectSessionRecoveryRequiredV1 { + sessions, + candidate_sessions: Some(candidate), + memory, + phase: ProjectSessionRecoveryPhaseV1::Terminal(proof), + }); + return Err(session_registry_error( + "retire recovered project session candidate", + "recovered candidate relation graph is not attached".to_owned(), + )); + } + }; entries.insert( self.project_id.clone(), ProjectRuntimeOwnerStateV1::ReplacingSessions, @@ -1885,7 +2062,7 @@ impl ProjectSessionRecoveryReservationV1 { Ok(ProjectSessionReplacementReservationV1 { owners: self.owners.clone(), project_id: self.project_id.clone(), - sessions: Some(ProjectSessionRetirementOwnerV1::from_ready(candidate)), + sessions: Some(candidate), memory, recovery_proof: Some(proof), armed: true, @@ -2478,10 +2655,16 @@ impl DaemonSessionRuntimeRegistryV1 { "Project runtime is not accepting retirement admission", )); }; - let sessions = owners - .sessions - .take() - .map(ProjectSessionRetirementOwnerV1::from_ready); + let sessions = match owners.sessions.take() { + Some(owner) => match ProjectSessionRetirementOwnerV1::from_ready(owner) { + Ok(sessions) => Some(sessions), + Err(owner) => { + owners.sessions = Some(owner); + None + } + }, + None => None, + }; entries.insert(project_id.clone(), ProjectRuntimeOwnerStateV1::Retiring); Ok(Some(ProjectRuntimeOwnerRetirementReservationV1 { owners: self.project_owners.clone(), @@ -2492,10 +2675,13 @@ impl DaemonSessionRuntimeRegistryV1 { })) } - fn reserve_project_session_replacement( + async fn reserve_project_session_replacement( &self, project_id: &ProjectId, ) -> Result> { + self.project_owners + .wait_for_session_graph(project_id) + .await?; self.project_owners.reserve_session_replacement(project_id) } } diff --git a/src/daemon/store_runtime/session_registry/code_graph.rs b/src/daemon/store_runtime/session_registry/code_graph.rs index 00960b0972..aaa5ce8a6e 100644 --- a/src/daemon/store_runtime/session_registry/code_graph.rs +++ b/src/daemon/store_runtime/session_registry/code_graph.rs @@ -31,7 +31,7 @@ use tracedecay_store::{ SemanticVectorStageResumeOutcome, SemanticVectorStagingStore, StoreShardIdV1, }; -use super::{DaemonSessionRuntimeRegistryV1, Result, SessionGraphOwnerV1, session_registry_error}; +use super::{DaemonSessionRuntimeRegistryV1, Result, session_registry_error}; mod memory_runtime; pub(super) use memory_runtime::{ @@ -1511,24 +1511,6 @@ impl DaemonSessionRuntimeRegistryV1 { } } } - - pub(super) async fn retain_session_relation_graph_owner( - &self, - shard_id: StoreShardIdV1, - ) -> Result { - let (graph, store_target) = graph_attachment::open_session_relation_owner( - &self.registry, - &self.graph_registry, - &self.graph_lifecycle_cancelled, - self.incarnation, - shard_id, - ) - .await?; - Ok(SessionGraphOwnerV1 { - graph, - store_target, - }) - } } fn map_publication_error(error: GraphPublicationStoreErrorV1) -> GraphDbError { diff --git a/src/daemon/store_runtime/session_registry/code_graph/graph_attachment.rs b/src/daemon/store_runtime/session_registry/code_graph/graph_attachment.rs index e6e29cdbe1..6523ed93fe 100644 --- a/src/daemon/store_runtime/session_registry/code_graph/graph_attachment.rs +++ b/src/daemon/store_runtime/session_registry/code_graph/graph_attachment.rs @@ -108,7 +108,7 @@ async fn open_session_relation_owner_with_cancellation( // cooperative workers; cancellation remains visible inside the native // load through the exact registration authority. let graph = tokio::task::spawn_blocking(move || { - hotpath::measure_block!("daemon.store.memory_graph.open", { + hotpath::measure_block!("daemon.store.session_relation_graph.open", { graph_registry.resolve_owner_attachment(GraphDbOwnerRegistrationV1 { operation: registration, authority_attachment: Box::new(store_attachment), diff --git a/src/daemon/store_runtime/session_registry/code_reads.rs b/src/daemon/store_runtime/session_registry/code_reads.rs index 3b20d7bd6b..b1cb98bbca 100644 --- a/src/daemon/store_runtime/session_registry/code_reads.rs +++ b/src/daemon/store_runtime/session_registry/code_reads.rs @@ -259,7 +259,8 @@ impl DaemonSessionRuntimeRegistryV1 { &self, project_id: &ProjectId, ) -> Result<()> { - let Some(mut replacement) = self.reserve_project_session_replacement(project_id)? else { + let Some(mut replacement) = self.reserve_project_session_replacement(project_id).await? + else { return Ok(()); }; diff --git a/src/daemon/store_runtime/session_registry/mounts.rs b/src/daemon/store_runtime/session_registry/mounts.rs index e748cb5575..8d1fd5e266 100644 --- a/src/daemon/store_runtime/session_registry/mounts.rs +++ b/src/daemon/store_runtime/session_registry/mounts.rs @@ -25,10 +25,11 @@ use super::{ MemoryStoreOwnerV1, ProfileAuthorityPinResult, ProjectRuntimeOwnerAdmissionV1, ProjectRuntimeOwnerStateV1, RegisteredGlobalDbLeaseV1, RegisteredGlobalDbOwnerV1, RegisteredSchemaConvergenceMaintenance, RegisteredSessionOwnerV1, RemoteNodeStoreOwnerV1, - Result, RetainedHookTasks, StoreRuntimeClientLease, StoreRuntimeOpenRequest, - StoreRuntimeOpenResult, StoreRuntimeRegistry, StoreRuntimeResolver, open_runtime, - open_runtime_with_presence, register_registered_schema_installer, registry_open_error, - runtime_incarnation, session_registry_error, + Result, RetainedHookTasks, SessionGraphAttachmentStateV1, SessionGraphOwnerV1, + StoreRuntimeClientLease, StoreRuntimeOpenRequest, StoreRuntimeOpenResult, StoreRuntimeRegistry, + StoreRuntimeResolver, open_runtime, open_runtime_with_presence, + register_registered_schema_installer, registry_open_error, runtime_incarnation, + session_registry_error, }; use crate::errors::TraceDecayError; @@ -211,39 +212,71 @@ impl DaemonSessionRuntimeRegistryV1 { owner: &RegisteredSessionOwnerV1, scope: SessionRelationScope, ) -> Result { - self.issue_session_owner_lease_parts(&owner.database, &owner.relation_graph.graph, scope) + owner.issue_lease(scope) } - fn issue_session_owner_lease_parts( + fn publish_session_owner( &self, - owner: &RegisteredGlobalDbOwnerV1, - graph_owner: &tracedecay_graph_db::GraphDbOwnerAttachmentV1, - scope: SessionRelationScope, - ) -> Result { - let database = owner.issue_lease().map_err(|error| { - session_registry_error( - "issue registered session database client", - format!("{error:?}"), - ) - })?; - let graph = graph_owner.issue_lease().map_err(|error| { - session_registry_error( - "issue registered session relation graph client", - error.to_string(), - ) - })?; - let graph_binding = graph_owner.binding().clone(); - let graph_verified_locator = graph_owner.verified_locator().clone(); - database - .bind_session_relation_graph(scope, graph, graph_binding, graph_verified_locator) - .map_err(|_| { - session_registry_error( - "bind issued registered session relation graph", - "issued graph client did not match the exact registered session owner" - .to_owned(), - ) - })?; - Ok(database) + database: RegisteredGlobalDbOwnerV1, + shard_id: StoreShardIdV1, + ) -> RegisteredSessionOwnerV1 { + let relation_graph = Arc::new(std::sync::Mutex::new( + SessionGraphAttachmentStateV1::Warming, + )); + let graph_open_task_key = format!("{shard_id:?}"); + let task_relation_graph = Arc::clone(&relation_graph); + let graph_settled = Arc::new(tokio::sync::Notify::new()); + let task_graph_settled = Arc::clone(&graph_settled); + let registry = self.registry.clone(); + let graph_registry = self.graph_registry.clone(); + let graph_lifecycle_cancelled = Arc::clone(&self.graph_lifecycle_cancelled); + let incarnation = self.incarnation; + let retained = self.retained_hook_tasks.retain( + "session-relation-graph-open", + &graph_open_task_key, + move |cancellation| async move { + let opened = + super::code_graph::graph_attachment::open_session_relation_owner_for_task( + ®istry, + &graph_registry, + &graph_lifecycle_cancelled, + cancellation, + incarnation, + shard_id, + ) + .await; + let state = match opened { + Ok((graph, store_target)) => SessionGraphAttachmentStateV1::Attached { + owner: Some(SessionGraphOwnerV1 { + graph, + store_target, + }), + }, + Err(error) => SessionGraphAttachmentStateV1::Detached { + error: error.to_string(), + }, + }; + *task_relation_graph + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = state; + task_graph_settled.notify_waiters(); + }, + ); + if !retained { + *relation_graph + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = + SessionGraphAttachmentStateV1::Detached { + error: "session relation graph open task admission is closed".to_owned(), + }; + graph_settled.notify_waiters(); + } + RegisteredSessionOwnerV1 { + database, + relation_graph, + graph_settled, + graph_open_task_key, + } } pub(crate) async fn profile_database(&self) -> Result { @@ -366,11 +399,7 @@ impl DaemonSessionRuntimeRegistryV1 { let database = self .attach_registered(runtime, "mount profile session store") .await?; - let relation_graph = self.retain_session_relation_graph_owner(shard_id).await?; - let database = RegisteredSessionOwnerV1 { - database, - relation_graph, - }; + let database = self.publish_session_owner(database, shard_id); let lease = self.issue_session_owner_lease( &database, SessionRelationScope::profile_sessions(self.identity.profile_id().clone()), @@ -893,10 +922,9 @@ impl DaemonSessionRuntimeRegistryV1 { .unwrap_or_else(std::sync::PoisonError::into_inner) .take() { - retain_identity(( - owner.relation_graph.graph.binding().clone(), - owner.relation_graph.graph.verified_locator().clone(), - )); + if let Some(identity) = owner.take_graph_store_identity() { + retain_identity(identity); + } } let mut projects = self .project_owners @@ -911,10 +939,9 @@ impl DaemonSessionRuntimeRegistryV1 { retain_identity(runtime.graph_store_identity()); } if let Some(sessions) = owners.sessions.take() { - retain_identity(( - sessions.relation_graph.graph.binding().clone(), - sessions.relation_graph.graph.verified_locator().clone(), - )); + if let Some(identity) = sessions.take_graph_store_identity() { + retain_identity(identity); + } } } ProjectRuntimeOwnerStateV1::RecoveryRequired(recovery) => { @@ -930,10 +957,9 @@ impl DaemonSessionRuntimeRegistryV1 { )); } if let Some(sessions) = recovery.candidate_sessions.take() { - retain_identity(( - sessions.relation_graph.graph.binding().clone(), - sessions.relation_graph.graph.verified_locator().clone(), - )); + if let Some(identity) = sessions.take_graph_store_identity() { + retain_identity(identity); + } } } ProjectRuntimeOwnerStateV1::Faulted(faulted) => { @@ -943,10 +969,9 @@ impl DaemonSessionRuntimeRegistryV1 { retain_identity(runtime.graph_store_identity()); } if let Some(sessions) = faulted.retained.sessions.take() { - retain_identity(( - sessions.relation_graph.graph.binding().clone(), - sessions.relation_graph.graph.verified_locator().clone(), - )); + if let Some(identity) = sessions.take_graph_store_identity() { + retain_identity(identity); + } } if let Some(sessions) = faulted.sessions.take() { retain_identity(( @@ -1102,11 +1127,7 @@ impl DaemonSessionRuntimeRegistryV1 { let database = self .attach_registered(runtime, "mount project session store") .await?; - let relation_graph = self.retain_session_relation_graph_owner(shard_id).await?; - let database = RegisteredSessionOwnerV1 { - database, - relation_graph, - }; + let database = self.publish_session_owner(database, shard_id); let lease = self.issue_session_owner_lease( &database, SessionRelationScope::project_sessions(project_id.clone()), diff --git a/src/daemon/store_runtime/session_registry/remote_recovery/publication.rs b/src/daemon/store_runtime/session_registry/remote_recovery/publication.rs index cc4439c1e6..bb6aceaa44 100644 --- a/src/daemon/store_runtime/session_registry/remote_recovery/publication.rs +++ b/src/daemon/store_runtime/session_registry/remote_recovery/publication.rs @@ -40,32 +40,7 @@ impl RemoteRecoveryPublicationContextV1 { owner: &RegisteredSessionOwnerV1, project_id: &ProjectId, ) -> Result { - let database = owner.database.issue_lease().map_err(|error| { - session_registry_error( - "issue remote recovery project session client", - format!("{error:?}"), - ) - })?; - let graph = owner.relation_graph.graph.issue_lease().map_err(|error| { - session_registry_error( - "issue remote recovery session relation graph client", - error.to_string(), - ) - })?; - database - .bind_session_relation_graph( - SessionRelationScope::project_sessions(project_id.clone()), - graph, - owner.relation_graph.graph.binding().clone(), - owner.relation_graph.graph.verified_locator().clone(), - ) - .map_err(|_| { - session_registry_error( - "bind remote recovery session relation graph client", - "issued graph client did not match the exact project owner".to_owned(), - ) - })?; - Ok(database) + owner.issue_lease(SessionRelationScope::project_sessions(project_id.clone())) } async fn restore_replacement_ready( @@ -123,6 +98,9 @@ impl RemoteRecoveryPublicationContextV1 { project_id: &ProjectId, destination: &Path, ) -> Result { + self.project_owners + .wait_for_session_graph(project_id) + .await?; let Some(replacement) = self .project_owners .reserve_session_replacement(project_id)? @@ -316,6 +294,7 @@ impl RemoteRecoveryPublicationContextV1 { .await?; let database = Database::publish_runtime(runtime, DatabaseAccessMode::ReadWrite).await?; let database = RegisteredGlobalDbOwnerV1::admit_and_attach(database).await?; + let graph_open_task_key = format!("{shard_id:?}"); let (graph, store_target) = super::super::code_graph::graph_attachment::open_session_relation_owner( &self.registry, @@ -325,13 +304,14 @@ impl RemoteRecoveryPublicationContextV1 { shard_id, ) .await?; - Ok(RegisteredSessionOwnerV1 { + Ok(RegisteredSessionOwnerV1::with_attached_graph( database, - relation_graph: SessionGraphOwnerV1 { + SessionGraphOwnerV1 { graph, store_target, }, - }) + graph_open_task_key, + )) } async fn activate_candidate( @@ -759,6 +739,7 @@ mod tests { let mut replacement = registry .reserve_project_session_replacement(&project_id) + .await .expect("reserve prior session owner") .expect("mounted session owner"); let graph_target = replacement diff --git a/src/daemon/store_runtime/session_registry/tests.rs b/src/daemon/store_runtime/session_registry/tests.rs index 39b7925929..3aa0a24811 100644 --- a/src/daemon/store_runtime/session_registry/tests.rs +++ b/src/daemon/store_runtime/session_registry/tests.rs @@ -1241,6 +1241,70 @@ async fn corrupt_derived_graph_preserves_relational_owner_lifecycle() { .expect("detached relational owner retires cleanly"); } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn corrupt_session_relation_graph_preserves_relational_session_database() { + let temporary = tempfile::tempdir().expect("temporary project parent"); + let root = temporary + .path() + .canonicalize() + .expect("canonical fixture root"); + let profile_root = root.join("profile"); + let project_root = root.join("project"); + std::fs::create_dir_all(&project_root).expect("project root"); + gix::init(&project_root).expect("initialize project repository"); + let identity = crate::daemon::profile_identity::load_or_create(&profile_root) + .expect("durable profile identity"); + let project_id = ProjectId::new("project.session-relation-corrupt").expect("project id"); + + let first_registry = DaemonSessionRuntimeRegistryV1::open(identity.clone()) + .await + .expect("first session runtime registry"); + let first_database = first_registry + .project_sessions(project_id.clone(), [project_root.clone()]) + .await + .expect("initial project session database"); + let graph_path = first_database.db_path().with_extension("grafeo"); + drop(first_database); + first_registry.cancel_terminal_tasks(); + first_registry + .shutdown_terminal_tasks() + .await + .expect("first terminal tasks shut down"); + first_registry + .close_retained_graph_runtimes_for_shutdown() + .await + .expect("first session relation graph closes"); + drop(first_registry); + + std::fs::write(&graph_path, b"corrupt session relation graph") + .expect("corrupt session relation graph file"); + + let reopened_registry = DaemonSessionRuntimeRegistryV1::open(identity) + .await + .expect("reopened session runtime registry"); + let reopened = tokio::time::timeout( + std::time::Duration::from_secs(2), + reopened_registry.project_sessions(project_id.clone(), [project_root]), + ) + .await + .expect("relational session open must not wait for relation graph recovery") + .expect("relational session database remains available"); + assert!( + reopened.session_relation_graph_identity().is_err(), + "corrupt relation graph must remain unavailable rather than fabricating readiness" + ); + drop(reopened); + reopened_registry.cancel_terminal_tasks(); + reopened_registry + .shutdown_terminal_tasks() + .await + .expect("detached session relation graph task shuts down"); + reopened_registry + .close_retained_graph_runtimes_for_shutdown() + .await + .expect("detached relational session owner shuts down cleanly"); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn read_only_project_graph_reuses_daemon_publication_without_write_authority() { let temporary = tempfile::tempdir().expect("temporary project parent"); From 544ba28aa213218d3a62730041e2037d291267d8 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Wed, 26 Aug 2026 04:14:35 +0000 Subject: [PATCH 04/23] fix(runtime): bind late project graphs to live sessions --- crates/tracedecay-global-db/src/registered.rs | 39 +++++++-- src/daemon/project_composition/runtime.rs | 10 +-- src/daemon/store_runtime/session_registry.rs | 51 +++++++++++- .../store_runtime/session_registry/mounts.rs | 82 ++++++++++++++----- .../store_runtime/session_registry/tests.rs | 66 +++++++++++++++ 5 files changed, 212 insertions(+), 36 deletions(-) diff --git a/crates/tracedecay-global-db/src/registered.rs b/crates/tracedecay-global-db/src/registered.rs index 2fd7c4c1f0..3281178f72 100644 --- a/crates/tracedecay-global-db/src/registered.rs +++ b/crates/tracedecay-global-db/src/registered.rs @@ -31,6 +31,7 @@ pub use delivery_settlement::{ /// paths retain only [`RegisteredGlobalDbLeaseV1`]. pub struct RegisteredGlobalDbOwnerV1 { database: DatabaseOwnerV1, + project_graph: Arc>, } /// Cloneable, weak issuance route for one registered global-database owner. @@ -41,6 +42,7 @@ pub struct RegisteredGlobalDbOwnerV1 { #[derive(Clone)] pub struct RegisteredGlobalDbWeakLeaseIssuerV1 { database: DatabaseOwnerWeakLeaseIssuerV1, + project_graph: Arc>, } impl RegisteredGlobalDbOwnerV1 { @@ -72,7 +74,10 @@ impl RegisteredGlobalDbOwnerV1 { registered.rearm_queued_projection_retries().await?; super::schema_stages::converge_attached_registered_schema(®istered.database).await?; drop(registered); - Ok(Self { database }) + Ok(Self { + database, + project_graph: Arc::new(OnceLock::new()), + }) } /// Returns the resumable convergence plan for an already admitted schema @@ -89,7 +94,10 @@ impl RegisteredGlobalDbOwnerV1 { registered.rearm_queued_projection_retries().await?; drop(registered); Ok(( - Self { database }, + Self { + database, + project_graph: Arc::new(OnceLock::new()), + }, super::schema_stages::RegisteredSchemaConvergence::for_existing_client(), )) } @@ -99,14 +107,20 @@ impl RegisteredGlobalDbOwnerV1 { /// only that issuance. pub fn issue_lease(&self) -> Result { Ok(RegisteredGlobalDbLeaseV1::from_database( - RegisteredGlobalDb::from_database(self.database.issue_lease()?), + RegisteredGlobalDb::from_database_with_project_graph( + self.database.issue_lease()?, + Arc::clone(&self.project_graph), + ), )) } /// Issues a mode-reduced client that can never regain write authority. pub fn issue_read_only_lease(&self) -> Result { Ok(RegisteredGlobalDbLeaseV1::from_database( - RegisteredGlobalDb::from_database(self.database.issue_read_only_lease()?), + RegisteredGlobalDb::from_database_with_project_graph( + self.database.issue_read_only_lease()?, + Arc::clone(&self.project_graph), + ), )) } @@ -116,6 +130,7 @@ impl RegisteredGlobalDbOwnerV1 { pub fn weak_lease_issuer(&self) -> RegisteredGlobalDbWeakLeaseIssuerV1 { RegisteredGlobalDbWeakLeaseIssuerV1 { database: self.database.weak_lease_issuer(), + project_graph: Arc::clone(&self.project_graph), } } @@ -144,7 +159,10 @@ impl RegisteredGlobalDbWeakLeaseIssuerV1 { &self, ) -> Result { Ok(RegisteredGlobalDbLeaseV1::from_database( - RegisteredGlobalDb::from_database(self.database.issue_lease()?), + RegisteredGlobalDb::from_database_with_project_graph( + self.database.issue_lease()?, + Arc::clone(&self.project_graph), + ), )) } @@ -209,7 +227,7 @@ impl RegisteredGlobalDbLeaseV1 { pub struct RegisteredGlobalDb { database: Database, - project_graph: OnceLock, + project_graph: Arc>, session_relation_graph: OnceLock<( crate::session_temporal::relations::SessionRelationScope, tracedecay_graph_db::GraphDbLeaseV1, @@ -551,9 +569,16 @@ impl RegisteredGlobalDb { } fn from_database(database: Database) -> Self { + Self::from_database_with_project_graph(database, Arc::new(OnceLock::new())) + } + + fn from_database_with_project_graph( + database: Database, + project_graph: Arc>, + ) -> Self { Self { database, - project_graph: OnceLock::new(), + project_graph, session_relation_graph: OnceLock::new(), } } diff --git a/src/daemon/project_composition/runtime.rs b/src/daemon/project_composition/runtime.rs index be0fb50523..1849552951 100644 --- a/src/daemon/project_composition/runtime.rs +++ b/src/daemon/project_composition/runtime.rs @@ -16,13 +16,9 @@ pub(super) async fn bind_verified_project_graph_runtime( database: Arc, sessions: &RegisteredGlobalDb, ) -> crate::errors::Result<()> { - let graph_proxy = - database - .memory_graph_runtime() - .ok_or_else(|| crate::errors::TraceDecayError::Config { - message: "project memory graph runtime was not mounted before project sessions" - .to_owned(), - })?; + let Some(graph_proxy) = database.memory_graph_runtime() else { + return Ok(()); + }; sessions .bind_project_graph_runtime(graph_proxy) .map_err(|_| crate::errors::TraceDecayError::Config { diff --git a/src/daemon/store_runtime/session_registry.rs b/src/daemon/store_runtime/session_registry.rs index af516d7214..26f020f91e 100644 --- a/src/daemon/store_runtime/session_registry.rs +++ b/src/daemon/store_runtime/session_registry.rs @@ -107,6 +107,15 @@ impl RegisteredSessionOwnerV1 { let SessionGraphAttachmentStateV1::Attached { owner: Some(owner) } = &*state else { return Ok(database); }; + Self::bind_relation_graph(&database, owner, scope)?; + Ok(database) + } + + fn bind_relation_graph( + database: &RegisteredGlobalDbLeaseV1, + owner: &SessionGraphOwnerV1, + scope: SessionRelationScope, + ) -> Result<()> { let graph = owner.graph.issue_lease().map_err(|error| { session_registry_error( "issue registered session relation graph client", @@ -127,7 +136,7 @@ impl RegisteredSessionOwnerV1 { .to_owned(), ) })?; - Ok(database) + Ok(()) } fn graph_unavailable_reason(&self) -> String { @@ -416,6 +425,46 @@ impl ProjectRuntimeOwnerRegistryV1 { } } +fn bind_ready_project_memory_graph( + owners: &ProjectRuntimeOwnerRegistryV1, + project_id: &ProjectId, +) -> Result { + let (memory, sessions) = { + let entries = owners.lock().map_err(|_| { + session_registry_error( + "bind ready project memory graph", + "project runtime owner map lock is poisoned".to_owned(), + ) + })?; + let Some(ProjectRuntimeOwnerStateV1::Ready(owners)) = entries.get(project_id) else { + return Ok(false); + }; + let (Some(memory), Some(sessions)) = (owners.memory.as_ref(), owners.sessions.as_ref()) + else { + return Ok(false); + }; + ( + memory.issue_database_lease()?, + sessions.database.issue_lease().map_err(|error| { + session_registry_error( + "issue project session client for graph binding", + format!("{error:?}"), + ) + })?, + ) + }; + let Some(graph) = memory.memory_graph_runtime() else { + return Ok(false); + }; + sessions.bind_project_graph_runtime(graph).map_err(|_| { + session_registry_error( + "bind ready project memory graph", + "verified project graph runtime does not match the project session shard".to_owned(), + ) + })?; + Ok(true) +} + /// The only state which may temporarily separate the exact paired Store /// target from its Ready graph owner. It never reaches the canonical map. struct ProjectSessionRetirementOwnerV1 { diff --git a/src/daemon/store_runtime/session_registry/mounts.rs b/src/daemon/store_runtime/session_registry/mounts.rs index 8d1fd5e266..307a6bd462 100644 --- a/src/daemon/store_runtime/session_registry/mounts.rs +++ b/src/daemon/store_runtime/session_registry/mounts.rs @@ -13,7 +13,7 @@ use tracedecay_rusqlite_runtime::remote::RemoteRecoverySqliteAuthorityV1; use tracedecay_rusqlite_runtime::remote::{ RemoteSpoolKeyV1, RemoteSpoolKeyringV1, RemoteSqliteStorageErrorV1, RemoteSqliteStorageV1, }; -use tracedecay_store::{ProjectId, StoreShardIdV1}; +use tracedecay_store::{ProjectId, StoreShardIdV1, StoreShardScopeV1}; use super::remote_recovery::{ DaemonRemoteRecoveryPhysicalEffectsV1, RemoteRecoveryPublicationContextV1, @@ -27,9 +27,9 @@ use super::{ RegisteredSchemaConvergenceMaintenance, RegisteredSessionOwnerV1, RemoteNodeStoreOwnerV1, Result, RetainedHookTasks, SessionGraphAttachmentStateV1, SessionGraphOwnerV1, StoreRuntimeClientLease, StoreRuntimeOpenRequest, StoreRuntimeOpenResult, StoreRuntimeRegistry, - StoreRuntimeResolver, open_runtime, open_runtime_with_presence, - register_registered_schema_installer, registry_open_error, runtime_incarnation, - session_registry_error, + StoreRuntimeResolver, bind_ready_project_memory_graph, open_runtime, + open_runtime_with_presence, register_registered_schema_installer, registry_open_error, + runtime_incarnation, session_registry_error, }; use crate::errors::TraceDecayError; @@ -219,7 +219,14 @@ impl DaemonSessionRuntimeRegistryV1 { &self, database: RegisteredGlobalDbOwnerV1, shard_id: StoreShardIdV1, - ) -> RegisteredSessionOwnerV1 { + scope: SessionRelationScope, + ) -> Result<(RegisteredSessionOwnerV1, RegisteredGlobalDbLeaseV1)> { + let published_lease = database.issue_lease().map_err(|error| { + session_registry_error( + "issue published session database client", + format!("{error:?}"), + ) + })?; let relation_graph = Arc::new(std::sync::Mutex::new( SessionGraphAttachmentStateV1::Warming, )); @@ -227,6 +234,7 @@ impl DaemonSessionRuntimeRegistryV1 { let task_relation_graph = Arc::clone(&relation_graph); let graph_settled = Arc::new(tokio::sync::Notify::new()); let task_graph_settled = Arc::clone(&graph_settled); + let task_published_lease = published_lease.clone(); let registry = self.registry.clone(); let graph_registry = self.graph_registry.clone(); let graph_lifecycle_cancelled = Arc::clone(&self.graph_lifecycle_cancelled); @@ -246,12 +254,24 @@ impl DaemonSessionRuntimeRegistryV1 { ) .await; let state = match opened { - Ok((graph, store_target)) => SessionGraphAttachmentStateV1::Attached { - owner: Some(SessionGraphOwnerV1 { + Ok((graph, store_target)) => { + let owner = SessionGraphOwnerV1 { graph, store_target, - }), - }, + }; + match RegisteredSessionOwnerV1::bind_relation_graph( + &task_published_lease, + &owner, + scope, + ) { + Ok(()) => { + SessionGraphAttachmentStateV1::Attached { owner: Some(owner) } + } + Err(error) => SessionGraphAttachmentStateV1::Detached { + error: error.to_string(), + }, + } + } Err(error) => SessionGraphAttachmentStateV1::Detached { error: error.to_string(), }, @@ -271,12 +291,15 @@ impl DaemonSessionRuntimeRegistryV1 { }; graph_settled.notify_waiters(); } - RegisteredSessionOwnerV1 { - database, - relation_graph, - graph_settled, - graph_open_task_key, - } + Ok(( + RegisteredSessionOwnerV1 { + database, + relation_graph, + graph_settled, + graph_open_task_key, + }, + published_lease, + )) } pub(crate) async fn profile_database(&self) -> Result { @@ -399,9 +422,9 @@ impl DaemonSessionRuntimeRegistryV1 { let database = self .attach_registered(runtime, "mount profile session store") .await?; - let database = self.publish_session_owner(database, shard_id); - let lease = self.issue_session_owner_lease( - &database, + let (database, lease) = self.publish_session_owner( + database, + shard_id, SessionRelationScope::profile_sessions(self.identity.profile_id().clone()), )?; *self @@ -439,6 +462,11 @@ impl DaemonSessionRuntimeRegistryV1 { let graph_lifecycle_cancelled = Arc::clone(&self.graph_lifecycle_cancelled); let incarnation = self.incarnation; let task_shard_id = shard_id.clone(); + let task_project_id = match &task_shard_id.scope { + StoreShardScopeV1::Project { project_id } => Some(project_id.clone()), + _ => None, + }; + let project_owners = self.project_owners.clone(); let retained = self.retained_hook_tasks.retain( "memory-graph-open", &graph_open_task_key, @@ -503,6 +531,16 @@ impl DaemonSessionRuntimeRegistryV1 { *task_graph .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) = state; + if let Some(project_id) = task_project_id + && let Err(error) = + bind_ready_project_memory_graph(&project_owners, &project_id) + { + tracing::error!( + project_id = %project_id, + error = %error, + "background project memory graph could not bind to project sessions" + ); + } }, ); if !retained { @@ -1127,9 +1165,9 @@ impl DaemonSessionRuntimeRegistryV1 { let database = self .attach_registered(runtime, "mount project session store") .await?; - let database = self.publish_session_owner(database, shard_id); - let lease = self.issue_session_owner_lease( - &database, + let (database, lease) = self.publish_session_owner( + database, + shard_id, SessionRelationScope::project_sessions(project_id.clone()), )?; let replay_issuer = database.database.weak_lease_issuer(); @@ -1154,6 +1192,7 @@ impl DaemonSessionRuntimeRegistryV1 { format!("publish={error}; replay cleanup={cleanup:?}"), )); } + bind_ready_project_memory_graph(&self.project_owners, &project_id)?; let recoveries = self .remote_recovery_authorities .lock() @@ -1271,6 +1310,7 @@ impl DaemonSessionRuntimeRegistryV1 { .await?; let (owner, database) = self.publish_memory_owner(shard_id, runtime).await?; admission.publish_memory(owner)?; + bind_ready_project_memory_graph(&self.project_owners, &project_id)?; Ok(database) } diff --git a/src/daemon/store_runtime/session_registry/tests.rs b/src/daemon/store_runtime/session_registry/tests.rs index 3aa0a24811..8603127cbe 100644 --- a/src/daemon/store_runtime/session_registry/tests.rs +++ b/src/daemon/store_runtime/session_registry/tests.rs @@ -1305,6 +1305,72 @@ async fn corrupt_session_relation_graph_preserves_relational_session_database() .expect("detached relational session owner shuts down cleanly"); } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn background_session_relation_graph_reaches_the_published_relational_lease() { + let temporary = tempfile::tempdir().expect("temporary project parent"); + let root = temporary + .path() + .canonicalize() + .expect("canonical fixture root"); + let profile_root = root.join("profile"); + let project_root = root.join("project"); + std::fs::create_dir_all(&project_root).expect("project root"); + gix::init(&project_root).expect("initialize project repository"); + let identity = crate::daemon::profile_identity::load_or_create(&profile_root) + .expect("durable profile identity"); + let project_id = ProjectId::new("project.session-relation-late-bind").expect("project id"); + let registry = DaemonSessionRuntimeRegistryV1::open(identity) + .await + .expect("session runtime registry"); + + let sessions = registry + .project_sessions(project_id, [project_root]) + .await + .expect("relational sessions publish before graph restore"); + tokio::time::timeout(std::time::Duration::from_secs(10), async { + while sessions.session_relation_graph_identity().is_err() { + tokio::task::yield_now().await; + } + }) + .await + .expect("the published relational lease observes its late graph attachment"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn background_project_memory_graph_reaches_the_published_session_lease() { + let temporary = tempfile::tempdir().expect("temporary project parent"); + let root = temporary + .path() + .canonicalize() + .expect("canonical fixture root"); + let profile_root = root.join("profile"); + let project_root = root.join("project"); + std::fs::create_dir_all(&project_root).expect("project root"); + gix::init(&project_root).expect("initialize project repository"); + let identity = crate::daemon::profile_identity::load_or_create(&profile_root) + .expect("durable profile identity"); + let project_id = ProjectId::new("project.memory-relation-late-bind").expect("project id"); + let registry = DaemonSessionRuntimeRegistryV1::open(identity) + .await + .expect("session runtime registry"); + + let sessions = registry + .project_sessions(project_id.clone(), [project_root.clone()]) + .await + .expect("relational sessions publish before memory graph restore"); + registry + .project_memory(project_id, [project_root]) + .await + .expect("project memory database publishes before graph restore"); + tokio::time::timeout(std::time::Duration::from_secs(10), async { + while sessions.project_graph_runtime().is_none() { + tokio::task::yield_now().await; + } + }) + .await + .expect("the published session lease observes its late project graph attachment"); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn read_only_project_graph_reuses_daemon_publication_without_write_authority() { let temporary = tempfile::tempdir().expect("temporary project parent"); From b13d2673ed537d706b09268bb7999f8fff9e25ea Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Wed, 26 Aug 2026 05:20:41 +0000 Subject: [PATCH 05/23] fix(runtime): prioritize project core over schema audit --- src/daemon/project_composition.rs | 6 ++ .../session_registry/maintenance.rs | 61 ++++++++++++++++++- .../store_runtime/session_registry/tests.rs | 43 +++++++++++++ 3 files changed, 109 insertions(+), 1 deletion(-) diff --git a/src/daemon/project_composition.rs b/src/daemon/project_composition.rs index 01ef0dc253..2e4dc9771e 100644 --- a/src/daemon/project_composition.rs +++ b/src/daemon/project_composition.rs @@ -251,6 +251,10 @@ pub(super) async fn production_project_server( None, )); } + let foreground_project_open = store_administration + .session_runtime_registry() + .await? + .begin_foreground_project_open()?; let capacity_gate = project_open_capacity_gate(project_open_gates).await; let capacity_admission = tokio::select! { biased; @@ -582,6 +586,7 @@ pub(super) async fn production_project_server( hotpath::gauge!("project_servers").inc(1.0); } if !inserted { + drop(foreground_project_open); route_registered.store(false, Ordering::Release); } else { if cancellation.is_cancelled() { @@ -636,6 +641,7 @@ pub(super) async fn production_project_server( ), ], ); + drop(foreground_project_open); let semantic_startup_project = canonical_project_path.to_path_buf(); tokio::task::spawn_blocking(move || { let started = Instant::now(); diff --git a/src/daemon/store_runtime/session_registry/maintenance.rs b/src/daemon/store_runtime/session_registry/maintenance.rs index 80bc94640d..441be019b0 100644 --- a/src/daemon/store_runtime/session_registry/maintenance.rs +++ b/src/daemon/store_runtime/session_registry/maintenance.rs @@ -1,5 +1,5 @@ use std::collections::BTreeMap; -use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use std::sync::{Arc, Mutex as StdMutex, MutexGuard}; #[cfg(test)] @@ -48,6 +48,7 @@ fn lock_registered_schema_convergence_statuses( pub(super) struct RegisteredSchemaConvergenceMaintenance { accepting: AtomicBool, + foreground_project_opens: Arc, statuses: Arc>, tasks: StdMutex>>, #[cfg(test)] @@ -56,10 +57,57 @@ pub(super) struct RegisteredSchemaConvergenceMaintenance { gate: StdMutex>>, } +#[derive(Default)] +struct ForegroundProjectOpenState { + active: AtomicUsize, + settled: tokio::sync::Notify, +} + +pub(crate) struct ForegroundProjectOpenAdmission { + state: Arc, +} + +impl Drop for ForegroundProjectOpenAdmission { + fn drop(&mut self) { + if self.state.active.fetch_sub(1, Ordering::AcqRel) == 1 { + self.state.settled.notify_waiters(); + } + } +} + +impl ForegroundProjectOpenState { + fn admit(self: &Arc) -> Result { + self.active + .fetch_update(Ordering::AcqRel, Ordering::Acquire, |active| { + active.checked_add(1) + }) + .map_err(|_| { + session_registry_error( + "admit foreground project open", + "foreground project-open admission counter exhausted".to_owned(), + ) + })?; + Ok(ForegroundProjectOpenAdmission { + state: Arc::clone(self), + }) + } + + async fn wait_until_settled(&self) { + loop { + let settled = self.settled.notified(); + if self.active.load(Ordering::Acquire) == 0 { + return; + } + settled.await; + } + } +} + impl RegisteredSchemaConvergenceMaintenance { pub(super) fn new() -> Self { Self { accepting: AtomicBool::new(true), + foreground_project_opens: Arc::new(ForegroundProjectOpenState::default()), statuses: Arc::new(StdMutex::new(BTreeMap::new())), tasks: StdMutex::new(BTreeMap::new()), #[cfg(test)] @@ -69,6 +117,10 @@ impl RegisteredSchemaConvergenceMaintenance { } } + fn begin_foreground_project_open(&self) -> Result { + self.foreground_project_opens.admit() + } + #[cfg(test)] pub(super) fn status( &self, @@ -115,8 +167,10 @@ impl RegisteredSchemaConvergenceMaintenance { .expect("registered schema convergence test gate lock remains healthy") .clone(); let statuses = Arc::clone(&self.statuses); + let foreground_project_opens = Arc::clone(&self.foreground_project_opens); let task_shard_id = shard_id.clone(); let task = tokio::spawn(async move { + foreground_project_opens.wait_until_settled().await; #[cfg(test)] if let Some(gate) = gate { gate.block().await; @@ -311,6 +365,11 @@ impl DaemonSessionRuntimeRegistryV1 { Ok(database) } + pub(crate) fn begin_foreground_project_open(&self) -> Result { + self.registered_schema_convergence + .begin_foreground_project_open() + } + #[cfg(test)] pub(crate) fn registered_schema_convergence_status( &self, diff --git a/src/daemon/store_runtime/session_registry/tests.rs b/src/daemon/store_runtime/session_registry/tests.rs index 8603127cbe..df184a688a 100644 --- a/src/daemon/store_runtime/session_registry/tests.rs +++ b/src/daemon/store_runtime/session_registry/tests.rs @@ -665,6 +665,49 @@ async fn daemon_admission_returns_while_historical_convergence_is_blocked() { convergence_gate.release(); } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn foreground_project_open_defers_historical_convergence_until_core_publication() { + let (_temporary, identity, project_id, project_root, _sessions_path, _database_scope) = + project_sessions_pending_convergence("project.schema-foreground-admission").await; + let registry = DaemonSessionRuntimeRegistryV1::open_with_session_maintenance(identity, true) + .await + .expect("session runtime registry"); + let convergence_gate = registry.block_registered_schema_convergence_for_test(); + let foreground = registry + .begin_foreground_project_open() + .expect("foreground project-open admission"); + + let database = registry + .project_sessions(project_id, [project_root]) + .await + .expect("registered project sessions publish for foreground open"); + assert!( + tokio::time::timeout( + std::time::Duration::from_millis(100), + convergence_gate.wait_until_blocked(), + ) + .await + .is_err(), + "historical convergence must not enter its writer lane before core publication" + ); + database + .begin_write_transaction() + .await + .expect("foreground project-open writer is not queued behind convergence") + .rollback() + .await + .expect("foreground project-open writer probe rolls back"); + + drop(foreground); + tokio::time::timeout( + std::time::Duration::from_secs(1), + convergence_gate.wait_until_blocked(), + ) + .await + .expect("historical convergence starts after core publication"); + convergence_gate.release(); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn duplicate_project_attaches_schedule_one_historical_convergence() { let (_temporary, identity, project_id, project_root, _sessions_path, _database_scope) = From 9f87feb87600eb123d0b9a81e8a29712dce63b05 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Wed, 26 Aug 2026 06:25:03 +0000 Subject: [PATCH 06/23] fix(runtime): late-bind native integration graph --- .../src/native_integration/mod.rs | 2 +- .../src/native_integration/topology.rs | 79 ++++++++++++++----- .../native_declared_topology_projection.rs | 46 ++++++++++- src/daemon/native_integration/registry.rs | 17 ++-- 4 files changed, 114 insertions(+), 30 deletions(-) diff --git a/crates/tracedecay-usecases/src/native_integration/mod.rs b/crates/tracedecay-usecases/src/native_integration/mod.rs index 0b1758071b..6ef3660bc1 100644 --- a/crates/tracedecay-usecases/src/native_integration/mod.rs +++ b/crates/tracedecay-usecases/src/native_integration/mod.rs @@ -11,7 +11,7 @@ pub use authorization::{ }; pub use gix_adapter::GixNativeIntegrationAdapter; pub use status_broadcast::NativeIntegrationStatusBroadcastV1; -pub use topology::ExactPairNativeIntegrationTopology; +pub use topology::{ExactPairNativeIntegrationTopology, NativeIntegrationGraphRuntimeProviderV1}; pub use transaction::{ NativeApplyEffectV1, NativeIntegrationAuthorizationOutcomeV1, NativeIntegrationAuthorizationPort, NativeIntegrationMechanics, NativeIntegrationProbeV1, diff --git a/crates/tracedecay-usecases/src/native_integration/topology.rs b/crates/tracedecay-usecases/src/native_integration/topology.rs index 8a527db0e1..d3884e9792 100644 --- a/crates/tracedecay-usecases/src/native_integration/topology.rs +++ b/crates/tracedecay-usecases/src/native_integration/topology.rs @@ -54,16 +54,22 @@ pub struct ExactPairNativeIntegrationTopology { repository_id: RepositoryId, repository_root: PathBuf, repository: GitRepositoryAuthority, - graph_runtime: Option>, + graph_runtime: Option<(StoreShardIdV1, NativeIntegrationGraphRuntimeProviderV1)>, } +/// Late-bound verified graph authority for one retained native-integration +/// owner. The provider may truthfully return `None` while the graph warms; +/// callers re-observe it for every declared-stack resolution. +pub type NativeIntegrationGraphRuntimeProviderV1 = + Arc Option> + Send + Sync>; + impl ExactPairNativeIntegrationTopology { pub fn open( project_id: ProjectId, repository_id: RepositoryId, enrolled_repository_root: &Path, ) -> Result { - Self::open_with_optional_graph_runtime( + Self::open_with_optional_graph_runtime_provider( project_id, repository_id, enrolled_repository_root, @@ -78,7 +84,26 @@ impl ExactPairNativeIntegrationTopology { expected_graph_shard: StoreShardIdV1, graph_runtime: Arc, ) -> Result { - Self::open_with_optional_graph_runtime( + let retained_runtime = Arc::clone(&graph_runtime); + Self::open_with_optional_graph_runtime_provider( + project_id, + repository_id, + enrolled_repository_root, + Some(( + expected_graph_shard, + Arc::new(move || Some(Arc::clone(&retained_runtime))), + )), + ) + } + + pub fn open_with_graph_runtime_provider( + project_id: ProjectId, + repository_id: RepositoryId, + enrolled_repository_root: &Path, + expected_graph_shard: StoreShardIdV1, + graph_runtime: NativeIntegrationGraphRuntimeProviderV1, + ) -> Result { + Self::open_with_optional_graph_runtime_provider( project_id, repository_id, enrolled_repository_root, @@ -86,28 +111,19 @@ impl ExactPairNativeIntegrationTopology { ) } - fn open_with_optional_graph_runtime( + fn open_with_optional_graph_runtime_provider( project_id: ProjectId, repository_id: RepositoryId, enrolled_repository_root: &Path, - graph_runtime: Option<(StoreShardIdV1, Arc)>, + graph_runtime: Option<(StoreShardIdV1, NativeIntegrationGraphRuntimeProviderV1)>, ) -> Result { project_id.validate().map_err(domain_error)?; repository_id.validate().map_err(domain_error)?; - if let Some((expected_shard, runtime)) = &graph_runtime { - let binding = runtime.relational_binding(); - let locator = runtime.relational_verified_locator(); - let exact_project = matches!( - &expected_shard.scope, - StoreShardScopeV1::Project { project_id: bound } if bound == &project_id - ); - if !exact_project - || &binding.shard_id != expected_shard - || locator.shard_id != binding.shard_id - || locator.incarnation != binding.incarnation - { - return Err(NativeIntegrationPortError::Unavailable); - } + if let Some((expected_shard, provider)) = &graph_runtime + && let Some(runtime) = provider() + && !verified_graph_runtime_matches(&project_id, expected_shard, runtime.as_ref()) + { + return Err(NativeIntegrationPortError::Unavailable); } let repository = GitRepositoryAuthority::discover(enrolled_repository_root).map_err(native_error)?; @@ -116,7 +132,7 @@ impl ExactPairNativeIntegrationTopology { repository_id, repository_root: enrolled_repository_root.to_path_buf(), repository, - graph_runtime: graph_runtime.map(|(_, runtime)| runtime), + graph_runtime, }) } @@ -153,9 +169,15 @@ impl ExactPairNativeIntegrationTopology { request: &NativeIntegrationStackResolutionRequestV1, cancellation: &CancellationSignal, ) -> Result { - let Some(runtime) = &self.graph_runtime else { + let Some((expected_shard, provider)) = &self.graph_runtime else { + return Ok(NativeIntegrationStackResolutionOutcomeV1::Unavailable); + }; + let Some(runtime) = provider() else { return Ok(NativeIntegrationStackResolutionOutcomeV1::Unavailable); }; + if !verified_graph_runtime_matches(&self.project_id, expected_shard, runtime.as_ref()) { + return Ok(NativeIntegrationStackResolutionOutcomeV1::Unavailable); + } let NativeIntegrationSelectionBindingV1::DeclaredStackEdge { declared_revision, source_node_id, @@ -388,6 +410,21 @@ impl ExactPairNativeIntegrationTopology { } } +fn verified_graph_runtime_matches( + project_id: &ProjectId, + expected_shard: &StoreShardIdV1, + runtime: &dyn VerifiedGraphRuntimePortV1, +) -> bool { + let binding = runtime.relational_binding(); + let locator = runtime.relational_verified_locator(); + matches!( + &expected_shard.scope, + StoreShardScopeV1::Project { project_id: bound } if bound == project_id + ) && binding.shard_id == *expected_shard + && locator.shard_id == binding.shard_id + && locator.incarnation == binding.incarnation +} + fn attached_reference( authority: &GitRepositoryAuthority, ) -> Result, NativeIntegrationStackResolutionOutcomeV1> { diff --git a/crates/tracedecay-usecases/tests/native_declared_topology_projection.rs b/crates/tracedecay-usecases/tests/native_declared_topology_projection.rs index c9c68abb8c..51fbd56d6c 100644 --- a/crates/tracedecay-usecases/tests/native_declared_topology_projection.rs +++ b/crates/tracedecay-usecases/tests/native_declared_topology_projection.rs @@ -7,7 +7,7 @@ use std::collections::BTreeSet; use std::path::{Path, PathBuf}; use std::process::Command; use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::{Arc, Mutex}; +use std::sync::{Arc, Mutex, RwLock}; use tracedecay_application::{ AuthorizedRootAdmission, AuthorizedScopeSet, AuthorizedScopeSetAuthority, CancellationContext, @@ -586,6 +586,50 @@ fn declared_stack_without_registered_linked_roots_is_unavailable() { ); } +#[test] +fn declared_stack_provider_becomes_available_without_reopening_native_owner() { + let fixture = NativeGitFixture::new(); + fixture.advance_feature("late graph feature revision\n"); + let declared = declared_stack_request(&fixture, "branch-stack-revision.native.late-graph"); + let runtime = Arc::new(VerifiedSnapshotRuntime::default()); + let expected_shard = runtime.relational_binding().shard_id.clone(); + let runtime_authority = Arc::new(RwLock::new(None)); + let provider_authority = Arc::clone(&runtime_authority); + let resolver = ExactPairNativeIntegrationTopology::open_with_graph_runtime_provider( + declared.project.clone(), + declared.repository.clone(), + fixture.root(), + expected_shard, + Arc::new(move || { + provider_authority + .read() + .expect("late graph authority lock") + .clone() + }), + ) + .expect("open native owner before graph publication"); + let cancellation = CancellationSignal::active("cancel.native-declared-topology.late-graph") + .expect("cancellation"); + + assert_eq!( + resolver + .resolve(&declared.request, &cancellation) + .expect("pre-graph declared-stack resolution"), + NativeIntegrationStackResolutionOutcomeV1::Unavailable + ); + *runtime_authority + .write() + .expect("late graph authority lock") = + Some(Arc::clone(&runtime) as Arc); + + expect_declared_stack( + resolver + .resolve(&declared.request, &cancellation) + .expect("late-bound declared-stack resolution"), + &declared.revision, + ); +} + #[test] fn declared_stack_outside_the_enrolled_project_is_denied() { let fixture = NativeGitFixture::new(); diff --git a/src/daemon/native_integration/registry.rs b/src/daemon/native_integration/registry.rs index da7d2f5eb7..7923891069 100644 --- a/src/daemon/native_integration/registry.rs +++ b/src/daemon/native_integration/registry.rs @@ -32,7 +32,8 @@ use tracedecay_store::{ }; use tracedecay_usecases::native_integration::{ DaemonNativeIntegrationAuthorization, ExactPairNativeIntegrationTopology, - GixNativeIntegrationAdapter, NativeIntegrationTransactionCoordinator, + GixNativeIntegrationAdapter, NativeIntegrationGraphRuntimeProviderV1, + NativeIntegrationTransactionCoordinator, }; use tracedecay_usecases::source_authorization::ProjectSourceAccessSnapshot; use tracedecay_usecases::stack_coordinator::{ @@ -387,10 +388,6 @@ impl DaemonNativeIntegrationServiceRegistry { let scope_sets = database .authorized_scope_set_storage() .map_err(|_| NativeIntegrationPortError::Unavailable)?; - let graph_runtime = database - .project_graph_runtime() - .map(|runtime| Arc::new(runtime.clone()) as Arc) - .ok_or(NativeIntegrationPortError::Unavailable)?; let session_shard = &database.binding().shard_id; let StoreShardScopeV1::ProjectSessions { project_id: session_project, @@ -406,6 +403,12 @@ impl DaemonNativeIntegrationServiceRegistry { session_shard.profile_id.clone(), project_id.clone(), ); + let graph_database = database.clone(); + let graph_runtime: NativeIntegrationGraphRuntimeProviderV1 = Arc::new(move || { + graph_database + .project_graph_runtime() + .map(|runtime| Arc::new(runtime.clone()) as Arc) + }); self.ensure_with( database_path, repository_root, @@ -432,7 +435,7 @@ impl DaemonNativeIntegrationServiceRegistry { observed_at: UtcMicros, scope_sets: Option, expected_graph_shard: Option, - graph_runtime: Option>, + graph_runtime: Option, open_store: F, ) -> Result where @@ -477,7 +480,7 @@ impl DaemonNativeIntegrationServiceRegistry { let topology = SharedProjectNativeIntegrationTopology { inner: Arc::new(match (topology_shard, topology_runtime) { (Some(expected_shard), Some(runtime)) => { - ExactPairNativeIntegrationTopology::open_with_graph_runtime( + ExactPairNativeIntegrationTopology::open_with_graph_runtime_provider( owner_project_id.clone(), owner_repository_id.clone(), &native_root, From c721bf57fa152a9fe5d707f6743c5b750da3e481 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Wed, 26 Aug 2026 06:33:08 +0000 Subject: [PATCH 07/23] test(runtime): await background graph attachment --- src/host_admission.rs | 10 ++++---- .../verified_graph_test_support.rs | 23 +++++++++++++------ 2 files changed, 22 insertions(+), 11 deletions(-) diff --git a/src/host_admission.rs b/src/host_admission.rs index 1f7cbe2d09..2fd2cd3492 100644 --- a/src/host_admission.rs +++ b/src/host_admission.rs @@ -149,10 +149,11 @@ impl HostAdmissionTestRuntimeV1 { .session_registry .project_memory(project_id.clone(), [project_root.to_path_buf()]) .await?; - let graph_proxy = verified_graph_test_support::bound_graph_runtime( + let graph_proxy = verified_graph_test_support::await_bound_graph_runtime( &project_database, "bind sibling test runtime project graph", - )?; + ) + .await?; registered .bind_project_graph_runtime(graph_proxy) .map_err(|_| TraceDecayError::Database { @@ -234,10 +235,11 @@ impl HostAdmissionTestRuntimeV1 { let project_database = session_registry .project_memory(project_id.clone(), [project_root]) .await?; - let graph_proxy = verified_graph_test_support::bound_graph_runtime( + let graph_proxy = verified_graph_test_support::await_bound_graph_runtime( &project_database, "bind test runtime project graph", - )?; + ) + .await?; registered .bind_project_graph_runtime(graph_proxy) .map_err(|_| TraceDecayError::Database { diff --git a/src/host_admission/verified_graph_test_support.rs b/src/host_admission/verified_graph_test_support.rs index 949105b7af..f57963e83f 100644 --- a/src/host_admission/verified_graph_test_support.rs +++ b/src/host_admission/verified_graph_test_support.rs @@ -1,16 +1,25 @@ +use std::time::Duration; + use tracedecay_runtime_core::store_runtime::VerifiedGraphRuntimeWeakProxyV1; use crate::db::Database; use crate::errors::{Result, TraceDecayError}; -pub(super) fn bound_graph_runtime( +pub(super) async fn await_bound_graph_runtime( database: &Database, operation: &'static str, ) -> Result { - database - .memory_graph_runtime() - .ok_or_else(|| TraceDecayError::Database { - operation: operation.to_owned(), - message: "project memory database has no verified graph runtime".to_owned(), - }) + tokio::time::timeout(Duration::from_secs(5), async { + loop { + if let Some(runtime) = database.memory_graph_runtime() { + break runtime; + } + tokio::task::yield_now().await; + } + }) + .await + .map_err(|_| TraceDecayError::Database { + operation: operation.to_owned(), + message: "project memory database did not publish its verified graph runtime".to_owned(), + }) } From 5eaded251d40f68ad8400967e4460dbecd86ea96 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Wed, 26 Aug 2026 09:04:19 +0000 Subject: [PATCH 08/23] fix(index): keep exact tools live during graph warming --- .../src/retention/code_index_generations.rs | 12 + src/daemon/code_index_branch_diff.rs | 7 +- src/daemon/code_index_scheduler.rs | 273 +++++++++++++----- .../branch_generations.rs | 192 +++++++++--- .../code_index_scheduler/git_tree_capture.rs | 23 +- .../ignored_dependencies.rs | 2 +- .../ignored_dependencies_tests.rs | 14 +- src/daemon/code_index_scheduler/registry.rs | 49 ++-- src/daemon/code_index_scheduler/tests.rs | 115 ++++++-- .../vector_retention_tests.rs | 1 + 10 files changed, 527 insertions(+), 161 deletions(-) diff --git a/crates/tracedecay-usecases/src/retention/code_index_generations.rs b/crates/tracedecay-usecases/src/retention/code_index_generations.rs index 958dc1a8e5..6cfa8279df 100644 --- a/crates/tracedecay-usecases/src/retention/code_index_generations.rs +++ b/crates/tracedecay-usecases/src/retention/code_index_generations.rs @@ -153,6 +153,14 @@ pub struct DurableCodeTextArtifactDescriptorV1 { pub artifact_size_bytes: u64, } +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct DurableGenerationCardinalityV1 { + pub file_count: u64, + pub chunk_count: u64, + pub symbol_count: u64, +} + #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] #[serde(deny_unknown_fields)] pub struct DurableGenerationIndexEntryV1 { @@ -167,6 +175,8 @@ pub struct DurableGenerationIndexEntryV1 { pub source_revision: Option, pub source_tree: Option, #[serde(default, skip_serializing_if = "Option::is_none")] + pub cardinality: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub text_artifact: Option, } @@ -4449,6 +4459,7 @@ mod tests { source_reference: exact.then(|| format!("refs/heads/branch-{sequence}")), source_revision: exact.then(|| format!("{sequence:040x}")), source_tree: exact.then(|| format!("{:040x}", sequence + 1)), + cardinality: None, text_artifact: None, } } @@ -4642,6 +4653,7 @@ mod tests { source_reference: None, source_revision: None, source_tree: None, + cardinality: None, text_artifact: None, }; let generation_index = vec![active_entry]; diff --git a/src/daemon/code_index_branch_diff.rs b/src/daemon/code_index_branch_diff.rs index d273387083..decdc098c8 100644 --- a/src/daemon/code_index_branch_diff.rs +++ b/src/daemon/code_index_branch_diff.rs @@ -346,7 +346,7 @@ pub(super) fn code_index_branch_diff_executor( } }; let generations = match schedulers - .generations_for_revisions( + .bounded_generations_for_revisions( &scope, &request.base_reference, &request.base_revision, @@ -354,6 +354,11 @@ pub(super) fn code_index_branch_diff_executor( &request.head_reference, &request.head_revision, &request.head_tree, + code_index_scheduler::branch_generations::BranchGenerationCardinalityBoundsV1 { + maximum_files: MAX_BRANCH_DIFF_FILES_PER_GENERATION, + maximum_chunks: MAX_BRANCH_DIFF_CHUNKS_PER_GENERATION, + maximum_symbols: MAX_BRANCH_DIFF_SYMBOLS_PER_GENERATION, + }, control.clone(), ) .await diff --git a/src/daemon/code_index_scheduler.rs b/src/daemon/code_index_scheduler.rs index 370828f855..a62721e546 100644 --- a/src/daemon/code_index_scheduler.rs +++ b/src/daemon/code_index_scheduler.rs @@ -91,12 +91,13 @@ use crate::{ ports::RetrievalPortError, }, retention::code_index_generations::{ - DurableCodeTextArtifactDescriptorV1, DurableGenerationIndexEntryV1, - DurablePublicationPointerV1, DurableSealedCodeGenerationIdentityV1, - MAX_DURABLE_GENERATION_INDEX_BYTES_V1, MAX_DURABLE_GENERATION_INDEX_ENTRIES_V1, - acquire_code_generation_store_lock, attach_verified_text_artifact_under_lock, - code_text_artifact_path, code_text_artifacts_root, durable_generation_index_digest, - retain_bounded_generation_index, withdraw_verified_text_artifact_under_lock, + DurableCodeTextArtifactDescriptorV1, DurableGenerationCardinalityV1, + DurableGenerationIndexEntryV1, DurablePublicationPointerV1, + DurableSealedCodeGenerationIdentityV1, MAX_DURABLE_GENERATION_INDEX_BYTES_V1, + MAX_DURABLE_GENERATION_INDEX_ENTRIES_V1, acquire_code_generation_store_lock, + attach_verified_text_artifact_under_lock, code_text_artifact_path, + code_text_artifacts_root, durable_generation_index_digest, retain_bounded_generation_index, + withdraw_verified_text_artifact_under_lock, }, }; @@ -656,6 +657,19 @@ impl DaemonCodeIndexPublicationStoreV1 { durable_generation_index_digest(entries, truncated).map_err(Self::unavailable) } + fn generation_cardinality( + generation: &CodeIndexPublishedGenerationV1, + ) -> Result { + Ok(DurableGenerationCardinalityV1 { + file_count: u64::try_from(generation.snapshot().files.len()) + .map_err(Self::unavailable)?, + chunk_count: u64::try_from(generation.chunks().chunks().len()) + .map_err(Self::unavailable)?, + symbol_count: u64::try_from(generation.symbols().symbols.len()) + .map_err(Self::unavailable)?, + }) + } + fn validate_generation_file(value: &str) -> Result<(), CodeIndexPublicationStoreErrorV1> { let path = Path::new(value); if value.is_empty() @@ -892,9 +906,32 @@ impl DaemonCodeIndexPublicationStoreV1 { else { return Ok(None); }; + self.load_indexed_generation_shared(generation_id, entry) + } + + /// Decode one exact indexed generation under its identity-keyed barrier. + /// + /// Unlike [`Self::load_generation`], this does not join the active + /// activation barrier merely because the indexed identity is currently + /// active. Exact Git reads are immutable and independently bounded, so a + /// concurrent activation may duplicate this decode but may not make PR or + /// branch tools unavailable. An already-decoded active generation is still + /// reused immediately. + fn load_indexed_generation_shared( + &self, + generation_id: &CodeGenerationId, + entry: &DurableGenerationIndexEntryV1, + ) -> Result>, CodeIndexPublicationStoreErrorV1> { let subject = DecodeSubjectV1::Generation(generation_id.clone()); let lease = loop { let mut state = self.cache.lock_state()?; + if let Some(active) = state + .active + .as_ref() + .filter(|active| active.manifest().generation_id == *generation_id) + { + return Ok(Some(Arc::clone(active))); + } if let Some(cached) = state.cached(generation_id) { return Ok(Some(cached)); } @@ -992,6 +1029,13 @@ impl DaemonCodeIndexPublicationStoreV1 { self.cache.note_decode(); let generation = CodeIndexPublishedGenerationV1::decode_sealed(&bytes).map_err(Self::corruption)?; + if let Some(cardinality) = entry.cardinality.as_ref() + && Self::generation_cardinality(&generation)? != *cardinality + { + return Err(Self::corruption( + "durable code-generation cardinality does not match its sealed generation", + )); + } if generation.manifest().generation_id != *generation_id || generation.snapshot().content_identity.as_str() != entry.snapshot_content_identity || generation.manifest().seal.sealed_at.0 != entry.sealed_at_micros @@ -1348,6 +1392,7 @@ impl CodeIndexAtomicPublicationPort for DaemonCodeIndexPublicationStoreV1 { .as_ref() .map(|(_, revision, _)| revision.clone()), source_tree: exact_git_evidence.map(|(_, _, tree)| tree), + cardinality: Some(Self::generation_cardinality(&generation)?), text_artifact: None, }); generation_index.sort_by(|left, right| { @@ -3780,6 +3825,65 @@ pub(super) struct CodeIndexWorktreeSchedulerV1 { Option, } +/// Immutable authority for historical-generation reads and their detached +/// query derivations. +/// +/// The mounted registry retains this separately from the mutable scheduler so +/// already-sealed Git revisions remain readable while reconcile owns the +/// scheduler mutex. Its generation-local caches never replace the active +/// generation's text, progress, record-index, or graph owners. +#[derive(Clone)] +pub(super) struct HistoricalCodeIndexGenerationOwnerV1 { + publication: DaemonCodeIndexPublicationStoreV1, + store_root: PathBuf, + resident_memory: Arc, + project_id: ProjectId, + worktree_id: WorktreeId, + shutting_down: Arc, + progress_daemon_incarnation: u64, + progress_producer_incarnation: u64, +} + +impl HistoricalCodeIndexGenerationOwnerV1 { + fn bind_complete( + &self, + generation: Arc, + ) -> LatestCompleteCodeIndexV1 { + let generation_id = generation.manifest().generation_id.clone(); + let mut progress_slot = CodeIndexBuildProgressSlotStateV1::default(); + let text_progress_owner_epoch = progress_slot.replace_generation(generation_id); + let metadata = Arc::new( + VerifiedSealedTextGenerationMetadataV1::from_published_generation(&generation), + ); + LatestCompleteCodeIndexV1 { + generation, + text: LatestCodeTextGenerationV1 { + metadata, + query_owners: Arc::new(OnceLock::new()), + text_projection_build: Arc::new(Mutex::new(None)), + text_projection_failed: Arc::new(AtomicBool::new(false)), + text_control: GenerationTextControlV1::new(Arc::clone(&self.shutting_down)), + text_progress_state: Arc::new(Mutex::new(CodeIndexBuildProgressStateV1::new())), + text_progress_slot: Arc::new(RwLock::new(progress_slot)), + text_progress_owner_epoch, + text_progress_daemon_incarnation: self.progress_daemon_incarnation, + text_progress_producer_incarnation: self.progress_producer_incarnation, + text_artifact_store: DaemonCodeTextArtifactStoreV1::bind( + &self.store_root, + &self.publication, + &self.resident_memory, + &self.project_id, + &self.worktree_id, + ), + preopened_source: Arc::new(Mutex::new(None)), + publication_binding: None, + }, + record_index: Arc::new(OnceLock::new()), + graph_activation: Arc::new(RwLock::new(CodeGraphActivationStateV1::Pending)), + } + } +} + impl CodeIndexWorktreeSchedulerV1 { pub fn open( project_id: ProjectId, @@ -3918,6 +4022,19 @@ impl CodeIndexWorktreeSchedulerV1 { Arc::clone(&self.build_progress) } + pub(super) fn historical_generation_owner(&self) -> HistoricalCodeIndexGenerationOwnerV1 { + HistoricalCodeIndexGenerationOwnerV1 { + publication: self.publication.clone(), + store_root: self.store_root.clone(), + resident_memory: Arc::clone(&self.resident_memory), + project_id: self.project_id.clone(), + worktree_id: self.worktree_id.clone(), + shutting_down: Arc::clone(&self.shutting_down), + progress_daemon_incarnation: self.progress_daemon_incarnation, + progress_producer_incarnation: self.progress_producer_incarnation, + } + } + /// Reserve the installed worker plan on the canonical process authority. /// The returned RAII guard spans source capture and the complete production /// build, releasing on success, typed failure, cancellation, or unwind. @@ -4408,7 +4525,10 @@ impl CodeIndexWorktreeSchedulerV1 { /// An ignored-source roster is revalidated against the live worktree /// before seating: a tracked or retargeted admission must not become /// serving, and the scheduler must not keep that roster. - pub(super) fn servable_retained_generation(&mut self) -> Option { + pub(super) fn servable_retained_generation( + &mut self, + retained_text: Option<&LatestCodeTextGenerationV1>, + ) -> Option { if self.shutting_down.load(Ordering::Acquire) { return None; } @@ -4423,7 +4543,7 @@ impl CodeIndexWorktreeSchedulerV1 { self.ignored_source_admissions.clear(); return None; } - Some(self.bind_latest_complete(generation)) + Some(self.bind_latest_complete(generation, retained_text)) } /// Bind exact/lexical serving directly from the canonical active pointer. @@ -5065,7 +5185,7 @@ impl CodeIndexWorktreeSchedulerV1 { .ok() .flatten()?; self.validate_generation_identity(&generation).ok()?; - Some(self.bind_latest_complete(generation)) + Some(self.bind_latest_complete(generation, None)) } /// Bind one decoded generation to this scheduler's per-generation serving @@ -5073,8 +5193,11 @@ impl CodeIndexWorktreeSchedulerV1 { fn bind_latest_complete( &self, generation: Arc, + retained_text: Option<&LatestCodeTextGenerationV1>, ) -> LatestCompleteCodeIndexV1 { let generation_id = generation.manifest().generation_id.clone(); + let retained_text = + retained_text.filter(|text| text.metadata().manifest().generation_id == generation_id); let mut cached = self .query_owners .lock() @@ -5099,33 +5222,63 @@ impl CodeIndexWorktreeSchedulerV1 { progress, progress_epoch, interactive, - )) if cached_id == &generation_id => ( - Arc::clone(owners), - Arc::clone(index), - Arc::clone(build), - Arc::clone(failed), - control.clone(), - Arc::clone(progress), - *progress_epoch, - Arc::clone(interactive), - ), + )) if cached_id == &generation_id + && retained_text + .is_none_or(|text| Arc::ptr_eq(build, &text.text_projection_build)) => + { + ( + Arc::clone(owners), + Arc::clone(index), + Arc::clone(build), + Arc::clone(failed), + control.clone(), + Arc::clone(progress), + *progress_epoch, + Arc::clone(interactive), + ) + } _ => { if let Some((_, _, _, _, _, control, _, _, _)) = cached.as_ref() { control.retire(); } - let owners = Arc::new(OnceLock::new()); - let index = Arc::new(OnceLock::new()); - let build = Arc::new(Mutex::new(None)); - let failed = Arc::new(AtomicBool::new(false)); - let control = GenerationTextControlV1::new(Arc::clone(&self.shutting_down)); - let progress = Arc::new(Mutex::new(CodeIndexBuildProgressStateV1::new())); - let graph_activation = Arc::new(RwLock::new(CodeGraphActivationStateV1::Pending)); - let progress_epoch = hotpath::measure_block!("query.artifact.progress.publish", { - self.build_progress - .write() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .replace_generation(generation_id.clone()) - }); + let same_generation_cache = cached + .as_ref() + .filter(|(cached_id, ..)| cached_id == &generation_id); + let index = same_generation_cache.map_or_else( + || Arc::new(OnceLock::new()), + |(_, _, index, ..)| Arc::clone(index), + ); + let graph_activation = same_generation_cache.map_or_else( + || Arc::new(RwLock::new(CodeGraphActivationStateV1::Pending)), + |(_, _, _, _, _, _, _, _, graph_activation)| Arc::clone(graph_activation), + ); + let (owners, build, failed, control, progress, progress_epoch) = + if let Some(text) = retained_text { + ( + Arc::clone(&text.query_owners), + Arc::clone(&text.text_projection_build), + Arc::clone(&text.text_projection_failed), + text.text_control.clone(), + Arc::clone(&text.text_progress_state), + text.text_progress_owner_epoch, + ) + } else { + let progress_epoch = hotpath::measure_block!( + "query.artifact.progress.publish", + self.build_progress + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .replace_generation(generation_id.clone()) + ); + ( + Arc::new(OnceLock::new()), + Arc::new(Mutex::new(None)), + Arc::new(AtomicBool::new(false)), + GenerationTextControlV1::new(Arc::clone(&self.shutting_down)), + Arc::new(Mutex::new(CodeIndexBuildProgressStateV1::new())), + progress_epoch, + ) + }; *cached = Some(( generation_id, Arc::clone(&owners), @@ -5152,9 +5305,9 @@ impl CodeIndexWorktreeSchedulerV1 { let metadata = Arc::new( VerifiedSealedTextGenerationMetadataV1::from_published_generation(&generation), ); - LatestCompleteCodeIndexV1 { - generation, - text: LatestCodeTextGenerationV1 { + let text = retained_text + .cloned() + .unwrap_or_else(|| LatestCodeTextGenerationV1 { metadata, query_owners, text_projection_build, @@ -5174,7 +5327,10 @@ impl CodeIndexWorktreeSchedulerV1 { ), preopened_source: Arc::new(Mutex::new(None)), publication_binding: None, - }, + }); + LatestCompleteCodeIndexV1 { + generation, + text, record_index, graph_activation, } @@ -5222,50 +5378,7 @@ impl CodeIndexWorktreeSchedulerV1 { .map(|generation| { generation .filter(|generation| self.validate_generation_identity(generation).is_ok()) - .map(|generation| { - let generation_id = generation.manifest().generation_id.clone(); - let mut progress_slot = CodeIndexBuildProgressSlotStateV1::default(); - let text_progress_owner_epoch = - progress_slot.replace_generation(generation_id); - let metadata = Arc::new( - VerifiedSealedTextGenerationMetadataV1::from_published_generation( - &generation, - ), - ); - LatestCompleteCodeIndexV1 { - generation, - text: LatestCodeTextGenerationV1 { - metadata, - query_owners: Arc::new(OnceLock::new()), - text_projection_build: Arc::new(Mutex::new(None)), - text_projection_failed: Arc::new(AtomicBool::new(false)), - text_control: GenerationTextControlV1::new(Arc::clone( - &self.shutting_down, - )), - text_progress_state: Arc::new(Mutex::new( - CodeIndexBuildProgressStateV1::new(), - )), - text_progress_slot: Arc::new(RwLock::new(progress_slot)), - text_progress_owner_epoch, - text_progress_daemon_incarnation: self.progress_daemon_incarnation, - text_progress_producer_incarnation: self - .progress_producer_incarnation, - text_artifact_store: DaemonCodeTextArtifactStoreV1::bind( - &self.store_root, - &self.publication, - &self.resident_memory, - &self.project_id, - &self.worktree_id, - ), - preopened_source: Arc::new(Mutex::new(None)), - publication_binding: None, - }, - record_index: Arc::new(OnceLock::new()), - graph_activation: Arc::new(RwLock::new( - CodeGraphActivationStateV1::Pending, - )), - } - }) + .map(|generation| self.historical_generation_owner().bind_complete(generation)) }) .map_err(|error| CodeIndexProductionErrorV1::Publication(error).into()) } diff --git a/src/daemon/code_index_scheduler/branch_generations.rs b/src/daemon/code_index_scheduler/branch_generations.rs index e8a68dc39f..a509c52f86 100644 --- a/src/daemon/code_index_scheduler/branch_generations.rs +++ b/src/daemon/code_index_scheduler/branch_generations.rs @@ -1,6 +1,6 @@ //! Exact Git-revision reads over immutable sealed code-index generations. -use std::sync::{Arc, TryLockError}; +use std::sync::Arc; use tracedecay_domain::{GitOidV1, RefId}; use tracedecay_query::code_search::CodeIndexSearchUnavailableReasonV1; @@ -40,6 +40,31 @@ pub(in crate::daemon) struct BranchGenerationPairV1 { pub(in crate::daemon) head: LatestCompleteCodeIndexV1, } +#[derive(Clone, Copy)] +pub(in crate::daemon) struct BranchGenerationCardinalityBoundsV1 { + pub(in crate::daemon) maximum_files: usize, + pub(in crate::daemon) maximum_chunks: usize, + pub(in crate::daemon) maximum_symbols: usize, +} + +impl BranchGenerationCardinalityBoundsV1 { + fn admits( + self, + entry: &crate::retention::code_index_generations::DurableGenerationIndexEntryV1, + ) -> bool { + let Some(cardinality) = entry.cardinality.as_ref() else { + // Older persisted entries did not carry the authenticated summary. + // They remain readable through the ordinary decode-and-validate path. + return true; + }; + usize::try_from(cardinality.file_count).is_ok_and(|count| count <= self.maximum_files) + && usize::try_from(cardinality.chunk_count) + .is_ok_and(|count| count <= self.maximum_chunks) + && usize::try_from(cardinality.symbol_count) + .is_ok_and(|count| count <= self.maximum_symbols) + } +} + /// Which half of a requested exact revision pair the durable index could not /// serve. Carrying both sides separately is the whole point: the mint path used /// to rebuild *base* whenever anything was missing, so a request whose base was @@ -85,6 +110,7 @@ impl DaemonCodeIndexPublicationStoreV1 { head_reference: &RefId, head_revision: &GitOidV1, head_tree: &GitOidV1, + bounds: Option, control: &BranchGenerationReadControlV1, ) -> Result { if let Some(reason) = control.termination() { @@ -129,6 +155,14 @@ impl DaemonCodeIndexPublicationStoreV1 { } else { find(head_reference, head_revision, head_tree) }; + if bounds.is_some_and(|bounds| { + base_entry + .iter() + .chain(head_entry.iter()) + .any(|entry| !bounds.admits(entry)) + }) { + return Err(CodeIndexSearchUnavailableReasonV1::CapacityUnavailable); + } if let Some(reason) = control.termination() { return Err(reason); } @@ -147,7 +181,7 @@ impl DaemonCodeIndexPublicationStoreV1 { // generation back rather than failing a read the object database // can still answer. let Some(generation) = self - .load_generation(&generation_id) + .load_indexed_generation_shared(&generation_id, entry) .map_err(Self::exact_read_error)? else { return Ok(None); @@ -205,14 +239,67 @@ impl CodeIndexSchedulerRegistryV1 { head_tree: &GitOidV1, control: BranchGenerationReadControlV1, ) -> Result { - let scheduler = { + self.generations_for_revisions_with_bounds( + scope, + base_reference, + base_revision, + base_tree, + head_reference, + head_revision, + head_tree, + None, + control, + ) + .await + } + + pub(in crate::daemon) async fn bounded_generations_for_revisions( + &self, + scope: &tracedecay_application::ResolvedScope, + base_reference: &RefId, + base_revision: &GitOidV1, + base_tree: &GitOidV1, + head_reference: &RefId, + head_revision: &GitOidV1, + head_tree: &GitOidV1, + bounds: BranchGenerationCardinalityBoundsV1, + control: BranchGenerationReadControlV1, + ) -> Result { + self.generations_for_revisions_with_bounds( + scope, + base_reference, + base_revision, + base_tree, + head_reference, + head_revision, + head_tree, + Some(bounds), + control, + ) + .await + } + + async fn generations_for_revisions_with_bounds( + &self, + scope: &tracedecay_application::ResolvedScope, + base_reference: &RefId, + base_revision: &GitOidV1, + base_tree: &GitOidV1, + head_reference: &RefId, + head_revision: &GitOidV1, + head_tree: &GitOidV1, + bounds: Option, + control: BranchGenerationReadControlV1, + ) -> Result { + let (scheduler, historical_generation_owner) = { let mounted = self.mounted.lock().await; - Arc::clone( - &unique_mounted_for_scope(&mounted, scope) - .unique() - .ok_or(CodeIndexSearchUnavailableReasonV1::GenerationUnavailable)? - .1 - .scheduler, + let worktree = unique_mounted_for_scope(&mounted, scope) + .unique() + .ok_or(CodeIndexSearchUnavailableReasonV1::GenerationUnavailable)? + .1; + ( + Arc::clone(&worktree.scheduler), + worktree.historical_generation_owner.clone(), ) }; let base_reference = base_reference.clone(); @@ -224,15 +311,6 @@ impl CodeIndexSchedulerRegistryV1 { let scope = scope.clone(); let terminal_control = control.clone(); let task = tokio::task::spawn_blocking(move || { - let mut scheduler = match scheduler.try_lock() { - Ok(scheduler) => scheduler, - Err(TryLockError::WouldBlock) => { - return Err(CodeIndexSearchUnavailableReasonV1::CapacityUnavailable); - } - Err(TryLockError::Poisoned(_)) => { - return Err(CodeIndexSearchUnavailableReasonV1::Internal); - } - }; let exact_source = |reference: &RefId, revision: &GitOidV1, tree: &GitOidV1| { Ok::<_, CodeIndexSearchUnavailableReasonV1>( super::git_tree_capture::ExactGitTreeSourceV1 { @@ -247,13 +325,14 @@ impl CodeIndexSchedulerRegistryV1 { let same_revision = base_reference == head_reference && base_revision == head_revision && base_tree == head_tree; - let revisions = scheduler.publication.revisions( + let revisions = historical_generation_owner.publication.revisions( &base_reference, &base_revision, &base_tree, &head_reference, &head_revision, &head_tree, + bounds, &control, )?; let (base, head) = match revisions { @@ -266,6 +345,15 @@ impl CodeIndexSchedulerRegistryV1 { // working repository could no longer capture into a hard failure // of a read the index could have answered. ExactGenerationPairV1::Missing(missing) => { + let mut scheduler = match scheduler.try_lock() { + Ok(scheduler) => scheduler, + Err(std::sync::TryLockError::WouldBlock) => { + return Err(CodeIndexSearchUnavailableReasonV1::CapacityUnavailable); + } + Err(std::sync::TryLockError::Poisoned(_)) => { + return Err(CodeIndexSearchUnavailableReasonV1::Internal); + } + }; if missing.base { scheduler.publish_exact_git_tree_generation( &exact_source(&base_reference, &base_revision, &base_tree)?, @@ -278,13 +366,15 @@ impl CodeIndexSchedulerRegistryV1 { &control, )?; } - match scheduler.publication.revisions( + drop(scheduler); + match historical_generation_owner.publication.revisions( &base_reference, &base_revision, &base_tree, &head_reference, &head_revision, &head_tree, + bounds, &control, )? { ExactGenerationPairV1::Sealed(base, head) => (base, head), @@ -296,8 +386,8 @@ impl CodeIndexSchedulerRegistryV1 { } } }; - let base = scheduler.bind_latest_complete(base); - let head = scheduler.bind_latest_complete(head); + let base = historical_generation_owner.bind_complete(base); + let head = historical_generation_owner.bind_complete(head); if !super::registry::latest_matches_scope_identity(&base, &scope) || !super::registry::latest_matches_scope_identity(&head, &scope) { @@ -418,6 +508,7 @@ mod tests { let large_tree = GitOidV1::new(git(project.path(), &["rev-parse", "HEAD^{tree}"])).expect("large tree"); scheduler.reconcile_now().expect("publish large generation"); + let large_generation = scheduler.latest_complete().expect("large generation"); drop(scheduler); let generations_root = scoped_store.join("code-generations-v1"); for index in 0..512 { @@ -556,20 +647,51 @@ mod tests { && base.content_digest != head.content_digest )); - let large_pair = settled_pair( - ®istry, - &scope, - (&reference, &large_revision, &large_tree), - (&reference, &large_revision, &large_tree), - &control, + // A sealed exact generation is immutable publication evidence, not + // activation-owned state. Prove the read remains available while the + // active-generation decode barrier is occupied by background warming. + let scheduler = registry + .scheduler_handle(&canonical_project) + .await + .expect("mounted scheduler handle"); + let active_decode = scheduler + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .hold_active_decode(); + let admission_started = std::time::Instant::now(); + let large_admission = tokio::time::timeout( read_timeout, + registry.bounded_generations_for_revisions( + &scope, + &reference, + &large_revision, + &large_tree, + &reference, + &large_revision, + &large_tree, + BranchGenerationCardinalityBoundsV1 { + maximum_files: 1_024, + maximum_chunks: 4_096, + maximum_symbols: 1_024, + }, + control.clone(), + ), ) .await - .expect("large exact generation"); + .expect("bounded exact-generation admission"); + assert!(matches!( + large_admission, + Err(CodeIndexSearchUnavailableReasonV1::CapacityUnavailable) + )); + assert!( + admission_started.elapsed() < std::time::Duration::from_secs(1), + "authenticated cardinality admission must not read sealed bytes" + ); + drop(active_decode); let started = std::time::Instant::now(); let outcome = bounded_diff( - large_pair.base.generation(), - large_pair.head.generation(), + large_generation.generation(), + large_generation.generation(), None, None, &control, @@ -590,8 +712,8 @@ mod tests { cancellation.cancel(tracedecay_application::clock::now_micros()); assert_eq!( bounded_diff( - large_pair.base.generation(), - large_pair.head.generation(), + large_generation.generation(), + large_generation.generation(), None, None, &BranchGenerationReadControlV1 { @@ -606,8 +728,8 @@ mod tests { .expect("expired deadline"); assert_eq!( bounded_diff( - large_pair.base.generation(), - large_pair.head.generation(), + large_generation.generation(), + large_generation.generation(), None, None, &BranchGenerationReadControlV1 { diff --git a/src/daemon/code_index_scheduler/git_tree_capture.rs b/src/daemon/code_index_scheduler/git_tree_capture.rs index b872ea78b8..3ebd0f8946 100644 --- a/src/daemon/code_index_scheduler/git_tree_capture.rs +++ b/src/daemon/code_index_scheduler/git_tree_capture.rs @@ -533,6 +533,7 @@ impl CodeIndexWorktreeSchedulerV1 { retained_bytes: _retained_bytes, retained_reservations: _retained_reservations, } = captured; + let requested_scope = CodeIndexGenerationScopeV1::for_snapshot(&snapshot); // Retained-history generations live inside the active publication // pointer, so they need an active generation to ride on. A store with // no publication at all has no such anchor — there the mint itself @@ -543,7 +544,25 @@ impl CodeIndexWorktreeSchedulerV1 { .load_active_shared() .map_err(DaemonCodeIndexPublicationStoreV1::exact_read_error)? { - Some(_) => self.publication.retained_history(), + Some(active) if active.sealed_scope() == requested_scope => { + self.publication.retained_history() + } + Some(_) => { + // A retained generation for another ref/worktree cannot adopt + // the active generation as its incremental parent. Preserve + // that active pointer under the existing exact CAS authority, + // but make the production owner build the requested scope from + // its immutable Git tree instead of reporting the foreign + // active slot as corruption. + let pointer = self + .publication + .read_publication_pointer() + .map_err(DaemonCodeIndexPublicationStoreV1::exact_read_error)? + .ok_or(CodeIndexSearchUnavailableReasonV1::GenerationUnavailable)?; + self.publication + .for_undecoded_active_rebuild(&pointer) + .retained_history() + } None => self.publication.clone(), }; let mut owner = open_production_code_index_owner_v1( @@ -580,7 +599,7 @@ impl CodeIndexWorktreeSchedulerV1 { } _ => CodeIndexSearchUnavailableReasonV1::Internal, })?; - Ok(self.bind_latest_complete(generation)) + Ok(self.bind_latest_complete(generation, None)) } } diff --git a/src/daemon/code_index_scheduler/ignored_dependencies.rs b/src/daemon/code_index_scheduler/ignored_dependencies.rs index df5005c271..27101a375d 100644 --- a/src/daemon/code_index_scheduler/ignored_dependencies.rs +++ b/src/daemon/code_index_scheduler/ignored_dependencies.rs @@ -259,7 +259,7 @@ impl CodeIndexWorktreeSchedulerV1 { self.latest_content_identity = Some(generation.snapshot().content_identity.clone()); let sampled_signature = self.worktree_stat_signature().ok(); self.mark_reconciled(sampled_metadata, sampled_signature); - let latest = self.bind_latest_complete(Arc::clone(&generation)); + let latest = self.bind_latest_complete(Arc::clone(&generation), None); let publication = publication_evidence(reextracted_files, &generation)?; Ok(CodeIndexIgnoredDependencyBuildV1 { outcome: CodeIndexIgnoredDependencyIndexOutcomeV1 { diff --git a/src/daemon/code_index_scheduler/ignored_dependencies_tests.rs b/src/daemon/code_index_scheduler/ignored_dependencies_tests.rs index 4f66b93013..438b9bec44 100644 --- a/src/daemon/code_index_scheduler/ignored_dependencies_tests.rs +++ b/src/daemon/code_index_scheduler/ignored_dependencies_tests.rs @@ -177,10 +177,16 @@ async fn latest( registry: &CodeIndexSchedulerRegistryV1, project_root: &Path, ) -> LatestCompleteCodeIndexV1 { - registry - .latest_complete_fresh(project_root) - .await - .expect("fresh serving generation") + tokio::time::timeout(Duration::from_secs(5), async { + loop { + if let Some(latest) = registry.latest_complete_fresh(project_root).await { + break latest; + } + tokio::task::yield_now().await; + } + }) + .await + .expect("fresh serving generation") } async fn wait_for_reconciling(registry: &CodeIndexSchedulerRegistryV1, expected: u64) { diff --git a/src/daemon/code_index_scheduler/registry.rs b/src/daemon/code_index_scheduler/registry.rs index 43b49847e6..4590eaff4a 100644 --- a/src/daemon/code_index_scheduler/registry.rs +++ b/src/daemon/code_index_scheduler/registry.rs @@ -360,6 +360,7 @@ pub(super) struct MountedCodeIndexWorktreeV1 { pub(super) semantic_vector_graph_provider: Option>, pub(super) scheduler: Arc>, + pub(super) historical_generation_owner: super::HistoricalCodeIndexGenerationOwnerV1, pub(super) serving_generation: Arc>>, pub(super) text_generation: Arc>>, /// Immutable progress snapshot independently readable while the scheduler @@ -2287,6 +2288,7 @@ impl CodeIndexSchedulerRegistryV1 { let reconcile_in_progress = opened.reconcile_in_progress(); let active_generation_encoded_bytes = opened.active_generation_encoded_bytes(); let build_progress = opened.build_progress_slot(); + let historical_generation_owner = opened.historical_generation_owner(); // Cold mount publishes only the exact route. The worker may seat a // complete identity-valid generation as stale serving before refresh // claims freshness; missing Git authority still leaves this empty. @@ -2507,6 +2509,11 @@ impl CodeIndexSchedulerRegistryV1 { continue; } } + let text_serving_ready = worker_text_generation + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .as_ref() + .is_some_and(LatestCodeTextGenerationV1::text_serving_is_ready); // Admission is held: queue wait ends and service time begins. let started_micros = now_micros().0; let (arrival, trigger) = Self::take_pending_arrival( @@ -2519,17 +2526,23 @@ impl CodeIndexSchedulerRegistryV1 { // of reconcile. Stale is truthful; do not mark_reconciled. let mut seat_retry_pending = false; if graph_activation_enabled + && text_serving_ready && serving_generation .read() .unwrap_or_else(std::sync::PoisonError::into_inner) .is_none() { let remount_scheduler = Arc::clone(&scheduler); + let remount_text = worker_text_generation + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clone(); let remount = tokio::task::spawn_blocking(move || { let mut scheduler = remount_scheduler .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); - let retained = scheduler.servable_retained_generation()?; + let retained = + scheduler.servable_retained_generation(remount_text.as_ref())?; let replay_binding = scheduler.code_graph_replay_binding( &retained.generation().manifest().generation_id, ); @@ -2627,31 +2640,32 @@ impl CodeIndexSchedulerRegistryV1 { } continue; } - let retained_text_metadata = (!graph_activation_enabled).then(|| { - worker_text_generation - .read() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .as_ref() - .map(|text| text.metadata().clone()) - }); + let retained_text_metadata = worker_text_generation + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .as_ref() + .map(|text| text.metadata().clone()); let mut result = tokio::task::spawn_blocking(move || { let mut scheduler = scheduler .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); - let mut result = if graph_activation_enabled { - scheduler.activate_or_reconcile() - } else if let Some(metadata) = retained_text_metadata.flatten() { + let mut result = if let Some(metadata) = retained_text_metadata { match scheduler.reconcile_retained_text_generation(&metadata) { Ok(Some(outcome)) => Ok(outcome), + Ok(None) if graph_activation_enabled => { + scheduler.activate_or_reconcile() + } Ok(None) => scheduler.reconcile_now(), Err(error) => Err(error), } + } else if graph_activation_enabled { + scheduler.activate_or_reconcile() } else { scheduler.reconcile_now() }; // A terminal outcome may publish a newer complete generation; // swap serving to that after graph activation below. - let mut latest = graph_activation_enabled + let mut latest = (graph_activation_enabled && text_serving_ready) .then(|| { result .as_ref() @@ -2675,12 +2689,10 @@ impl CodeIndexSchedulerRegistryV1 { (result, latest, replay_binding) }) .await; - if !graph_activation_enabled - && matches!( - &result, - Ok((Ok(CodeIndexReconcileOutcomeV1::Published(_)), None, None)) - ) - { + if matches!( + &result, + Ok((Ok(CodeIndexReconcileOutcomeV1::Published(_)), None, None)) + ) { // Publication moved the durable pointer, so the prior text // owner is no longer authoritative even while the new // lightweight handle is opening. Withdraw it first: a @@ -2899,6 +2911,7 @@ impl CodeIndexSchedulerRegistryV1 { query_activation_redundancy: None, semantic_vector_graph_provider: None, scheduler, + historical_generation_owner, serving_generation, text_generation, build_progress, diff --git a/src/daemon/code_index_scheduler/tests.rs b/src/daemon/code_index_scheduler/tests.rs index 0a4e6506b4..77fffbb1c7 100644 --- a/src/daemon/code_index_scheduler/tests.rs +++ b/src/daemon/code_index_scheduler/tests.rs @@ -9440,9 +9440,9 @@ async fn witness_verified_mount_activates_without_rebuild() { registry.shutdown().await; } -/// A retained text generation remains serving when persistent graph replay -/// fails. The full graph owner stays absent while ordinary refresh remains -/// pending, so exact/lexical availability never implies graph availability. +/// A retained text generation reaches exact/lexical readiness when persistent +/// graph replay is permanently refused. The full graph owner stays absent, so +/// text availability never implies graph availability. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn failed_cold_mount_graph_replay_preserves_retained_text_generation() { let fixture = GitFixture::new(ALPHA_LIB_V1); @@ -9549,20 +9549,19 @@ async fn failed_cold_mount_graph_replay_preserves_retained_text_generation() { } let deadline = std::time::Instant::now() + Duration::from_secs(5); - loop { - if registry - .pending_wake_micros_for_scope(&scope) - .await - .is_some_and(|pending| pending != 0) + let text = loop { + if let Some(text) = registry.latest_text_serving_for_scope(&scope).await + && text.query_owners_are_warm() { - break; + break text; } assert!( std::time::Instant::now() <= deadline, - "failed graph replay did not restore its pending retry arrival" + "permanently refused graph replay withheld exact and lexical readiness" ); tokio::time::sleep(Duration::from_millis(10)).await; - } + }; + assert!(text.production_query_owners().is_ok()); assert_eq!( registry.latest_generation_id(fixture.path()).await, @@ -10993,7 +10992,19 @@ async fn same_root_remount_updates_retained_graph_policy_before_worker_activatio /// waking the worker. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn retryable_activation_failure_retries_the_sealed_generation_without_resealing() { - let fixture = GitFixture::new(ALPHA_LIB_V1); + let sources = (0..512) + .map(|index| { + ( + format!("src/file_{index:04}.rs"), + format!("pub fn alpha_{index:04}() -> usize {{ {index} }}\n"), + ) + }) + .collect::>(); + let source_refs = sources + .iter() + .map(|(path, source)| (path.as_str(), source.as_str())) + .collect::>(); + let fixture = GitFixture::new(&source_refs); let store = TempDir::new().expect("store root"); let bytes = Arc::new(SharedCodeIndexBytePoolV1::default()); let scoped_store = super::scoped_code_index_store_root( @@ -11048,6 +11059,37 @@ async fn retryable_activation_failure_retries_the_sealed_generation_without_rese .await .expect("mount retained generation"); + let scheduler = registry + .scheduler_handle(fixture.path()) + .await + .expect("mounted scheduler"); + let progress_deadline = std::time::Instant::now() + Duration::from_secs(10); + let (text_owner_before_retry, owner_epoch_before_retry, progress_before_retry) = loop { + let text = registry.latest_text_serving_for_scope(&scope).await; + let observed = { + let scheduler = scheduler + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let progress = scheduler.build_progress_slot(); + let progress = progress + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner); + (progress.owner_epoch, progress.snapshot()) + }; + if let (Some(text), (owner_epoch, Some(progress))) = (text, observed) + && progress.committed_pages > 0 + && progress.phase + != crate::dashboard::code_index_freshness_api::CodeIndexBuildPhaseV1::Ready + { + break (text, owner_epoch, progress); + } + assert!( + std::time::Instant::now() <= progress_deadline, + "graph-on text projection never exposed bounded live progress" + ); + tokio::task::yield_now().await; + }; + // Keep waking the worker while activation stays failing; each pass must // hold the sealed artifact instead of rebuilding. for _ in 0..5 { @@ -11059,20 +11101,53 @@ async fn retryable_activation_failure_retries_the_sealed_generation_without_rese 1, "a retryable activation failure must not seal a duplicate generation" ); - let text_deadline = std::time::Instant::now() + Duration::from_secs(5); - loop { - if registry - .latest_text_serving_for_scope(&scope) - .await - .is_some() + let text_deadline = std::time::Instant::now() + Duration::from_secs(10); + let text_owner_after_retry = loop { + if let Some(text) = registry.latest_text_serving_for_scope(&scope).await + && text.query_owners_are_warm() { - break; + break text; } assert!( std::time::Instant::now() <= text_deadline, - "graph retry backoff withheld the retained text generation" + "graph retry backoff withheld exact and lexical readiness" ); tokio::time::sleep(Duration::from_millis(10)).await; + }; + assert!( + text_owner_after_retry.same_text_owner(&text_owner_before_retry), + "graph retry must preserve the lightweight text owner" + ); + { + let scheduler = scheduler + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let progress = scheduler.build_progress_slot(); + let progress = progress + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner); + assert_eq!( + progress.owner_epoch, owner_epoch_before_retry, + "graph activation must not replace the text progress authority" + ); + let progress = progress + .snapshot() + .expect("ready text progress remains visible during graph retry"); + assert_eq!(progress.generation_id, progress_before_retry.generation_id); + assert_eq!( + progress.daemon_incarnation, + progress_before_retry.daemon_incarnation + ); + assert_eq!( + progress.producer_incarnation, + progress_before_retry.producer_incarnation + ); + assert!(progress.progress_epoch > progress_before_retry.progress_epoch); + assert!(progress.committed_pages >= progress_before_retry.committed_pages); + assert_eq!( + progress.phase, + crate::dashboard::code_index_freshness_api::CodeIndexBuildPhaseV1::Ready + ); } assert_eq!( registry.latest_generation_id(fixture.path()).await, diff --git a/src/daemon/git_watch/store_maintenance/vector_retention_tests.rs b/src/daemon/git_watch/store_maintenance/vector_retention_tests.rs index 919b35e41c..9dac08e5af 100644 --- a/src/daemon/git_watch/store_maintenance/vector_retention_tests.rs +++ b/src/daemon/git_watch/store_maintenance/vector_retention_tests.rs @@ -109,6 +109,7 @@ fn seed_sealed_generation_store(store_root: &Path, count: usize) { source_reference: None, source_revision: None, source_tree: None, + cardinality: None, text_artifact: None, }]; let generation_index_digest = From bd1de98962ccf02a57c72f6b9daddf71947ff49b Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Wed, 26 Aug 2026 10:02:08 +0000 Subject: [PATCH 09/23] fix(index): verify stale text witness without decode --- src/daemon/code_index_scheduler.rs | 23 +++++------ src/daemon/code_index_scheduler/tests.rs | 52 ++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 12 deletions(-) diff --git a/src/daemon/code_index_scheduler.rs b/src/daemon/code_index_scheduler.rs index a62721e546..421a869b85 100644 --- a/src/daemon/code_index_scheduler.rs +++ b/src/daemon/code_index_scheduler.rs @@ -4350,20 +4350,19 @@ impl CodeIndexWorktreeSchedulerV1 { hints.overflow || !hints.paths.is_empty() }; if !has_hints && let Some(witness) = witness.as_ref() { - if witness.git_metadata_signature != sampled_metadata.stable_signature() - || witness.stat_signature != sampled_signature + if witness.git_metadata_signature == sampled_metadata.stable_signature() + && witness.stat_signature == sampled_signature { - return Ok(None); + let snapshot_content_identity = metadata.snapshot().content_identity.clone(); + self.latest_content_identity = Some(snapshot_content_identity.clone()); + self.mark_reconciled_state(sampled_metadata, Some(sampled_signature)); + return Ok(Some(CodeIndexReconcileOutcomeV1::Noop( + CodeIndexNoopEvidenceV1 { + snapshot_content_identity, + overflow_reconciled: false, + }, + ))); } - let snapshot_content_identity = metadata.snapshot().content_identity.clone(); - self.latest_content_identity = Some(snapshot_content_identity.clone()); - self.mark_reconciled_state(sampled_metadata, Some(sampled_signature)); - return Ok(Some(CodeIndexReconcileOutcomeV1::Noop( - CodeIndexNoopEvidenceV1 { - snapshot_content_identity, - overflow_reconciled: false, - }, - ))); } let _worker_memory = self.reserve_worker_memory()?; diff --git a/src/daemon/code_index_scheduler/tests.rs b/src/daemon/code_index_scheduler/tests.rs index 77fffbb1c7..1d8b76ebd3 100644 --- a/src/daemon/code_index_scheduler/tests.rs +++ b/src/daemon/code_index_scheduler/tests.rs @@ -10362,6 +10362,58 @@ async fn graph_off_overflow_preserves_text_owner_progress_without_full_decode() registry.shutdown().await; } +/// A benign Git metadata rewrite after a clean graph-off seal must trigger one +/// authoritative text-only capture, not fall through to the graph-bearing +/// reconcile path. The captured source identity is unchanged, so the retained +/// generation becomes current without decoding its full sealed payload. +#[test] +fn graph_off_stale_witness_reconciles_unchanged_source_without_full_decode() { + let fixture = GitFixture::new(ALPHA_LIB_V1); + let store = TempDir::new().expect("store root"); + let bytes = Arc::new(SharedCodeIndexBytePoolV1::default()); + let seeded = { + let mut scheduler = scheduler(&fixture, store.path().to_path_buf(), Arc::clone(&bytes)); + published(scheduler.reconcile_now().expect("seed retained generation")) + }; + let index_path = fixture.path().join(".git/index"); + let index_mtime = std::fs::metadata(&index_path) + .expect("git index metadata") + .modified() + .expect("git index mtime"); + filetime::set_file_mtime( + &index_path, + filetime::FileTime::from_system_time(index_mtime + Duration::from_secs(2)), + ) + .expect("advance only the git index mtime"); + + let mut reopened = scheduler(&fixture, store.path().to_path_buf(), bytes); + let metadata = reopened + .servable_retained_text_generation() + .expect("authenticated retained text generation") + .metadata() + .clone(); + let outcome = reopened + .reconcile_retained_text_generation(&metadata) + .expect("graph-off retained reconcile") + .expect("stale witness must be verified by text-only capture"); + let CodeIndexReconcileOutcomeV1::Noop(evidence) = outcome else { + panic!("metadata-only Git change must not publish a generation"); + }; + assert_eq!( + evidence.snapshot_content_identity, seeded.snapshot_content_identity, + "authoritative capture proves the sealed source identity is unchanged" + ); + assert_eq!( + reopened.sealed_decode_count(), + 0, + "graph-off freshness verification must not decode the full generation" + ); + assert!( + reopened.verified_against_source(), + "successful text-only capture establishes current source truth" + ); +} + /// A graph-off changed-source rebuild must use the same canonical worker-memory /// admission as the complete reconcile path. A denial occurs before capture, /// leaves the durable pointer and hint authority intact, and releases its RAII From d50df9c77fc8be8f0fe29e7327b7bfeb5a0bc472 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Wed, 26 Aug 2026 11:22:32 +0000 Subject: [PATCH 10/23] fix(index): suppress quiet Git backstop captures --- src/daemon/code_index_scheduler.rs | 14 ++- .../registry/watch_ingress.rs | 68 +++++++---- src/daemon/code_index_scheduler/tests.rs | 110 ++++++++++++++++++ src/daemon/git_watch.rs | 6 +- src/daemon/git_watch/overflow.rs | 2 +- 5 files changed, 172 insertions(+), 28 deletions(-) diff --git a/src/daemon/code_index_scheduler.rs b/src/daemon/code_index_scheduler.rs index 421a869b85..78f6ad9306 100644 --- a/src/daemon/code_index_scheduler.rs +++ b/src/daemon/code_index_scheduler.rs @@ -5100,12 +5100,15 @@ impl CodeIndexWorktreeSchedulerV1 { /// must answer `false` and wake nothing: the ladder suppressing work is the /// common case, and waking the worker on every read would turn each query /// into a rebuild trigger — exactly the coupling this change removes. - pub(super) fn request_fresh_for_query_background(&mut self) -> bool { + /// Decide whether the cheap Git/stat ladder requires an authoritative + /// reconcile, without posting a worker wake. Callers that own a separate + /// cadence authority use this split form so they can record the arrival + /// before making the worker runnable. + pub(super) fn freshness_probe_requires_reconcile(&mut self) -> bool { if !self.verified_against_source || identity::GitMetadataFingerprintV1::capture(&self.project_root) .differs_from(&self.git_metadata) { - self.request_background_reconcile(); return true; } if self.last_reconciled_at.elapsed() < self.policy.staleness_threshold { @@ -5119,6 +5122,13 @@ impl CodeIndexWorktreeSchedulerV1 { self.last_reconciled_at_micros = Some(now_micros().0); return false; } + true + } + + pub(super) fn request_fresh_for_query_background(&mut self) -> bool { + if !self.freshness_probe_requires_reconcile() { + return false; + } self.request_background_reconcile(); true } diff --git a/src/daemon/code_index_scheduler/registry/watch_ingress.rs b/src/daemon/code_index_scheduler/registry/watch_ingress.rs index 0369753add..b789a7dbde 100644 --- a/src/daemon/code_index_scheduler/registry/watch_ingress.rs +++ b/src/daemon/code_index_scheduler/registry/watch_ingress.rs @@ -1,35 +1,53 @@ //! Watcher → scheduler freshness ingress. //! //! The git-metadata watcher routes repository frontiers into a mounted -//! worktree scheduler here. The route is deliberately synchronous: watchers -//! cannot await the async registry map without risking a feedback loop with -//! mount/shutdown, so contention is surfaced as a typed `Busy` for the bounded -//! watcher owner to retry rather than silently dropping the frontier. +//! worktree scheduler here. The Git/stat probe runs on the blocking pool, while +//! the registry and scheduler are both entered through non-waiting locks so a +//! watcher cannot deadlock with mount or shutdown. Contention and worker loss +//! remain distinct typed retry states. use tracedecay_runtime_core::git_discovery::GitRepositoryIdentity; -use super::super::{CodeIndexCadenceTriggerV1, DaemonCodeIndexControlV1}; +use super::super::CodeIndexCadenceTriggerV1; use super::CodeIndexSchedulerRegistryV1; -/// Synchronous result of routing one watcher frontier into a mounted scheduler. +/// Result of routing one watcher frontier into a mounted scheduler. /// /// Watchers cannot await the registry map without risking a feedback loop with /// mount/shutdown. `Busy` is therefore explicit and retryable by the bounded -/// watcher owner rather than silently dropping the frontier. +/// watcher owner rather than silently dropping the frontier. A blocking-worker +/// failure is distinct so it cannot masquerade as ordinary lock contention. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(in crate::daemon) enum GitStateChangeRequestV1 { Accepted, Unmounted, Busy, + WorkerUnavailable, IdentityMismatch, } impl CodeIndexSchedulerRegistryV1 { - /// Route a watcher wake without blocking the watcher thread on the async - /// registry map. Structural identity is checked before the wake can enter - /// the scheduler's coalescing slot. The scheduler derives the exact git - /// frontier through its canonical gix reconciliation. - pub(in crate::daemon) fn request_for_root( + /// Route a watcher freshness probe without blocking the watcher thread on + /// the async registry map. Structural identity is checked before the probe + /// can enter the scheduler. A quiet backstop tick must not fabricate source + /// mutation evidence: the scheduler's cheap Git/stat ladder suppresses it, + /// while real drift records one coalesced worker wake. Contention remains a + /// typed retry so the watcher never queues behind a capture. + pub(in crate::daemon) async fn request_for_root( + &self, + identity: &GitRepositoryIdentity, + ) -> GitStateChangeRequestV1 { + let registry = self.clone(); + let identity = identity.clone(); + match tokio::task::spawn_blocking(move || registry.request_for_root_blocking(&identity)) + .await + { + Ok(request) => request, + Err(_) => GitStateChangeRequestV1::WorkerUnavailable, + } + } + + fn request_for_root_blocking( &self, identity: &GitRepositoryIdentity, ) -> GitStateChangeRequestV1 { @@ -51,17 +69,21 @@ impl CodeIndexSchedulerRegistryV1 { if worktree.repository_id != repository_id || worktree.worktree_id != worktree_id { return GitStateChangeRequestV1::IdentityMismatch; } - worktree - .hints - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .overflow(); - DaemonCodeIndexControlV1::advance(&worktree.epoch); - Self::note_wake( - &worktree.pending_wake, - &worktree.wake, - CodeIndexCadenceTriggerV1::GitWatcher, - ); + let scheduler = std::sync::Arc::clone(&worktree.scheduler); + let wake = std::sync::Arc::clone(&worktree.wake); + let pending_wake = std::sync::Arc::clone(&worktree.pending_wake); + drop(mounted); + let mut scheduler = match scheduler.try_lock() { + Ok(scheduler) => scheduler, + Err(std::sync::TryLockError::Poisoned(error)) => error.into_inner(), + Err(std::sync::TryLockError::WouldBlock) => return GitStateChangeRequestV1::Busy, + }; + if !scheduler.freshness_probe_requires_reconcile() { + return GitStateChangeRequestV1::Accepted; + } + Self::note_wake(&pending_wake, &wake, CodeIndexCadenceTriggerV1::GitWatcher); + scheduler.request_background_reconcile(); + drop(scheduler); GitStateChangeRequestV1::Accepted } } diff --git a/src/daemon/code_index_scheduler/tests.rs b/src/daemon/code_index_scheduler/tests.rs index 1d8b76ebd3..34e6b6d026 100644 --- a/src/daemon/code_index_scheduler/tests.rs +++ b/src/daemon/code_index_scheduler/tests.rs @@ -4613,6 +4613,116 @@ async fn mounted_core_query_worktree_with_one_permit( .await } +/// A healthy Git-watcher backstop is a freshness probe, not evidence that the +/// checkout changed. When the exact stat signature still matches, routing that +/// probe must leave both the source-hint authority and worker queue empty. +#[tokio::test] +async fn unchanged_git_watcher_probe_does_not_enqueue_authoritative_capture() { + let fixture = GitFixture::new(&[("src/main.rs", "fn main() {}\n")]); + let store = TempDir::new().expect("store root"); + let scoped_store = super::scoped_code_index_store_root(store.path(), fixture.path()); + let scope = { + let mut scheduler = scheduler( + &fixture, + scoped_store, + Arc::new(SharedCodeIndexBytePoolV1::default()), + ); + published(scheduler.reconcile_now().expect("seed retained generation")); + let latest = scheduler.latest_complete().expect("seeded generation"); + let snapshot = latest.generation.snapshot(); + ResolvedScope::new( + test_project_id(), + snapshot.repository.clone(), + snapshot.worktree.clone().expect("worktree id"), + snapshot.reference.clone(), + ) + .expect("resolved scope") + }; + let registry = CodeIndexSchedulerRegistryV1::with_background_reconcile_permits(1, 1); + registry + .mount_worktree_with_graph_policy( + test_project_id(), + fixture.path(), + store.path().to_path_buf(), + None, + super::CodeGraphActivationPolicyV1::RefusedByConfiguration, + ) + .await + .expect("mount graph-off retained generation"); + + let canonical_root = fixture.path().canonicalize().expect("canonical fixture"); + let scheduler = { + let mounted = registry.mounted.lock().await; + Arc::clone( + &mounted + .get(&canonical_root) + .expect("mounted worktree") + .scheduler, + ) + }; + let settled_deadline = Instant::now() + Duration::from_secs(10); + loop { + let settled = { + let scheduler = scheduler + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + scheduler.verified_against_source() && scheduler.pending_hint_count() == Some(0) + }; + if settled + && !registry + .reconcile_in_progress_for_test(fixture.path()) + .await + { + break; + } + assert!( + Instant::now() <= settled_deadline, + "retained graph-off owner never established initial freshness" + ); + tokio::task::yield_now().await; + } + let admission = registry + .background_reconcile_admission() + .acquire_owned() + .await + .expect("hold background reconcile admission"); + registry.clear_pending_wake_for_scope(&scope).await; + assert_eq!( + scheduler + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .pending_hint_count(), + Some(0), + "settled fixture starts without source-change evidence" + ); + + let identity = tracedecay_runtime_core::git_discovery::GitRepositoryIdentity { + worktree_root: canonical_root.clone(), + git_dir: canonical_root.join(".git"), + common_dir: canonical_root.join(".git"), + }; + assert_eq!( + registry.request_for_root(&identity).await, + super::GitStateChangeRequestV1::Accepted + ); + assert_eq!( + scheduler + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .pending_hint_count(), + Some(0), + "an unchanged watcher probe must not fabricate overflow evidence" + ); + assert_eq!( + registry.pending_wake_micros_for_scope(&scope).await, + Some(0), + "an unchanged watcher probe must not queue a capture pass" + ); + + drop(admission); + registry.shutdown().await; +} + async fn mounted_core_query_worktree_in( registry: CodeIndexSchedulerRegistryV1, fixture: &GitFixture, diff --git a/src/daemon/git_watch.rs b/src/daemon/git_watch.rs index 929df799b4..b323e503d5 100644 --- a/src/daemon/git_watch.rs +++ b/src/daemon/git_watch.rs @@ -1015,7 +1015,7 @@ async fn request_freshness_for_repository( } } }; - match code_index_schedulers.request_for_root(&identity) { + match code_index_schedulers.request_for_root(&identity).await { GitStateChangeRequestV1::Accepted => { accepted = true; log_daemon_event( @@ -1023,7 +1023,9 @@ async fn request_freshness_for_repository( &[("project", project_root.display().to_string())], ); } - GitStateChangeRequestV1::Busy | GitStateChangeRequestV1::IdentityMismatch => { + GitStateChangeRequestV1::Busy + | GitStateChangeRequestV1::WorkerUnavailable + | GitStateChangeRequestV1::IdentityMismatch => { retry.insert(project_root); } GitStateChangeRequestV1::Unmounted => { diff --git a/src/daemon/git_watch/overflow.rs b/src/daemon/git_watch/overflow.rs index aa714120ab..682dfefb7b 100644 --- a/src/daemon/git_watch/overflow.rs +++ b/src/daemon/git_watch/overflow.rs @@ -194,7 +194,7 @@ pub(super) async fn cover_overflowed_repositories(watcher: &GitWatcher) { // IdentityMismatch mean the scheduler is not serving this // root — coverage stays typed on the roster, and the next // real handshake re-resolves identity. - let _ = schedulers.request_for_root(&identity); + let _ = schedulers.request_for_root(&identity).await; } log_daemon_event( "git_watch_backstop", From f6989cc75b397a5648ddef6fe247ca192aed71a4 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Wed, 26 Aug 2026 12:15:24 +0000 Subject: [PATCH 11/23] fix(index): keep unchanged ready queries current --- src/daemon/code_index_scheduler.rs | 9 +---- src/daemon/code_index_scheduler/tests.rs | 42 ++++++++++++++++++++++-- 2 files changed, 40 insertions(+), 11 deletions(-) diff --git a/src/daemon/code_index_scheduler.rs b/src/daemon/code_index_scheduler.rs index 78f6ad9306..8997ba4de5 100644 --- a/src/daemon/code_index_scheduler.rs +++ b/src/daemon/code_index_scheduler.rs @@ -4948,14 +4948,7 @@ impl CodeIndexWorktreeSchedulerV1 { if self.shutting_down.load(Ordering::Acquire) { return Err(cancelled_code_index_reconcile()); } - if self.freshness_unknown - || identity::GitMetadataFingerprintV1::capture(&self.project_root) - .differs_from(&self.git_metadata) - { - self.request_background_reconcile(); - return Ok(None); - } - if self.last_reconciled_at.elapsed() >= self.policy.staleness_threshold { + if self.freshness_probe_requires_reconcile() { self.request_background_reconcile(); return Ok(None); } diff --git a/src/daemon/code_index_scheduler/tests.rs b/src/daemon/code_index_scheduler/tests.rs index 34e6b6d026..420506810b 100644 --- a/src/daemon/code_index_scheduler/tests.rs +++ b/src/daemon/code_index_scheduler/tests.rs @@ -5674,9 +5674,15 @@ fn restored_generation_abstains_and_schedules_background_truth() { .expect("repeat ready check") .is_none() ); - assert!( - restarted.epoch.load(std::sync::atomic::Ordering::Acquire) > first_wake_epoch, - "an earlier failed wake cannot strand a retained overflow marker" + assert_eq!( + restarted.epoch.load(std::sync::atomic::Ordering::Acquire), + first_wake_epoch, + "a repeated read wake must not cancel in-flight generation work" + ); + assert_eq!( + restarted.pending_hint_count(), + None, + "the retained overflow marker remains pending for the refreshed wake" ); let outcome = restarted.reconcile_now().expect("background truth"); @@ -5690,6 +5696,36 @@ fn restored_generation_abstains_and_schedules_background_truth() { ); } +#[test] +fn unchanged_ready_query_refreshes_its_stat_witness_without_reconcile() { + let fixture = GitFixture::new(&[("src/lib.rs", "pub fn alpha() -> u32 { 1 }\n")]); + let store = TempDir::new().expect("store root"); + let mut scheduler = scheduler( + &fixture, + store.path().to_path_buf(), + Arc::new(SharedCodeIndexBytePoolV1::default()), + ); + let published = published(scheduler.reconcile_now().expect("initial publish")); + scheduler.policy.staleness_threshold = Duration::ZERO; + + let ready = scheduler + .latest_complete_ready_for_query() + .expect("unchanged ready query"); + + assert_eq!( + ready + .as_ref() + .map(|latest| &latest.generation.manifest().generation_id), + Some(&published.generation_id), + "elapsed time alone must not make an unchanged generation unavailable" + ); + assert_eq!( + scheduler.pending_hint_count(), + Some(0), + "a matching Git/stat witness must not enqueue authoritative capture" + ); +} + #[test] fn exact_source_readiness_abstains_when_a_file_is_added_inside_freshness_window() { let fixture = GitFixture::new(&[("src/lib.rs", "pub fn alpha() -> u32 { 1 }\n")]); From 72a42dc087fb813ebe1019e1ea89f84da7c0f2c1 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Wed, 26 Aug 2026 13:03:39 +0000 Subject: [PATCH 12/23] fix(index): probe freshness without forcing capture --- src/daemon/code_index_scheduler.rs | 13 +- src/daemon/code_index_scheduler/registry.rs | 76 +++++++++++- src/daemon/code_index_scheduler/tests.rs | 116 ++++++++++++++++++ src/daemon/project_composition.rs | 6 +- .../code_index_activation.rs | 12 ++ src/mcp/server.rs | 10 ++ .../server/background_refresh_writer_tests.rs | 47 ++++++- src/mcp/server/construction.rs | 12 ++ src/mcp/server/hook_writes.rs | 37 ++++-- src/mcp/server/lifecycle.rs | 4 + 10 files changed, 311 insertions(+), 22 deletions(-) diff --git a/src/daemon/code_index_scheduler.rs b/src/daemon/code_index_scheduler.rs index 8997ba4de5..2469a3d215 100644 --- a/src/daemon/code_index_scheduler.rs +++ b/src/daemon/code_index_scheduler.rs @@ -1571,6 +1571,10 @@ struct PendingHintsV1 { } impl PendingHintsV1 { + fn count(&self) -> Option { + (!self.overflow).then(|| u64::try_from(self.paths.len()).unwrap_or(u64::MAX)) + } + fn path(&mut self, path: PathBuf) { if self.paths.len() >= MAX_PENDING_HINTS { self.paths.clear(); @@ -5139,19 +5143,12 @@ impl CodeIndexWorktreeSchedulerV1 { self.verified_against_source } - /// Whether the last execution-owned source observation is older than the - /// configured freshness window. This only inspects scheduler state; it does - /// not reopen Git, scan the worktree, enqueue a wake, or mutate a watermark. - pub(super) fn freshness_window_elapsed(&self) -> bool { - self.last_reconciled_at.elapsed() >= self.policy.staleness_threshold - } - pub(super) fn pending_hint_count(&self) -> Option { let hints = self .hints .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); - (!hints.overflow).then(|| u64::try_from(hints.paths.len()).unwrap_or(u64::MAX)) + hints.count() } #[cfg(test)] diff --git a/src/daemon/code_index_scheduler/registry.rs b/src/daemon/code_index_scheduler/registry.rs index 4590eaff4a..43f05784a5 100644 --- a/src/daemon/code_index_scheduler/registry.rs +++ b/src/daemon/code_index_scheduler/registry.rs @@ -3396,6 +3396,47 @@ impl CodeIndexSchedulerRegistryV1 { true } + /// Run the bounded Git/stat freshness ladder for an ordinary read without + /// manufacturing an overflow. Only a proven source change posts a query + /// admission wake; a matching stat signature refreshes the scheduler's + /// cadence watermark and returns without traversal. + pub(in crate::daemon) async fn probe_freshness(&self, project_root: &Path) -> bool { + let Ok(project_root) = project_root.canonicalize() else { + return false; + }; + let (scheduler, wake, pending_wake, reconcile_in_progress) = { + let mounted = self.mounted.lock().await; + let Some(worktree) = mounted.get(&project_root) else { + return false; + }; + ( + Arc::clone(&worktree.scheduler), + Arc::clone(&worktree.wake), + Arc::clone(&worktree.pending_wake), + Arc::clone(&worktree.reconcile_in_progress), + ) + }; + if pending_wake.has_pending_arrival() || reconcile_in_progress.load(Ordering::Acquire) != 0 + { + return true; + } + tokio::task::spawn_blocking(move || { + let mut scheduler = scheduler + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if scheduler.request_fresh_for_query_background() { + Self::note_wake( + &pending_wake, + &wake, + CodeIndexCadenceTriggerV1::QueryAdmission, + ); + } + true + }) + .await + .unwrap_or(false) + } + /// Mounted scope identity plus the currently serving generation for one /// project. Daemon authorities that must retain this scope's code-graph /// runtime (semantic vectors, generation retention) resolve through this @@ -3517,7 +3558,14 @@ impl CodeIndexSchedulerRegistryV1 { project_root: &Path, ) -> Option { let canonical_root = project_root.canonicalize().ok()?; - let (scheduler, reconcile_in_progress, serving_generation, text_generation, build_progress) = { + let ( + scheduler, + reconcile_in_progress, + serving_generation, + text_generation, + build_progress, + hints, + ) = { let mounted = self.mounted.lock().await; let worktree = mounted.get(&canonical_root)?; ( @@ -3526,6 +3574,7 @@ impl CodeIndexSchedulerRegistryV1 { Arc::clone(&worktree.serving_generation), Arc::clone(&worktree.text_generation), Arc::clone(&worktree.build_progress), + Arc::clone(&worktree.hints), ) }; tokio::task::spawn_blocking(move || { @@ -3567,26 +3616,43 @@ impl CodeIndexSchedulerRegistryV1 { } else { dashboard_text_freshness_identity(text.as_ref()) }; + let hook_hint_count = hints + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .count(); + let ready = latest.is_some() || text_ready; + let stale = hook_hint_count != Some(0); return crate::dashboard::code_index_freshness_api::CodeIndexWorktreeFreshnessV1 { worktree_root: canonical_root.display().to_string(), last_reconcile_micros: None, staleness_state: Some( - if latest.is_some() || text_ready { + if refreshing && ready { "refreshing" + } else if stale && ready { + "stale" + } else if ready { + "fresh" } else { "indexing" } .to_owned(), ), - hook_hint_count: None, - coverage: "partial_refresh_in_progress".to_owned(), + hook_hint_count, + coverage: if refreshing { + "partial_refresh_in_progress" + } else if hook_hint_count.is_some() { + "complete" + } else { + "partial_hook_hint_overflow" + } + .to_owned(), progress, ..identity }; } }; let verified = scheduler.verified_against_source(); - let stale = !verified || scheduler.freshness_window_elapsed(); + let stale = !verified; let latest = serving_generation .read() .unwrap_or_else(std::sync::PoisonError::into_inner) diff --git a/src/daemon/code_index_scheduler/tests.rs b/src/daemon/code_index_scheduler/tests.rs index 420506810b..a98b53f3b7 100644 --- a/src/daemon/code_index_scheduler/tests.rs +++ b/src/daemon/code_index_scheduler/tests.rs @@ -5520,6 +5520,7 @@ async fn dashboard_progress_does_not_wait_for_the_scheduler_mutex() { .await .expect("mount daemon-owned scheduler"); wait_for_initial_generation(®istry, fixture.path()).await; + wait_for_dashboard_ready(®istry, fixture.path()).await; let canonical_root = fixture .path() .canonicalize() @@ -5561,6 +5562,13 @@ async fn dashboard_progress_does_not_wait_for_the_scheduler_mutex() { let projected_progress = projected.progress.expect("projected progress snapshot"); assert_eq!(projected_progress.generation_id, expected.generation_id); assert!(projected_progress.progress_epoch >= expected.progress_epoch); + assert_eq!( + projected.staleness_state.as_deref(), + Some("fresh"), + "an unrelated scheduler-mutex holder is not a source refresh" + ); + assert_eq!(projected.coverage, "complete"); + assert_eq!(projected.hook_hint_count, Some(0)); let _ = release_tx.send(()); scheduler_holder .await @@ -5568,6 +5576,94 @@ async fn dashboard_progress_does_not_wait_for_the_scheduler_mutex() { registry.shutdown().await; } +#[tokio::test] +async fn unchanged_background_freshness_probe_posts_no_overflow_wake() { + let fixture = GitFixture::new(&[("src/main.rs", "fn main() {}\n")]); + let store = TempDir::new().expect("store root"); + let registry = CodeIndexSchedulerRegistryV1::new(1); + registry + .mount_worktree( + test_project_id(), + fixture.path(), + store.path().to_path_buf(), + None, + ) + .await + .expect("mount daemon-owned scheduler"); + wait_for_initial_generation(®istry, fixture.path()).await; + let canonical = fixture.path().canonicalize().expect("canonical fixture"); + { + let mounted = registry.mounted.lock().await; + let scheduler = &mounted.get(&canonical).expect("mounted worktree").scheduler; + scheduler + .lock() + .expect("scheduler") + .policy + .staleness_threshold = Duration::ZERO; + } + let receipts_before = registry.event_to_ready_receipts().len(); + + assert!(registry.probe_freshness(fixture.path()).await); + tokio::time::sleep(Duration::from_millis(50)).await; + + let mounted = registry.mounted.lock().await; + let scheduler = mounted.get(&canonical).expect("mounted worktree"); + assert_eq!( + scheduler + .scheduler + .lock() + .expect("scheduler") + .pending_hint_count(), + Some(0), + "matching Git/stat evidence must not become an overflow hint" + ); + assert_eq!( + registry.event_to_ready_receipts().len(), + receipts_before, + "a suppressed probe must not fabricate a reconcile receipt" + ); + drop(mounted); + registry.shutdown().await; +} + +#[tokio::test] +async fn elapsed_freshness_window_alone_does_not_make_dashboard_state_stale() { + let fixture = GitFixture::new(&[("src/main.rs", "fn main() {}\n")]); + let store = TempDir::new().expect("store root"); + let registry = CodeIndexSchedulerRegistryV1::new(1); + registry + .mount_worktree( + test_project_id(), + fixture.path(), + store.path().to_path_buf(), + None, + ) + .await + .expect("mount daemon-owned scheduler"); + wait_for_initial_generation(®istry, fixture.path()).await; + wait_for_dashboard_ready(®istry, fixture.path()).await; + let canonical = fixture.path().canonicalize().expect("canonical fixture"); + { + let mounted = registry.mounted.lock().await; + mounted + .get(&canonical) + .expect("mounted worktree") + .scheduler + .lock() + .expect("scheduler") + .policy + .staleness_threshold = Duration::ZERO; + } + + let projected = registry + .dashboard_freshness(fixture.path()) + .await + .expect("dashboard freshness"); + assert_eq!(projected.staleness_state.as_deref(), Some("fresh")); + assert_eq!(projected.coverage, "complete"); + registry.shutdown().await; +} + /// A dashboard status view reports the last execution-owned scheduler state; it /// must not run the freshness ladder, wake a worker, or publish an out-of-band /// source change merely because an operator opened the view. @@ -8112,6 +8208,26 @@ async fn wait_for_initial_generation( .expect("initial generation published") } +async fn wait_for_dashboard_ready(registry: &CodeIndexSchedulerRegistryV1, path: &Path) { + tokio::time::timeout(Duration::from_secs(5), async { + loop { + let ready = registry + .dashboard_freshness(path) + .await + .is_some_and(|freshness| { + freshness.staleness_state.as_deref() == Some("fresh") + && freshness.coverage == "complete" + }); + if ready { + break; + } + tokio::task::yield_now().await; + } + }) + .await + .expect("dashboard reaches fresh complete state"); +} + /// Wait until the mounted worktree publishes a generation distinct from `previous`. async fn wait_for_generation_change( registry: &CodeIndexSchedulerRegistryV1, diff --git a/src/daemon/project_composition.rs b/src/daemon/project_composition.rs index 2e4dc9771e..538ecc0a3a 100644 --- a/src/daemon/project_composition.rs +++ b/src/daemon/project_composition.rs @@ -11,7 +11,7 @@ mod runtime; mod session_database_admission; use code_index_activation::{ CodeIndexActivationMountInputs, code_index_activation_hint_sink, code_index_activation_mount, - code_index_hook_sink, code_index_reconcile_sink, + code_index_freshness_probe_sink, code_index_hook_sink, code_index_reconcile_sink, }; pub(in crate::daemon) use runtime::ProductionProjectCompositionRuntime; use runtime::bind_verified_project_graph_runtime; @@ -414,6 +414,8 @@ pub(super) async fn production_project_server( invocation.code_index_schedulers.clone(), Arc::clone(&code_index_activation), ); + let code_index_freshness_probe_sink = + code_index_freshness_probe_sink(invocation.code_index_schedulers.clone()); // The daemon mounts the same broker the MCP server and the directly // served dashboard open: persisted analyzer settings (with a recorded // degradation for an unreadable file) plus the home-level OpenCode @@ -500,6 +502,7 @@ pub(super) async fn production_project_server( .with_diagnostics_lsp(Arc::clone(&diagnostic_broker)) .with_code_index_hook_sink(Arc::clone(&code_index_hook_sink)) .with_code_index_reconcile_sink(Arc::clone(&code_index_reconcile_sink)) + .with_code_index_freshness_probe_sink(Arc::clone(&code_index_freshness_probe_sink)) .with_code_index_publication_identity(Arc::clone(&code_index_publication_identity)) .with_code_index_search_executor(Arc::clone(&code_index_search_executor)) .with_code_index_branch_diff_executor(Arc::clone(&code_index_branch_diff_executor)) @@ -871,6 +874,7 @@ pub(super) async fn production_project_server( .with_diagnostics_lsp(diagnostic_broker) .with_code_index_hook_sink(code_index_hook_sink) .with_code_index_reconcile_sink(code_index_reconcile_sink) + .with_code_index_freshness_probe_sink(code_index_freshness_probe_sink) .with_code_index_publication_identity(code_index_publication_identity) .with_code_index_search_executor(code_index_search_executor) .with_code_index_branch_diff_executor(code_index_branch_diff_executor) diff --git a/src/daemon/project_composition/code_index_activation.rs b/src/daemon/project_composition/code_index_activation.rs index 7ec720b8e8..9dc25c587f 100644 --- a/src/daemon/project_composition/code_index_activation.rs +++ b/src/daemon/project_composition/code_index_activation.rs @@ -292,6 +292,18 @@ pub(super) fn code_index_reconcile_sink( sink } +/// MCP-facing ordinary-read freshness probe. Unlike the explicit reconcile +/// sink, this runs only the scheduler's bounded Git/stat ladder and creates an +/// overflow wake solely when that evidence proves a reconcile is required. +pub(super) fn code_index_freshness_probe_sink( + schedulers: code_index_scheduler::CodeIndexSchedulerRegistryV1, +) -> crate::mcp::server::CodeIndexFreshnessProbeSink { + Arc::new(move |root: PathBuf| { + let schedulers = schedulers.clone(); + Box::pin(async move { schedulers.probe_freshness(&root).await }) + }) +} + #[cfg(test)] mod tests { use std::process::Command; diff --git a/src/mcp/server.rs b/src/mcp/server.rs index 16b83dce6c..0addb4a99f 100644 --- a/src/mcp/server.rs +++ b/src/mcp/server.rs @@ -122,6 +122,13 @@ pub(crate) type CodeIndexHookSink = pub(crate) type CodeIndexReconcileSink = Arc CodeIndexHookNotifyFuture + Send + Sync + 'static>; +/// Non-blocking bridge for ordinary reads to run the scheduler's cheap +/// Git/stat freshness ladder. A successful future means the mounted scheduler +/// inspected or already owns the freshness remedy; it does not imply that a +/// reconcile was necessary. +pub(crate) type CodeIndexFreshnessProbeSink = + Arc CodeIndexHookNotifyFuture + Send + Sync + 'static>; + /// Type-erased bridge from a tool handler to the daemon-owned code-index /// generation authority. The daemon constructs this from its cloneable /// `CodeIndexSchedulerRegistryV1`; direct (non-daemon) servers leave it `None`, @@ -306,6 +313,7 @@ pub struct McpServer { /// scheduler queue. `None` for direct servers with no scheduler registry. code_index_hook_sink: Option, code_index_reconcile_sink: Option, + code_index_freshness_probe_sink: Option, /// Daemon-owned bridge to the code-index generation authority, the single /// mint for `file.daemon.` file identity and the generation every /// diagnostic producer must publish under. `None` for direct servers. @@ -743,6 +751,7 @@ impl McpServer { background_refresh_writer, code_index_hook_sink, code_index_reconcile_sink, + code_index_freshness_probe_sink, code_index_publication_identity, code_index_search_executor, code_index_branch_diff_executor, @@ -991,6 +1000,7 @@ impl McpServer { background_refresh_writer, code_index_hook_sink, code_index_reconcile_sink, + code_index_freshness_probe_sink, code_index_publication_identity, code_index_search_executor, code_index_branch_diff_executor, diff --git a/src/mcp/server/background_refresh_writer_tests.rs b/src/mcp/server/background_refresh_writer_tests.rs index 63ff4045a6..6490917b95 100644 --- a/src/mcp/server/background_refresh_writer_tests.rs +++ b/src/mcp/server/background_refresh_writer_tests.rs @@ -1,6 +1,7 @@ use super::writer_test_support::init_indexed_repo; use super::{ - BackgroundRefreshRequest, BackgroundRefreshWriter, McpServer, McpServerConstructionContext, + BackgroundRefreshModeV1, BackgroundRefreshRequest, BackgroundRefreshWriter, McpServer, + McpServerConstructionContext, }; use std::collections::HashMap; use std::path::PathBuf; @@ -8,6 +9,50 @@ use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::{Arc, Mutex}; use std::time::Duration; +#[tokio::test] +async fn read_refresh_routes_through_the_freshness_probe_not_forced_reconcile() { + let (cg, dir, _authority) = init_indexed_repo().await; + let forced = Arc::new(AtomicUsize::new(0)); + let probed = Arc::new(AtomicUsize::new(0)); + let reconcile_sink: super::CodeIndexReconcileSink = { + let forced = Arc::clone(&forced); + Arc::new(move |_root| { + let forced = Arc::clone(&forced); + Box::pin(async move { + forced.fetch_add(1, Ordering::AcqRel); + true + }) + }) + }; + let freshness_probe_sink: super::CodeIndexFreshnessProbeSink = { + let probed = Arc::clone(&probed); + Arc::new(move |_root| { + let probed = Arc::clone(&probed); + Box::pin(async move { + probed.fetch_add(1, Ordering::AcqRel); + true + }) + }) + }; + + super::hook_writes::execute_background_refresh_direct(BackgroundRefreshRequest { + graph: Arc::new(cg), + project_root: dir.path().to_path_buf(), + mode: BackgroundRefreshModeV1::FreshnessProbe, + reconcile_sink: Some(reconcile_sink), + freshness_probe_sink: Some(freshness_probe_sink), + }) + .await + .expect("read freshness probe"); + + assert_eq!(probed.load(Ordering::Acquire), 1); + assert_eq!( + forced.load(Ordering::Acquire), + 0, + "an ordinary read must never enter the force-overflow authority" + ); +} + #[tokio::test] async fn read_refresh_uses_injected_writer_without_direct_fallback() { let (cg, dir, _authority) = init_indexed_repo().await; diff --git a/src/mcp/server/construction.rs b/src/mcp/server/construction.rs index e5f1708ffc..bf57256ba6 100644 --- a/src/mcp/server/construction.rs +++ b/src/mcp/server/construction.rs @@ -117,6 +117,7 @@ pub(crate) struct McpServerConstructionContext { pub(crate) background_refresh_writer: BackgroundRefreshWriter, pub(crate) code_index_hook_sink: Option, pub(crate) code_index_reconcile_sink: Option, + pub(crate) code_index_freshness_probe_sink: Option, pub(crate) code_index_publication_identity: Option, pub(crate) code_index_search_executor: Option, pub(crate) code_index_branch_diff_executor: Option, @@ -227,6 +228,7 @@ impl McpServerConstructionContext { background_refresh_writer: direct_background_refresh_writer(), code_index_hook_sink: None, code_index_reconcile_sink: None, + code_index_freshness_probe_sink: None, code_index_publication_identity: None, code_index_search_executor: None, code_index_branch_diff_executor: None, @@ -324,6 +326,7 @@ impl McpServerConstructionContext { background_refresh_writer: writers.background_refresh, code_index_hook_sink: None, code_index_reconcile_sink: None, + code_index_freshness_probe_sink: None, code_index_publication_identity: None, code_index_search_executor: None, code_index_branch_diff_executor: None, @@ -387,6 +390,7 @@ impl McpServerConstructionContext { background_refresh_writer: writers.background_refresh, code_index_hook_sink: None, code_index_reconcile_sink: None, + code_index_freshness_probe_sink: None, code_index_publication_identity: None, code_index_search_executor: None, code_index_branch_diff_executor: None, @@ -429,6 +433,14 @@ impl McpServerConstructionContext { self } + pub(crate) fn with_code_index_freshness_probe_sink( + mut self, + sink: super::CodeIndexFreshnessProbeSink, + ) -> Self { + self.code_index_freshness_probe_sink = Some(sink); + self + } + pub(crate) fn with_code_index_search_executor( mut self, executor: super::CodeIndexSearchExecutor, diff --git a/src/mcp/server/hook_writes.rs b/src/mcp/server/hook_writes.rs index 1d99bbb283..43a2bc59dd 100644 --- a/src/mcp/server/hook_writes.rs +++ b/src/mcp/server/hook_writes.rs @@ -11,11 +11,19 @@ use crate::errors::{Result, TraceDecayError}; use crate::tracedecay::TraceDecay; /// Complete detached reconciliation admission requested by the MCP server. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum BackgroundRefreshModeV1 { + ForceReconcile, + FreshnessProbe, +} + #[derive(Clone)] pub(crate) struct BackgroundRefreshRequest { pub(crate) graph: Arc, pub(crate) project_root: PathBuf, + pub(crate) mode: BackgroundRefreshModeV1, pub(crate) reconcile_sink: Option, + pub(crate) freshness_probe_sink: Option, } /// Injectable ownership boundary for detached reconciliation admission. @@ -55,14 +63,29 @@ pub(crate) async fn execute_background_refresh_direct( message: "retained background refresh graph is stale".to_string(), }); } - let Some(reconcile_sink) = request.reconcile_sink else { - return Err(TraceDecayError::project_route( - "code_index_scheduler_unavailable", - true, - "background refresh requires the daemon code-index scheduler", - )); + let accepted = match request.mode { + BackgroundRefreshModeV1::ForceReconcile => request + .reconcile_sink + .map(|sink| sink(canonical_root)) + .ok_or_else(|| { + TraceDecayError::project_route( + "code_index_scheduler_unavailable", + true, + "background refresh requires the daemon code-index scheduler", + ) + })?, + BackgroundRefreshModeV1::FreshnessProbe => request + .freshness_probe_sink + .map(|sink| sink(canonical_root)) + .ok_or_else(|| { + TraceDecayError::project_route( + "code_index_scheduler_unavailable", + true, + "background freshness probing requires the daemon code-index scheduler", + ) + })?, }; - if !reconcile_sink(canonical_root).await { + if !accepted.await { return Err(TraceDecayError::project_route( "code_index_scheduler_unavailable", true, diff --git a/src/mcp/server/lifecycle.rs b/src/mcp/server/lifecycle.rs index 77298a6943..9094c23638 100644 --- a/src/mcp/server/lifecycle.rs +++ b/src/mcp/server/lifecycle.rs @@ -522,7 +522,9 @@ impl McpServer { let request = BackgroundRefreshRequest { graph: Arc::clone(&cg), project_root: cg.project_root().to_path_buf(), + mode: super::BackgroundRefreshModeV1::ForceReconcile, reconcile_sink: self.code_index_reconcile_sink.clone(), + freshness_probe_sink: self.code_index_freshness_probe_sink.clone(), }; match refresh(request).await { Ok(Some(fresh)) => { @@ -698,7 +700,9 @@ impl McpServer { let request = BackgroundRefreshRequest { graph: Arc::clone(cg), project_root: cg.project_root().to_path_buf(), + mode: super::BackgroundRefreshModeV1::FreshnessProbe, reconcile_sink: self.code_index_reconcile_sink.clone(), + freshness_probe_sink: self.code_index_freshness_probe_sink.clone(), }; let _admitted = self.background_tasks.spawn(async move { let _running = running_guard; From 4ccb7b0a46443522217bd88f4e8cb3e45d84879e Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Wed, 26 Aug 2026 14:31:43 +0000 Subject: [PATCH 13/23] fix(daemon): defer schema convergence until full publication --- .../branch_admin/session_runtime_shutdown.rs | 30 ++++ src/daemon/production_harness.rs | 29 +++- .../project_server_capacity_journey_test.rs | 140 +++++++++++++++++- src/daemon/project_composition.rs | 1 - .../session_registry/maintenance.rs | 8 +- .../store_runtime/session_registry/tests.rs | 6 +- 6 files changed, 200 insertions(+), 14 deletions(-) diff --git a/src/daemon/branch_admin/session_runtime_shutdown.rs b/src/daemon/branch_admin/session_runtime_shutdown.rs index e9919ebfe6..c9eda8d280 100644 --- a/src/daemon/branch_admin/session_runtime_shutdown.rs +++ b/src/daemon/branch_admin/session_runtime_shutdown.rs @@ -37,6 +37,36 @@ impl SessionRuntimeMemoryGraphReconciliationShutdownV1 { } impl StoreAdministration { + #[cfg(test)] + pub(in crate::daemon) async fn install_long_lived_session_runtime_registry_for_test( + &self, + ) -> Result<()> { + let identity = self.profile_identity()?.clone(); + let profile_root = authority::canonical_identity_path(identity.profile_root())?; + let registry = Arc::new( + crate::daemon::store_runtime::session_registry::DaemonSessionRuntimeRegistryV1::open_with_session_maintenance( + identity.clone(), + true, + ) + .await?, + ); + let cell = { + let mut registries = self.session_runtime_registries.lock().await; + Arc::clone( + ®istries + .entry(profile_root) + .or_insert_with(|| SessionRuntimeRegistryEntryV1 { + identity, + registry: Arc::new(tokio::sync::OnceCell::new()), + }) + .registry, + ) + }; + cell.set(registry).map_err(|_| TraceDecayError::Config { + message: "test session runtime registry was already initialized".to_owned(), + }) + } + pub(in crate::daemon) async fn session_runtime_registry( &self, ) -> Result> diff --git a/src/daemon/production_harness.rs b/src/daemon/production_harness.rs index 0729ce5437..d2d3acb6a9 100644 --- a/src/daemon/production_harness.rs +++ b/src/daemon/production_harness.rs @@ -85,8 +85,14 @@ impl ProductionProjectCompositionHarnessV1 { project_roots: impl IntoIterator, ) -> Result { let live_profile_root = crate::config::user_data_dir().filter(|path| path.exists()); - Self::open_with_live_profile_root(isolation_root, project_roots, live_profile_root, None) - .await + Self::open_with_live_profile_root( + isolation_root, + project_roots, + live_profile_root, + None, + false, + ) + .await } pub async fn open_with_scope_prefix( @@ -100,6 +106,7 @@ impl ProductionProjectCompositionHarnessV1 { project_roots, live_profile_root, Some(scope_prefix.into()), + false, ) .await } @@ -109,6 +116,7 @@ impl ProductionProjectCompositionHarnessV1 { project_roots: impl IntoIterator, live_profile_root: Option, scope_prefix: Option, + long_lived_session_maintenance_for_test: bool, ) -> Result { std::fs::create_dir_all(isolation_root.as_ref()).map_err(|error| { TraceDecayError::Config { @@ -194,6 +202,14 @@ impl ProductionProjectCompositionHarnessV1 { )?; let store_administration = StoreAdministration::default().with_profile_identity(profile_identity.clone()); + #[cfg(test)] + if long_lived_session_maintenance_for_test { + store_administration + .install_long_lived_session_runtime_registry_for_test() + .await?; + } + #[cfg(not(test))] + let _ = long_lived_session_maintenance_for_test; let invocation = DaemonInvocationState::default(); invocation.configure_github_read_only_credentials(&profile_identity); let profile_sessions = store_administration @@ -312,10 +328,19 @@ impl ProductionProjectCompositionHarnessV1 { project_roots, Some(live_profile_root), None, + false, ) .await } + #[cfg(test)] + pub(super) async fn open_with_session_maintenance_for_test( + isolation_root: impl AsRef, + project_roots: impl IntoIterator, + ) -> Result { + Self::open_with_live_profile_root(isolation_root, project_roots, None, None, true).await + } + pub fn isolation_root(&self) -> &Path { &self.isolation_root } diff --git a/src/daemon/production_harness/project_server_capacity_journey_test.rs b/src/daemon/production_harness/project_server_capacity_journey_test.rs index 9fc4a1b18b..5d1f293e48 100644 --- a/src/daemon/production_harness/project_server_capacity_journey_test.rs +++ b/src/daemon/production_harness/project_server_capacity_journey_test.rs @@ -5,11 +5,11 @@ use super::*; use crate::daemon::code_index_scheduler::LatestCompleteCodeIndexV1; use crate::daemon::project_composition::ProductionProjectComposition; -async fn open_project( +async fn open_project_composition( harness: &ProductionProjectCompositionHarnessV1, project: &Path, instance: &str, -) -> Result<(ProductionProjectComposition, LatestCompleteCodeIndexV1)> { +) -> Result { let resources = harness .resources .as_ref() @@ -33,7 +33,7 @@ async fn open_project( moved_store_adoption: crate::tracedecay::MovedStoreAdoption::Never, }; let (canonical_project_path, _) = project_route_for_handshake(&handshake)?; - let composition = resources + resources .store_administration .with_writer(|| async { production_project_server( @@ -52,7 +52,21 @@ async fn open_project( ) .await }) - .await?; + .await +} + +async fn open_project( + harness: &ProductionProjectCompositionHarnessV1, + project: &Path, + instance: &str, +) -> Result<(ProductionProjectComposition, LatestCompleteCodeIndexV1)> { + let resources = harness + .resources + .as_ref() + .ok_or_else(|| TraceDecayError::Config { + message: "production harness is shut down".to_owned(), + })?; + let composition = open_project_composition(harness, project, instance).await?; let code_search_scope = { let graph = composition.server.cg().await; let target = graph.configuration_runtime().configuration_target(); @@ -70,6 +84,49 @@ async fn open_project( Ok((composition, latest)) } +async fn seed_project_sessions_pending_convergence( + profile_root: &Path, + project_root: &Path, + project_id: &tracedecay_domain::ProjectId, +) { + let identity = crate::daemon::profile_identity::load_or_create(profile_root) + .expect("durable harness profile identity"); + crate::storage::pin_fixture_repository_identity(project_root, project_id.as_str()) + .expect("target project enrollment"); + let sessions_path = + crate::storage::profile_sharded_data_root(profile_root, project_id.as_str()) + .join(crate::storage::SESSIONS_DB_FILENAME); + std::fs::create_dir_all(sessions_path.parent().expect("session database parent")) + .expect("session database directory"); + crate::daemon::store_runtime::register_registered_schema_installer(); + let authority = crate::db::DatabaseAuthority::acquire_test( + &sessions_path, + "seed production project-open convergence fixture", + ) + .expect("project sessions fixture database authority"); + let (database, _) = crate::db::Database::publish_registered_test_runtime_for_profile_identity( + &sessions_path, + &authority, + crate::db::TestDatabaseRuntimeMode::Initialize, + crate::db::TestRuntimeProfileIdentityV1::new( + identity.brain_id().clone(), + identity.profile_id().clone(), + ), + crate::db::TestDatabaseRuntimeScope::ProjectSessions { + project_id: project_id.clone(), + }, + ) + .await + .expect("seed complete registered project sessions schema"); + database + .execute_write_batch( + "remove production project-open convergence checkpoint", + "DELETE FROM authority_audit_checkpoints", + ) + .await + .expect("remove durable convergence checkpoint"); +} + fn assert_generation_contains_probe(latest: &LatestCompleteCodeIndexV1, probe: &str) { let symbols = &latest.generation().symbols().symbols; assert!( @@ -82,6 +139,81 @@ fn assert_generation_contains_probe(latest: &LatestCompleteCodeIndexV1, probe: & ); } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn project_full_publication_precedes_registered_schema_convergence() { + let isolation = TempDir::new().expect("production harness isolation"); + let bootstrap_project = isolation.path().join("bootstrap-project"); + let target_project = isolation.path().join("target-project"); + for (project, probe) in [ + (&bootstrap_project, "bootstrap_probe"), + (&target_project, "target_probe"), + ] { + std::fs::create_dir_all(project.join("src")).expect("project source root"); + std::fs::write( + project.join("src/lib.rs"), + format!("pub fn {probe}() -> usize {{ 1 }}\n"), + ) + .expect("project source"); + git(project, &["init", "-q"]); + git(project, &["add", "."]); + git(project, &["config", "user.name", "TraceDecay Test"]); + git( + project, + &["config", "user.email", "tracedecay@example.invalid"], + ); + git(project, &["commit", "-qm", "seed project"]); + } + + let harness = ProductionProjectCompositionHarnessV1::open_with_session_maintenance_for_test( + isolation.path(), + std::iter::once(bootstrap_project), + ) + .await + .expect("production harness authority"); + let target_project_id = + tracedecay_domain::ProjectId::new("project.schema-convergence-full-publication") + .expect("typed target project identity"); + seed_project_sessions_pending_convergence( + harness.profile_root(), + &target_project, + &target_project_id, + ) + .await; + + let resources = harness + .resources + .as_ref() + .expect("production harness resources"); + let registry = resources + .store_administration + .session_runtime_registry() + .await + .expect("session runtime registry"); + let convergence_gate = registry.block_registered_schema_convergence_for_test(); + let mut project_open = Box::pin(open_project_composition( + &harness, + &target_project, + "foreground-convergence", + )); + let composition = tokio::select! { + result = &mut project_open => result.expect("target project full publication"), + () = convergence_gate.wait_until_blocked() => { + panic!("historical schema convergence entered before full project publication") + } + }; + drop(project_open); + + tokio::time::timeout( + std::time::Duration::from_secs(1), + convergence_gate.wait_until_blocked(), + ) + .await + .expect("historical convergence starts after full project publication"); + convergence_gate.release(); + drop(composition); + harness.shutdown().await; +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn twelve_project_journey_retires_idle_owners_without_empty_graphs() { let isolation = TempDir::new().expect("production harness isolation"); diff --git a/src/daemon/project_composition.rs b/src/daemon/project_composition.rs index 538ecc0a3a..676b2b0070 100644 --- a/src/daemon/project_composition.rs +++ b/src/daemon/project_composition.rs @@ -644,7 +644,6 @@ pub(super) async fn production_project_server( ), ], ); - drop(foreground_project_open); let semantic_startup_project = canonical_project_path.to_path_buf(); tokio::task::spawn_blocking(move || { let started = Instant::now(); diff --git a/src/daemon/store_runtime/session_registry/maintenance.rs b/src/daemon/store_runtime/session_registry/maintenance.rs index 441be019b0..bc5563749c 100644 --- a/src/daemon/store_runtime/session_registry/maintenance.rs +++ b/src/daemon/store_runtime/session_registry/maintenance.rs @@ -313,19 +313,19 @@ impl RegisteredSchemaConvergenceTestGateState { } #[cfg(test)] -pub(super) struct RegisteredSchemaConvergenceTestGate { +pub(crate) struct RegisteredSchemaConvergenceTestGate { state: Arc, } #[cfg(test)] impl RegisteredSchemaConvergenceTestGate { - pub(super) async fn wait_until_blocked(&self) { + pub(crate) async fn wait_until_blocked(&self) { while !self.state.started.load(Ordering::Acquire) { self.state.started_notify.notified().await; } } - pub(super) fn release(&self) { + pub(crate) fn release(&self) { self.state.release.add_permits(1); } } @@ -379,7 +379,7 @@ impl DaemonSessionRuntimeRegistryV1 { } #[cfg(test)] - pub(super) fn block_registered_schema_convergence_for_test( + pub(crate) fn block_registered_schema_convergence_for_test( &self, ) -> RegisteredSchemaConvergenceTestGate { self.registered_schema_convergence.install_gate() diff --git a/src/daemon/store_runtime/session_registry/tests.rs b/src/daemon/store_runtime/session_registry/tests.rs index df184a688a..477db1d40a 100644 --- a/src/daemon/store_runtime/session_registry/tests.rs +++ b/src/daemon/store_runtime/session_registry/tests.rs @@ -666,7 +666,7 @@ async fn daemon_admission_returns_while_historical_convergence_is_blocked() { } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn foreground_project_open_defers_historical_convergence_until_core_publication() { +async fn foreground_project_open_defers_historical_convergence_until_full_publication() { let (_temporary, identity, project_id, project_root, _sessions_path, _database_scope) = project_sessions_pending_convergence("project.schema-foreground-admission").await; let registry = DaemonSessionRuntimeRegistryV1::open_with_session_maintenance(identity, true) @@ -688,7 +688,7 @@ async fn foreground_project_open_defers_historical_convergence_until_core_public ) .await .is_err(), - "historical convergence must not enter its writer lane before core publication" + "historical convergence must not enter its writer lane before full publication" ); database .begin_write_transaction() @@ -704,7 +704,7 @@ async fn foreground_project_open_defers_historical_convergence_until_core_public convergence_gate.wait_until_blocked(), ) .await - .expect("historical convergence starts after core publication"); + .expect("historical convergence starts after full publication"); convergence_gate.release(); } From 9a5603ed5a5774d27456d0d3ee24fee897424627 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Wed, 26 Aug 2026 14:55:58 +0000 Subject: [PATCH 14/23] perf(hotpath): distinguish status warming awaits --- src/mcp/tools/handlers/info/status.rs | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/mcp/tools/handlers/info/status.rs b/src/mcp/tools/handlers/info/status.rs index bdc9ba9f77..712499cff0 100644 --- a/src/mcp/tools/handlers/info/status.rs +++ b/src/mcp/tools/handlers/info/status.rs @@ -148,12 +148,22 @@ pub(crate) async fn handle_status( let include_session_ingest = status_arg_flag(&args, "include_session_ingest", true); let include_staleness = status_arg_flag(&args, "include_staleness", true); + let graph_statistics = hotpath::future!( + graph_statistics_value(generation_census_reader), + label = "mcp.status.generation_census" + ) + .await?; let mut output = json!({ "project_root": cg.project_root(), - "graph_statistics": graph_statistics_value(generation_census_reader).await?, + "graph_statistics": graph_statistics, }); let code_index_freshness = match code_index_freshness_reader { - Some(reader) => match reader(cg.project_root().to_path_buf()).await { + Some(reader) => match hotpath::future!( + reader(cg.project_root().to_path_buf()), + label = "mcp.status.code_index_freshness" + ) + .await + { Some(freshness) => { let authoritative = freshness.latest_generation_id.is_some() && freshness.coverage == "complete" From d74035c70836ee7eb928649941d73c7be9fc7ab4 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Wed, 26 Aug 2026 15:30:48 +0000 Subject: [PATCH 15/23] fix(index): isolate text refresh from graph retry --- src/daemon/code_index_scheduler/registry.rs | 23 +++--- src/daemon/code_index_scheduler/tests.rs | 92 ++++++++++----------- 2 files changed, 54 insertions(+), 61 deletions(-) diff --git a/src/daemon/code_index_scheduler/registry.rs b/src/daemon/code_index_scheduler/registry.rs index 43f05784a5..8105b96287 100644 --- a/src/daemon/code_index_scheduler/registry.rs +++ b/src/daemon/code_index_scheduler/registry.rs @@ -2629,17 +2629,14 @@ impl CodeIndexSchedulerRegistryV1 { } } } - if seat_retry_pending { - // Rebuilding here would seal a duplicate of an artifact - // that only failed to activate. Restore the arrival so the - // next pass measures this wake's full queue wait, then let - // the scheduled retry wake re-attempt activation. - Self::restore_pending_arrival(&worker_pending_wake, arrival, trigger); - if worker_shutting_down.load(Ordering::Acquire) { - return; - } - continue; - } + // A retryable native-graph failure defers only graph + // activation. The retained text authority can still prove an + // unchanged source without resealing, or publish one changed + // generation from an authoritative capture. Suppress the + // complete-generation load below until the scheduled graph + // retry so graph backoff cannot stall exact and lexical + // freshness or trigger another activation attempt here. + let graph_activation_deferred = seat_retry_pending; let retained_text_metadata = worker_text_generation .read() .unwrap_or_else(std::sync::PoisonError::into_inner) @@ -2665,7 +2662,9 @@ impl CodeIndexSchedulerRegistryV1 { }; // A terminal outcome may publish a newer complete generation; // swap serving to that after graph activation below. - let mut latest = (graph_activation_enabled && text_serving_ready) + let mut latest = (graph_activation_enabled + && text_serving_ready + && !graph_activation_deferred) .then(|| { result .as_ref() diff --git a/src/daemon/code_index_scheduler/tests.rs b/src/daemon/code_index_scheduler/tests.rs index a98b53f3b7..4dababd645 100644 --- a/src/daemon/code_index_scheduler/tests.rs +++ b/src/daemon/code_index_scheduler/tests.rs @@ -9976,9 +9976,9 @@ async fn failed_retained_activation_never_installs_unverified_serving_state() { .await .expect("mount retained generation"); - // Hold the scheduler after the worker dequeues the mount arrival. This - // makes the attempted activation observable through the existing pending - // arrival: zero while the attempt owns it, restored after the failure. + // Hold the scheduler after the worker enters the reconcile pass. The + // canonical in-progress authority proves the worker owns admission while + // the lock keeps the fallible retained activation from completing. let scheduler = { let mounted = registry.mounted.lock().await; Arc::clone( @@ -10006,12 +10006,15 @@ async fn failed_retained_activation_never_installs_unverified_serving_state() { let deadline = std::time::Instant::now() + Duration::from_secs(5); loop { - if registry.pending_wake_micros_for_scope(&scope).await == Some(0) { + if registry + .reconcile_in_progress_for_test(fixture.path()) + .await + { break; } assert!( std::time::Instant::now() <= deadline, - "worker did not dequeue the retained activation" + "worker did not enter the retained activation pass" ); tokio::time::sleep(Duration::from_millis(10)).await; } @@ -11299,13 +11302,11 @@ async fn same_root_remount_updates_retained_graph_policy_before_worker_activatio registry.shutdown().await; } -/// A retryable graph-activation failure of an already-sealed complete -/// generation must retry activation of that exact immutable artifact with -/// backoff. It must not fall through into reconcile and seal a duplicate -/// generation, even when the worktree has changed and overflow hints keep -/// waking the worker. +/// A retryable graph-activation failure may delay only native graph serving. +/// Exact and lexical refresh must still publish a changed worktree generation +/// while activation retries remain isolated to the immutable graph artifact. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn retryable_activation_failure_retries_the_sealed_generation_without_resealing() { +async fn retryable_graph_activation_does_not_block_changed_text_generation() { let sources = (0..512) .map(|index| { ( @@ -11343,8 +11344,9 @@ async fn retryable_activation_failure_retries_the_sealed_generation_without_rese latest.generation.manifest().generation_id.clone(), ) }; - // Change the worktree so a reconcile pass would seal a brand-new - // generation if the worker fell through after the activation failure. + // Change the worktree before mounting. Text reconciliation must publish + // this source even while native graph activation of the retained seal is + // retrying. fixture.edit("src/extra.rs", "pub fn extra() -> u32 { 2 }\n"); let generation_files = |scoped_store: &Path| -> usize { std::fs::read_dir(scoped_store.join("code-generations-v1")).map_or(0, |entries| { @@ -11404,33 +11406,37 @@ async fn retryable_activation_failure_retries_the_sealed_generation_without_rese tokio::task::yield_now().await; }; - // Keep waking the worker while activation stays failing; each pass must - // hold the sealed artifact instead of rebuilding. + // Keep waking the worker while activation stays failing. The graph owner + // remains unavailable, but source refresh must not be held behind it. for _ in 0..5 { let _ = registry.notify_hook_overflow(fixture.path()).await; tokio::time::sleep(Duration::from_millis(120)).await; } - assert_eq!( - generation_files(&scoped_store), - 1, - "a retryable activation failure must not seal a duplicate generation" - ); let text_deadline = std::time::Instant::now() + Duration::from_secs(10); - let text_owner_after_retry = loop { - if let Some(text) = registry.latest_text_serving_for_scope(&scope).await + let (text_owner_after_refresh, refreshed_generation_id) = loop { + let generation_id = registry.latest_generation_id(fixture.path()).await; + if let (Some(text), Some(generation_id)) = ( + registry.latest_text_serving_for_scope(&scope).await, + generation_id, + ) && generation_id != sealed_generation_id && text.query_owners_are_warm() { - break text; + break (text, generation_id); } assert!( std::time::Instant::now() <= text_deadline, - "graph retry backoff withheld exact and lexical readiness" + "graph retry backoff withheld the changed exact and lexical generation" ); tokio::time::sleep(Duration::from_millis(10)).await; }; assert!( - text_owner_after_retry.same_text_owner(&text_owner_before_retry), - "graph retry must preserve the lightweight text owner" + !text_owner_after_refresh.same_text_owner(&text_owner_before_retry), + "the changed generation must replace the retained text owner" + ); + assert_eq!( + generation_files(&scoped_store), + 2, + "source refresh must publish exactly one changed generation" ); { let scheduler = scheduler @@ -11440,34 +11446,20 @@ async fn retryable_activation_failure_retries_the_sealed_generation_without_rese let progress = progress .read() .unwrap_or_else(std::sync::PoisonError::into_inner); - assert_eq!( - progress.owner_epoch, owner_epoch_before_retry, - "graph activation must not replace the text progress authority" + assert!( + progress.owner_epoch > owner_epoch_before_retry, + "the changed generation must advance text progress authority" ); let progress = progress .snapshot() - .expect("ready text progress remains visible during graph retry"); - assert_eq!(progress.generation_id, progress_before_retry.generation_id); - assert_eq!( - progress.daemon_incarnation, - progress_before_retry.daemon_incarnation - ); - assert_eq!( - progress.producer_incarnation, - progress_before_retry.producer_incarnation - ); - assert!(progress.progress_epoch > progress_before_retry.progress_epoch); - assert!(progress.committed_pages >= progress_before_retry.committed_pages); + .expect("changed text progress remains visible during graph retry"); + assert_eq!(progress.generation_id, refreshed_generation_id.as_str()); + assert_ne!(progress.generation_id, progress_before_retry.generation_id); assert_eq!( progress.phase, crate::dashboard::code_index_freshness_api::CodeIndexBuildPhaseV1::Ready ); } - assert_eq!( - registry.latest_generation_id(fixture.path()).await, - Some(sealed_generation_id), - "exact and lexical serving remains authoritative during graph retry" - ); assert!( registry .latest_complete_serving_for_scope(&scope) @@ -11476,15 +11468,17 @@ async fn retryable_activation_failure_retries_the_sealed_generation_without_rese "retryable graph activation must not expose an unactivated graph owner" ); - // Clearing the injected failure lets the scheduled backoff retry activate - // the exact sealed artifact and resume ordinary refresh. + // Clearing the injected failure lets the scheduled backoff activate the + // changed generation without resealing it. super::graph_activation::set_injected_activation_failures(&sealed_worktree_id, 0); let deadline = std::time::Instant::now() + Duration::from_secs(10); loop { if registry .latest_complete_serving_for_scope(&scope) .await - .is_some() + .is_some_and(|latest| { + latest.generation().manifest().generation_id == refreshed_generation_id + }) { break; } From a1ae36ce1f49c475e88e11b99c1146ec0d9fae5a Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Wed, 26 Aug 2026 16:26:18 +0000 Subject: [PATCH 16/23] fix(index): prioritize text refresh before graph activation --- .../code_index_scheduler/graph_activation.rs | 68 ++++++ src/daemon/code_index_scheduler/registry.rs | 223 +++--------------- src/daemon/code_index_scheduler/tests.rs | 86 +++++++ 3 files changed, 188 insertions(+), 189 deletions(-) diff --git a/src/daemon/code_index_scheduler/graph_activation.rs b/src/daemon/code_index_scheduler/graph_activation.rs index 4f9aacd48b..0a242b4776 100644 --- a/src/daemon/code_index_scheduler/graph_activation.rs +++ b/src/daemon/code_index_scheduler/graph_activation.rs @@ -36,6 +36,60 @@ fn injected_resident_memory_refusals() REFUSALS.get_or_init(|| std::sync::Mutex::new(std::collections::BTreeSet::new())) } +#[cfg(test)] +struct InjectedActivationGateStateV1 { + started: tokio::sync::Notify, + release: tokio::sync::Notify, +} + +#[cfg(test)] +fn injected_activation_gates() +-> &'static std::sync::Mutex>> +{ + static GATES: std::sync::OnceLock< + std::sync::Mutex>>, + > = std::sync::OnceLock::new(); + GATES.get_or_init(|| std::sync::Mutex::new(std::collections::BTreeMap::new())) +} + +#[cfg(test)] +pub(super) struct InjectedActivationGateV1 { + state: Arc, +} + +#[cfg(test)] +impl InjectedActivationGateV1 { + pub(super) async fn wait_until_started(&self) { + self.state.started.notified().await; + } + + pub(super) fn release(&self) { + self.state.release.notify_one(); + } +} + +#[cfg(test)] +impl Drop for InjectedActivationGateV1 { + fn drop(&mut self) { + self.release(); + } +} + +#[cfg(test)] +pub(super) fn install_injected_activation_gate( + worktree_id: &WorktreeId, +) -> InjectedActivationGateV1 { + let state = Arc::new(InjectedActivationGateStateV1 { + started: tokio::sync::Notify::new(), + release: tokio::sync::Notify::new(), + }); + injected_activation_gates() + .lock() + .expect("injected activation gate map must not be poisoned") + .insert(worktree_id.as_str().to_owned(), Arc::clone(&state)); + InjectedActivationGateV1 { state } +} + #[cfg(test)] pub(super) fn set_injected_activation_failures(worktree_id: &WorktreeId, failures: usize) { let mut injected = injected_activation_failures() @@ -82,6 +136,16 @@ fn take_injected_activation_failure(worktree_id: &WorktreeId) -> bool { } } +#[cfg(test)] +fn take_injected_activation_gate( + worktree_id: &WorktreeId, +) -> Option> { + injected_activation_gates() + .lock() + .expect("injected activation gate map must not be poisoned") + .remove(worktree_id.as_str()) +} + #[derive(Clone)] pub(super) enum CodeGraphActivationAuthorityV1 { Persistent { @@ -187,6 +251,10 @@ impl CodeGraphActivationAuthorityV1 { } #[cfg(test)] Self::Memory { .. } => { + if let Some(gate) = take_injected_activation_gate(worktree_id) { + gate.started.notify_one(); + gate.release.notified().await; + } if has_injected_resident_memory_refusal(worktree_id) { latest.refuse_graph_activation( "code graph activation was refused by the resident-memory policy", diff --git a/src/daemon/code_index_scheduler/registry.rs b/src/daemon/code_index_scheduler/registry.rs index 8105b96287..70f8bdce65 100644 --- a/src/daemon/code_index_scheduler/registry.rs +++ b/src/daemon/code_index_scheduler/registry.rs @@ -1780,62 +1780,6 @@ impl CodeIndexSchedulerRegistryV1 { state.trigger = Self::pack_trigger(trigger); } - /// Seat an already-sealed retained generation as serving, but only while - /// it is still the active durable publication: install it, bump the - /// serving epoch, and re-offer semantic scheduling, then wake the worker. - /// Both graph-activation outcomes that leave the sealed artifact servable - /// (typed refusal and success) share this exact swap. - async fn seat_retained_serving_generation( - scheduler: &Arc>, - serving_generation: &Arc>>, - text_generation: &Arc>>, - serving_generation_epoch: &Arc, - wake: &Arc, - mut retained: LatestCompleteCodeIndexV1, - ) { - let swap_scheduler = Arc::clone(scheduler); - let swap_serving = Arc::clone(serving_generation); - let swap_text = Arc::clone(text_generation); - let swap_serving_epoch = Arc::clone(serving_generation_epoch); - let seated = tokio::task::spawn_blocking(move || { - let scheduler = swap_scheduler - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - if scheduler - .active_publication_matches(&retained) - .unwrap_or(false) - { - let mut text = swap_text - .write() - .unwrap_or_else(std::sync::PoisonError::into_inner); - if let Some(existing) = text.as_ref() - && existing.metadata().manifest().generation_id - == retained.generation().manifest().generation_id - { - retained.text = existing.clone(); - } else { - *text = Some(retained.text_generation_handle()); - } - drop(text); - let mut serving = swap_serving - .write() - .unwrap_or_else(std::sync::PoisonError::into_inner); - *serving = Some(retained.clone()); - swap_serving_epoch.fetch_add(1, Ordering::AcqRel); - drop(serving); - let _ = scheduler.schedule_semantic_generation(retained.generation_handle()); - true - } else { - false - } - }) - .await - .unwrap_or(false); - if seated { - wake.notify_one(); - } - } - /// Returns the pass's service time so the caller can attach the same /// measurement to the canonical index-lifecycle observation. fn record_reconcile_receipt( @@ -2413,8 +2357,6 @@ impl CodeIndexSchedulerRegistryV1 { return; } let scheduler = Arc::clone(&worker_scheduler); - let serving_generation = Arc::clone(&worker_serving_generation); - let serving_generation_epoch = Arc::clone(&worker_serving_generation_epoch); let graph_activation_enabled = worker_graph_activation.policy().is_enabled(); // Cover wake claim through failed-arrival restoration so admission // never misreads in-flight owner work as plain unavailability. @@ -2520,128 +2462,20 @@ impl CodeIndexSchedulerRegistryV1 { &worker_pending_wake, CodeIndexCadenceTriggerV1::Mount, ); - // Serve-during-refresh: seat the last complete compatible - // generation before rebuild. A cancelled refresh or branch - // split must not hide a sealed generation for the duration - // of reconcile. Stale is truthful; do not mark_reconciled. - let mut seat_retry_pending = false; - if graph_activation_enabled - && text_serving_ready - && serving_generation - .read() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .is_none() - { - let remount_scheduler = Arc::clone(&scheduler); - let remount_text = worker_text_generation - .read() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .clone(); - let remount = tokio::task::spawn_blocking(move || { - let mut scheduler = remount_scheduler - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - let retained = - scheduler.servable_retained_generation(remount_text.as_ref())?; - let replay_binding = scheduler.code_graph_replay_binding( - &retained.generation().manifest().generation_id, - ); - Some((retained, replay_binding)) - }) - .await; - if let Ok(Some((retained, replay_binding))) = remount { - if next_seat_attempt_at.is_some_and(|at| Instant::now() < at) { - // A sealed complete generation exists and its - // activation is backing off; hold this pass so the - // scheduled retry activates the same artifact. - seat_retry_pending = true; - } else { - let activation = match replay_binding { - Ok(replay_binding) => { - worker_graph_activation - .activate( - &worker_project_id, - &worker_repository_id, - &worker_worktree_id, - retained.clone(), - replay_binding, - Arc::clone(&worker_shutting_down), - ) - .await - } - Err(error) => Err(error), - }; - match activation { - Err(error) if error.is_graph_activation_refusal() => { - next_seat_attempt_at = None; - seat_retry_backoff = ACTIVATION_RETRY_BACKOFF_FLOOR; - Self::seat_retained_serving_generation( - &scheduler, - &serving_generation, - &worker_text_generation, - &serving_generation_epoch, - &worker_wake, - retained, - ) - .await; - } - Err(error) => { - let retryable = error.is_retryable_activation(); - tracing::warn!( - event = "code_index_retained_seat_failed", - path = "background_worker", - retryable, - error = %error, - "code-index retained generation did not activate; refresh continues without stale serving" - ); - if retryable { - next_seat_attempt_at = - Some(Instant::now() + seat_retry_backoff); - let retry_wake = Arc::clone(&worker_wake); - let retry_delay = seat_retry_backoff; - tokio::spawn(async move { - tokio::time::sleep(retry_delay).await; - retry_wake.notify_one(); - }); - seat_retry_backoff = seat_retry_backoff - .saturating_mul(2) - .min(ACTIVATION_RETRY_BACKOFF_CEILING); - seat_retry_pending = true; - } else { - next_seat_attempt_at = None; - seat_retry_backoff = ACTIVATION_RETRY_BACKOFF_FLOOR; - } - } - Ok(()) => { - next_seat_attempt_at = None; - seat_retry_backoff = ACTIVATION_RETRY_BACKOFF_FLOOR; - Self::seat_retained_serving_generation( - &scheduler, - &serving_generation, - &worker_text_generation, - &serving_generation_epoch, - &worker_wake, - retained, - ) - .await; - } - } - } - } - } // A retryable native-graph failure defers only graph - // activation. The retained text authority can still prove an - // unchanged source without resealing, or publish one changed - // generation from an authoritative capture. Suppress the - // complete-generation load below until the scheduled graph - // retry so graph backoff cannot stall exact and lexical - // freshness or trigger another activation attempt here. - let graph_activation_deferred = seat_retry_pending; - let retained_text_metadata = worker_text_generation + // activation. Reconcile and finish the lightweight text owner + // before opening the full generation: a large graph replay + // must never become exact/lexical time-to-ready. During + // backoff, the scheduled retry is the only pass that may open + // the immutable full generation again. + let graph_activation_deferred = + next_seat_attempt_at.is_some_and(|at| Instant::now() < at); + let retained_text = worker_text_generation .read() .unwrap_or_else(std::sync::PoisonError::into_inner) - .as_ref() - .map(|text| text.metadata().clone()); + .clone(); + let retained_text_metadata = + retained_text.as_ref().map(|text| text.metadata().clone()); let mut result = tokio::task::spawn_blocking(move || { let mut scheduler = scheduler .lock() @@ -2660,17 +2494,18 @@ impl CodeIndexSchedulerRegistryV1 { } else { scheduler.reconcile_now() }; - // A terminal outcome may publish a newer complete generation; - // swap serving to that after graph activation below. + // A publication must first reopen and finish its own + // lightweight text owner. Only a Noop proves that the + // retained Ready text owner and active durable pointer name + // the same generation, after which graph activation may + // safely load the full generation without delaying text. + let reconciled_current_text = + matches!(&result, Ok(CodeIndexReconcileOutcomeV1::Noop(_))); let mut latest = (graph_activation_enabled && text_serving_ready - && !graph_activation_deferred) - .then(|| { - result - .as_ref() - .ok() - .and_then(|_| scheduler.latest_complete()) - }) + && !graph_activation_deferred + && reconciled_current_text) + .then(|| scheduler.servable_retained_generation(retained_text.as_ref())) .flatten(); let replay_binding = latest.as_ref().map(|latest| { scheduler.code_graph_replay_binding( @@ -2750,14 +2585,21 @@ impl CodeIndexSchedulerRegistryV1 { Arc::clone(&worker_shutting_down), ) .await; - if let Err(error) = activation { - if error.is_graph_activation_refusal() { + match activation { + Ok(()) => { + next_seat_attempt_at = None; + seat_retry_backoff = ACTIVATION_RETRY_BACKOFF_FLOOR; + } + Err(error) if error.is_graph_activation_refusal() => { + next_seat_attempt_at = None; + seat_retry_backoff = ACTIVATION_RETRY_BACKOFF_FLOOR; tracing::warn!( event = "code_index_graph_activation_refused", error = %error, "code-index generation remains text-serving without native graph" ); - } else { + } + Err(error) => { // The generation just sealed is complete; a retryable // activation failure arms the same seat backoff so the // next passes retry this artifact instead of resealing. @@ -2772,6 +2614,9 @@ impl CodeIndexSchedulerRegistryV1 { seat_retry_backoff = seat_retry_backoff .saturating_mul(2) .min(ACTIVATION_RETRY_BACKOFF_CEILING); + } else { + next_seat_attempt_at = None; + seat_retry_backoff = ACTIVATION_RETRY_BACKOFF_FLOOR; } result = Ok((Err(error), None, None)); } diff --git a/src/daemon/code_index_scheduler/tests.rs b/src/daemon/code_index_scheduler/tests.rs index 4dababd645..99cb39b1a4 100644 --- a/src/daemon/code_index_scheduler/tests.rs +++ b/src/daemon/code_index_scheduler/tests.rs @@ -11491,6 +11491,92 @@ async fn retryable_graph_activation_does_not_block_changed_text_generation() { registry.shutdown().await; } +/// A graph projection can remain busy for minutes on a large retained +/// generation. Source reconciliation and the replacement text projection must +/// finish before that optional graph work starts, so exact and lexical serving +/// never inherit graph activation latency. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn changed_text_generation_is_ready_before_slow_graph_activation_starts() { + let sources = (0..64) + .map(|index| { + ( + format!("src/file_{index:04}.rs"), + format!("pub fn alpha_{index:04}() -> usize {{ {index} }}\n"), + ) + }) + .collect::>(); + let source_refs = sources + .iter() + .map(|(path, source)| (path.as_str(), source.as_str())) + .collect::>(); + let fixture = GitFixture::new(&source_refs); + let store = TempDir::new().expect("store root"); + let scoped_store = super::scoped_code_index_store_root( + store.path(), + &fixture.path().canonicalize().expect("canonical fixture"), + ); + let (scope, sealed_worktree_id, sealed_generation_id) = { + let mut scheduler = scheduler( + &fixture, + scoped_store, + Arc::new(SharedCodeIndexBytePoolV1::default()), + ); + published(scheduler.reconcile_now().expect("seed generation")); + let latest = scheduler.latest_complete().expect("seeded generation"); + let snapshot = latest.generation.snapshot(); + let worktree_id = snapshot.worktree.clone().expect("seeded worktree id"); + ( + ResolvedScope::new( + test_project_id(), + snapshot.repository.clone(), + worktree_id.clone(), + snapshot.reference.clone(), + ) + .expect("resolved scope"), + worktree_id, + latest.generation.manifest().generation_id.clone(), + ) + }; + fixture.edit("src/current.rs", "pub fn current() -> u32 { 2 }\n"); + + let activation_gate = + super::graph_activation::install_injected_activation_gate(&sealed_worktree_id); + let registry = CodeIndexSchedulerRegistryV1::with_background_reconcile_permits(1, 1); + registry + .mount_worktree( + test_project_id(), + fixture.path(), + store.path().to_path_buf(), + None, + ) + .await + .expect("mount retained generation"); + + tokio::time::timeout( + Duration::from_secs(10), + activation_gate.wait_until_started(), + ) + .await + .expect("graph activation did not reach the deterministic hold point"); + let observed_generation_id = registry.latest_generation_id(fixture.path()).await; + let text_ready = registry + .latest_text_serving_for_scope(&scope) + .await + .is_some_and(|text| text.query_owners_are_warm()); + activation_gate.release(); + + assert_ne!( + observed_generation_id, + Some(sealed_generation_id), + "slow graph activation started before the changed text generation replaced the retained seal" + ); + assert!( + text_ready, + "slow graph activation started before the changed generation became exact/lexical ready" + ); + registry.shutdown().await; +} + /// Busy admission preserves the prior generation and schedules a follow-up wake /// so serve-during-refresh cannot leave the index stale indefinitely. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] From e36a518a417ccc65fbb7d69b9c7d91c30ac014d2 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Wed, 26 Aug 2026 17:11:20 +0000 Subject: [PATCH 17/23] perf(mcp): detach optional tool activity writes --- src/mcp/server.rs | 5 ++ src/mcp/server/requests.rs | 129 +++++++++++++++++++++++++++++++++---- 2 files changed, 122 insertions(+), 12 deletions(-) diff --git a/src/mcp/server.rs b/src/mcp/server.rs index 0addb4a99f..0bc2b7b77e 100644 --- a/src/mcp/server.rs +++ b/src/mcp/server.rs @@ -236,6 +236,10 @@ pub struct McpServer { /// Retains non-request maintenance futures so shutdown can fence task /// admission, cancel every live future, and join it before stores close. background_tasks: McpBackgroundTaskOwner, + /// Single-flights optional tool-activity persistence. A busy session-store + /// writer may delay this local-detail observation, but it must never delay + /// the foreground tool whose activity is being observed. + tool_activity_publish_running: Arc, stats: ServerStats, method_call_counts: std::sync::Mutex>, resource_read_counts: std::sync::Mutex>, @@ -955,6 +959,7 @@ impl McpServer { branch_reopen: Arc::new(tokio::sync::Mutex::new(())), branch_reopen_completions: Arc::new(AtomicU64::new(0)), background_tasks: McpBackgroundTaskOwner::default(), + tool_activity_publish_running: Arc::new(AtomicBool::new(false)), stats: ServerStats::new(), method_call_counts: std::sync::Mutex::new(HashMap::new()), resource_read_counts: std::sync::Mutex::new(HashMap::new()), diff --git a/src/mcp/server/requests.rs b/src/mcp/server/requests.rs index 40c55b7359..213a3a0198 100644 --- a/src/mcp/server/requests.rs +++ b/src/mcp/server/requests.rs @@ -33,6 +33,14 @@ struct RoutedToolCall { selected_server: Option>, } +struct ToolActivityPublishRunning(Arc); + +impl Drop for ToolActivityPublishRunning { + fn drop(&mut self) { + self.0.store(false, Ordering::Release); + } +} + struct ToolTokenAccounting { raw_file_tokens: u64, response_tokens: u64, @@ -862,7 +870,7 @@ impl McpServer { .entry(tool_name.to_string()) .or_insert(0) += 1; if publish_activity { - self.publish_tool_call_activity(tool_name, cg).await; + self.publish_tool_call_activity(tool_name, cg); } } @@ -1208,22 +1216,38 @@ impl McpServer { } } - async fn publish_tool_call_activity(&self, tool_name: &str, cg: &TraceDecay) { + fn publish_tool_call_activity(&self, tool_name: &str, cg: &TraceDecay) { if !tracedecay_usecases::event_lane::enabled(self.session_db.as_deref()) { return; } - let Some(activity_db) = self.session_db.as_deref() else { + if self + .tool_activity_publish_running + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) + .is_err() + { + return; + } + let Some(activity_db) = self.session_db.clone() else { + self.tool_activity_publish_running + .store(false, Ordering::Release); return; }; - tracedecay_usecases::event_lane::publish( - activity_db, - tracedecay_usecases::event_lane::ActivityFamilyV1::ToolCall, - cg.project_root(), - cg.store_layout().identity.project_id.as_deref(), - 1, - Some(tool_name), - ) - .await; + let running = ToolActivityPublishRunning(Arc::clone(&self.tool_activity_publish_running)); + let project_root = cg.project_root().to_path_buf(); + let project_id = cg.store_layout().identity.project_id.clone(); + let tool_name = tool_name.to_owned(); + self.spawn_background_task(async move { + let _running = running; + tracedecay_usecases::event_lane::publish( + &activity_db, + tracedecay_usecases::event_lane::ActivityFamilyV1::ToolCall, + &project_root, + project_id.as_deref(), + 1, + Some(&tool_name), + ) + .await; + }); } fn message_search_worker_is_unavailable(&self, tool_name: &str, arguments: &Value) -> bool { @@ -1641,3 +1665,84 @@ mod git_read_control_tests { assert!(is_controlled_read_tool("tracedecay_search")); } } + +#[cfg(test)] +mod activity_dispatch_tests { + use super::*; + + /// Optional activity persistence must never serialize a foreground tool + /// read behind the project-session writer. The write remains daemon-owned + /// and durable once that writer becomes available. + #[tokio::test] + async fn tool_dispatch_does_not_wait_for_activity_persistence() { + let (cg, _dir, authority) = + crate::mcp::server::writer_test_support::init_indexed_repo().await; + let context = crate::mcp::server::writer_test_support::registered_context(cg, &authority); + let server = McpServer::new_with_registered_test_context(context, Vec::new()) + .await + .expect("registered test server"); + let (graph, live_branch) = server.reopen_if_branch_drifted_memoized().await; + let activity_db = server + .session_db + .as_deref() + .expect("registered project-session activity authority"); + let project_id = activity_db + .binding() + .shard_id + .scope + .project_id() + .expect("project-scoped activity store") + .as_str() + .to_owned(); + assert_eq!( + graph.store_layout().identity.project_id.as_deref(), + Some(project_id.as_str()), + "the graph and registered activity authority must name the same project", + ); + let blocked_writer = activity_db + .begin_write_transaction() + .await + .expect("hold activity writer"); + + tokio::time::timeout( + std::time::Duration::from_millis(250), + server.begin_tool_dispatch("tracedecay_status", &graph, &live_branch, false, true), + ) + .await + .expect("foreground dispatch must not wait for optional activity persistence"); + + blocked_writer + .commit() + .await + .expect("release activity writer"); + tokio::time::timeout(std::time::Duration::from_secs(5), async { + while server.tool_activity_publish_running.load(Ordering::Acquire) { + tokio::task::yield_now().await; + } + }) + .await + .expect("detached activity persistence must settle after writer release"); + tokio::time::timeout(std::time::Duration::from_secs(5), async { + loop { + let observed = + tracedecay_usecases::event_lane::replay_after(activity_db, &project_id, None) + .await + .is_some_and(|replay| { + replay.records.iter().any(|record| { + record.pulse.family + == tracedecay_usecases::event_lane::ActivityFamilyV1::ToolCall + && record.pulse.detail.is_none() + }) + }); + if observed { + break; + } + tokio::task::yield_now().await; + } + }) + .await + .expect("detached activity persistence must complete after writer release"); + + server.shutdown().await; + } +} From bea08d2532599dabba145c61792cee8c000ea44e Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Wed, 26 Aug 2026 17:44:07 +0000 Subject: [PATCH 18/23] fix(index): prune history before text attachment --- .../src/retention/code_index_generations.rs | 75 +++++++++++++++++++ 1 file changed, 75 insertions(+) diff --git a/crates/tracedecay-usecases/src/retention/code_index_generations.rs b/crates/tracedecay-usecases/src/retention/code_index_generations.rs index 6cfa8279df..ff2b67d74a 100644 --- a/crates/tracedecay-usecases/src/retention/code_index_generations.rs +++ b/crates/tracedecay-usecases/src/retention/code_index_generations.rs @@ -311,6 +311,7 @@ pub fn attach_verified_text_artifact_under_lock( )); } validate_durable_generation_index(&pointer)?; + let attached_generation_id = descriptor.generation_id.clone(); let entry = pointer .generation_index .iter_mut() @@ -337,6 +338,13 @@ pub fn attach_verified_text_artifact_under_lock( } None => entry.text_artifact = Some(descriptor), } + let active_generation_id = pointer.generation_id.clone(); + let removed = retain_bounded_generation_index_with_text_head( + &mut pointer.generation_index, + &active_generation_id, + Some(attached_generation_id.as_str()), + ); + pointer.generation_index_truncated |= removed > 0; pointer.generation_index_digest = Some(durable_generation_index_digest( &pointer.generation_index, pointer.generation_index_truncated, @@ -4718,6 +4726,73 @@ mod tests { ); } + #[test] + fn verified_text_artifact_attachment_retires_history_before_enforcing_byte_bound() { + let (store, generations) = fixture_store(2); + let prior = &generations[0]; + let active = &generations[1]; + let mut pointer = read_active_pointer(store.path()).expect("active pointer"); + let prior_artifact = + text_artifact(&prior.id, 31, MAX_DURABLE_GENERATION_INDEX_BYTES_V1 / 2); + pointer.generation_index.insert( + 0, + DurableGenerationIndexEntryV1 { + generation_id: prior.id.as_str().to_owned(), + snapshot_content_identity: "snapshot.prior".to_owned(), + sealed_at_micros: 0, + size_bytes: prior.size_bytes, + generation_file: prior.file.clone(), + state_digest: prior.state_digest.clone(), + source_reference: None, + source_revision: None, + source_tree: None, + cardinality: None, + text_artifact: Some(prior_artifact), + }, + ); + pointer.generation_index_digest = Some( + durable_generation_index_digest( + &pointer.generation_index, + pointer.generation_index_truncated, + ) + .expect("prior index digest"), + ); + std::fs::write( + store.path().join(ACTIVE_POINTER_FILE), + serde_json::to_vec(&pointer).expect("serialize prior pointer"), + ) + .expect("write prior pointer"); + + let descriptor = text_artifact(&active.id, 32, MAX_DURABLE_GENERATION_INDEX_BYTES_V1 / 2); + let sealed_identity = DurableSealedCodeGenerationIdentityV1 { + locator: active.file.clone(), + digest: ManifestDigest::new(active.state_digest.clone()).expect("sealed digest"), + size_bytes: active.size_bytes, + }; + let lock = acquire_code_generation_store_lock(store.path()).expect("generation store lock"); + + let updated = attach_verified_text_artifact_under_lock( + &lock, + &pointer, + &sealed_identity, + descriptor.clone(), + ) + .expect("attach active text artifact under byte pressure"); + drop(lock); + + assert!(updated.generation_index_truncated); + assert_eq!(updated.generation_index.len(), 1); + assert_eq!( + updated.generation_index[0].generation_id, + active.id.as_str() + ); + assert_eq!(updated.generation_index[0].text_artifact, Some(descriptor)); + assert_eq!( + read_active_pointer(store.path()).expect("durable pointer"), + updated + ); + } + #[test] fn verified_text_artifact_withdrawal_is_exact_durable_and_idempotent() { let (store, generations) = fixture_store(1); From 08b1c0237dd1509bcff40efffd37403af683d94c Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Wed, 26 Aug 2026 19:02:38 +0000 Subject: [PATCH 19/23] fix(index): detach graph work from text freshness --- src/daemon/code_index_scheduler.rs | 46 +++++-- src/daemon/code_index_scheduler/registry.rs | 91 ++++++++----- src/daemon/code_index_scheduler/tests.rs | 140 +++++++++++++++++++- 3 files changed, 229 insertions(+), 48 deletions(-) diff --git a/src/daemon/code_index_scheduler.rs b/src/daemon/code_index_scheduler.rs index 2469a3d215..7721ae4bf2 100644 --- a/src/daemon/code_index_scheduler.rs +++ b/src/daemon/code_index_scheduler.rs @@ -345,6 +345,8 @@ struct DecodedGenerationCacheV1 { /// Sealed-bytes decodes actually performed by this process. Test probe for /// "the serving path did not re-decode". decodes: AtomicU64, + #[cfg(test)] + active_waiters: AtomicUsize, } impl DecodedGenerationCacheV1 { @@ -449,6 +451,13 @@ impl Drop for HeldActiveDecodeV1 { } } +#[cfg(test)] +impl HeldActiveDecodeV1 { + pub(super) fn waiter_count(&self) -> usize { + self.cache.active_waiters.load(Ordering::Acquire) + } +} + /// Last validated publication pointer, reused when the on-disk file is unchanged. struct PublicationPointerMemoV1 { mtime: Option, @@ -473,7 +482,7 @@ impl UndecodedActivePublicationExpectationV1 { } #[derive(Clone)] -struct DaemonCodeIndexPublicationStoreV1 { +pub(super) struct DaemonCodeIndexPublicationStoreV1 { cache: Arc, active_encoded_bytes: Arc, active_path: PathBuf, @@ -1112,11 +1121,16 @@ impl DaemonCodeIndexPublicationStoreV1 { if state.is_in_flight(&DecodeSubjectV1::Active) { // Another caller already owns this O(store) decode. Park on it // rather than starting a second sweep over the same bytes. - let _parked = self + #[cfg(test)] + self.cache.active_waiters.fetch_add(1, Ordering::AcqRel); + let parked = self .cache .ready .wait(state) - .map_err(|_| DecodedGenerationCacheV1::poisoned())?; + .map_err(|_| DecodedGenerationCacheV1::poisoned()); + #[cfg(test)] + self.cache.active_waiters.fetch_sub(1, Ordering::AcqRel); + let _parked = parked?; continue; } let epoch = state.active_epoch; @@ -4519,17 +4533,22 @@ impl CodeIndexWorktreeSchedulerV1 { Ok(Some(outcome)) } - /// Load a complete identity-valid generation for stale serving. - /// - /// This does not claim freshness: a cancelled refresh or live ref switch - /// leaves the worktree ahead of the sealed generation, and that split is a - /// truthful stale serving state. Worktree identity must still resolve so a - /// missing Git authority stays unverified rather than a stale answer. - /// An ignored-source roster is revalidated against the live worktree - /// before seating: a tracked or retargeted admission must not become - /// serving, and the scheduler must not keep that roster. - pub(super) fn servable_retained_generation( + /// Clone the immutable publication decoder so an optional graph replay can + /// read and authenticate the O(store) sealed generation without occupying + /// the mutable scheduler mutex. The decoded generation is not servable + /// until [`Self::servable_decoded_retained_generation`] revalidates and + /// binds it under the scheduler authority. + pub(super) fn active_generation_decoder(&self) -> Option { + (!self.shutting_down.load(Ordering::Acquire)).then(|| self.publication.clone()) + } + + /// Validate and bind a generation decoded through the detached immutable + /// publication authority. Identity and ignored-source roster checks remain + /// serialized with reconciliation; only sealed-byte I/O happens outside + /// the scheduler mutex. + pub(super) fn servable_decoded_retained_generation( &mut self, + generation: Arc, retained_text: Option<&LatestCodeTextGenerationV1>, ) -> Option { if self.shutting_down.load(Ordering::Acquire) { @@ -4539,7 +4558,6 @@ impl CodeIndexWorktreeSchedulerV1 { if !resolved.authorizes_reuse_of(&self.identity) { return None; } - let generation = self.publication.load_active_shared().ok().flatten()?; self.validate_generation_identity(&generation).ok()?; self.adopt_ignored_source_roster(&generation); if !self.ignored_source_roster_matches_generation(&generation) { diff --git a/src/daemon/code_index_scheduler/registry.rs b/src/daemon/code_index_scheduler/registry.rs index 70f8bdce65..04a536db2c 100644 --- a/src/daemon/code_index_scheduler/registry.rs +++ b/src/daemon/code_index_scheduler/registry.rs @@ -2476,11 +2476,11 @@ impl CodeIndexSchedulerRegistryV1 { .clone(); let retained_text_metadata = retained_text.as_ref().map(|text| text.metadata().clone()); - let mut result = tokio::task::spawn_blocking(move || { + let source_result = tokio::task::spawn_blocking(move || { let mut scheduler = scheduler .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); - let mut result = if let Some(metadata) = retained_text_metadata { + if let Some(metadata) = retained_text_metadata { match scheduler.reconcile_retained_text_generation(&metadata) { Ok(Some(outcome)) => Ok(outcome), Ok(None) if graph_activation_enabled => { @@ -2493,36 +2493,67 @@ impl CodeIndexSchedulerRegistryV1 { scheduler.activate_or_reconcile() } else { scheduler.reconcile_now() - }; - // A publication must first reopen and finish its own - // lightweight text owner. Only a Noop proves that the - // retained Ready text owner and active durable pointer name - // the same generation, after which graph activation may - // safely load the full generation without delaying text. - let reconciled_current_text = - matches!(&result, Ok(CodeIndexReconcileOutcomeV1::Noop(_))); - let mut latest = (graph_activation_enabled - && text_serving_ready - && !graph_activation_deferred - && reconciled_current_text) - .then(|| scheduler.servable_retained_generation(retained_text.as_ref())) - .flatten(); - let replay_binding = latest.as_ref().map(|latest| { - scheduler.code_graph_replay_binding( - &latest.generation().manifest().generation_id, - ) - }); - let replay_binding = match replay_binding.transpose() { - Ok(binding) => binding, - Err(error) => { - result = Err(error); - latest = None; - None - } - }; - (result, latest, replay_binding) + } }) .await; + // A publication must first reopen and finish its own + // lightweight text owner. Only a Noop proves that the retained + // Ready text owner and active durable pointer name the same + // generation. At that point source reconciliation is complete: + // release its public freshness guard before the optional + // O(store) full decode and native graph activation begin. + let prepare_graph = graph_activation_enabled + && text_serving_ready + && !graph_activation_deferred + && matches!(&source_result, Ok(Ok(CodeIndexReconcileOutcomeV1::Noop(_)))); + if prepare_graph { + drop(_reconcile_pass); + } + let mut result = match source_result { + Ok(mut outcome) if prepare_graph => { + let graph_scheduler = Arc::clone(&worker_scheduler); + let graph_text = retained_text.clone(); + match tokio::task::spawn_blocking(move || { + let decoder = graph_scheduler + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .active_generation_decoder(); + let generation = decoder + .and_then(|decoder| decoder.load_active_shared().ok().flatten()); + let latest = generation.and_then(|generation| { + graph_scheduler + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .servable_decoded_retained_generation( + generation, + graph_text.as_ref(), + ) + }); + let replay_binding = latest.as_ref().map(|latest| { + graph_scheduler + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .code_graph_replay_binding( + &latest.generation().manifest().generation_id, + ) + }); + replay_binding.transpose().map(|binding| (latest, binding)) + }) + .await + { + Ok(Ok((latest, replay_binding))) => { + Ok((outcome, latest, replay_binding)) + } + Ok(Err(error)) => { + outcome = Err(error); + Ok((outcome, None, None)) + } + Err(error) => Err(error), + } + } + Ok(outcome) => Ok((outcome, None, None)), + Err(error) => Err(error), + }; if matches!( &result, Ok((Ok(CodeIndexReconcileOutcomeV1::Published(_)), None, None)) diff --git a/src/daemon/code_index_scheduler/tests.rs b/src/daemon/code_index_scheduler/tests.rs index 99cb39b1a4..ab2e149024 100644 --- a/src/daemon/code_index_scheduler/tests.rs +++ b/src/daemon/code_index_scheduler/tests.rs @@ -11344,10 +11344,6 @@ async fn retryable_graph_activation_does_not_block_changed_text_generation() { latest.generation.manifest().generation_id.clone(), ) }; - // Change the worktree before mounting. Text reconciliation must publish - // this source even while native graph activation of the retained seal is - // retrying. - fixture.edit("src/extra.rs", "pub fn extra() -> u32 { 2 }\n"); let generation_files = |scoped_store: &Path| -> usize { std::fs::read_dir(scoped_store.join("code-generations-v1")).map_or(0, |entries| { entries @@ -11405,6 +11401,21 @@ async fn retryable_graph_activation_does_not_block_changed_text_generation() { ); tokio::task::yield_now().await; }; + assert_eq!( + text_owner_before_retry.metadata().manifest().generation_id, + sealed_generation_id, + "the pre-edit observation must own the retained generation" + ); + assert_eq!( + progress_before_retry.generation_id, + sealed_generation_id.as_str(), + "the pre-edit progress must belong to the retained generation" + ); + + // Change the worktree only after the retained text owner is observably + // advancing. The retry journey must replace that exact owner with the new + // source generation while native graph activation keeps failing. + fixture.edit("src/extra.rs", "pub fn extra() -> u32 { 2 }\n"); // Keep waking the worker while activation stays failing. The graph owner // remains unavailable, but source refresh must not be held behind it. @@ -11558,6 +11569,20 @@ async fn changed_text_generation_is_ready_before_slow_graph_activation_starts() ) .await .expect("graph activation did not reach the deterministic hold point"); + assert!( + !registry + .reconcile_in_progress_for_test(fixture.path()) + .await, + "optional graph activation must not keep source reconciliation in progress" + ); + let (_, text_is_current) = registry + .latest_text_serving_freshness_for_scope(&scope) + .await + .expect("ready text generation remains queryable during graph activation"); + assert!( + text_is_current, + "slow graph activation must not mark reconciled exact and lexical serving stale" + ); let observed_generation_id = registry.latest_generation_id(fixture.path()).await; let text_ready = registry .latest_text_serving_for_scope(&scope) @@ -11577,6 +11602,113 @@ async fn changed_text_generation_is_ready_before_slow_graph_activation_starts() registry.shutdown().await; } +/// Decoding the immutable sealed generation for optional graph activation may +/// take tens of seconds on a cold large repository. That decode must not hold +/// the mutable scheduler mutex: exact/lexical freshness still needs its cheap +/// witness check while graph preparation is parked or reading the seal. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn graph_decode_does_not_block_text_freshness() { + let fixture = GitFixture::new(ALPHA_LIB_V1); + let store = TempDir::new().expect("store root"); + let scoped_store = super::scoped_code_index_store_root( + store.path(), + &fixture.path().canonicalize().expect("canonical fixture"), + ); + let scope = { + let mut scheduler = scheduler( + &fixture, + scoped_store, + Arc::new(SharedCodeIndexBytePoolV1::default()), + ); + published(scheduler.reconcile_now().expect("seed generation")); + let latest = scheduler.latest_complete().expect("seeded generation"); + let snapshot = latest.generation.snapshot(); + ResolvedScope::new( + test_project_id(), + snapshot.repository.clone(), + snapshot.worktree.clone().expect("worktree identity"), + snapshot.reference.clone(), + ) + .expect("resolved scope") + }; + + let registry = CodeIndexSchedulerRegistryV1::with_background_reconcile_permits(1, 1); + registry + .mount_worktree_with_graph_policy( + test_project_id(), + fixture.path(), + store.path().to_path_buf(), + None, + super::CodeGraphActivationPolicyV1::RefusedByConfiguration, + ) + .await + .expect("mount text-only retained generation"); + tokio::time::timeout(Duration::from_secs(10), async { + loop { + if registry + .latest_text_serving_freshness_for_scope(&scope) + .await + .is_some_and(|(text, current)| text.query_owners_are_warm() && current) + { + break; + } + tokio::task::yield_now().await; + } + }) + .await + .expect("text generation did not become ready and current"); + + let scheduler = registry + .scheduler_handle(fixture.path()) + .await + .expect("mounted scheduler"); + let held_decode = scheduler + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .hold_active_decode(); + assert!( + !registry + .mount_worktree_with_graph_policy( + test_project_id(), + fixture.path(), + store.path().to_path_buf(), + None, + super::CodeGraphActivationPolicyV1::Enabled, + ) + .await + .expect("enable graph activation on the retained owner"), + "same-root policy update must retain the mounted owner" + ); + assert!(registry.notify_hook_overflow(fixture.path()).await); + tokio::time::timeout(Duration::from_secs(10), async { + loop { + if held_decode.waiter_count() > 0 { + break; + } + tokio::task::yield_now().await; + } + }) + .await + .expect("graph preparation did not park on the held decode barrier"); + assert!( + !registry + .reconcile_in_progress_for_test(fixture.path()) + .await, + "the source pass must finish before the optional graph decode" + ); + let (_, current) = registry + .latest_text_serving_freshness_for_scope(&scope) + .await + .expect("ready text remains queryable during graph decode"); + assert!( + current, + "optional graph decode must not block the exact/lexical freshness witness" + ); + + drop(held_decode); + registry.shutdown().await; +} + /// Busy admission preserves the prior generation and schedules a follow-up wake /// so serve-during-refresh cannot leave the index stale indefinitely. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] From a4c9c64fda6e2f15d52164354636d6173a3238f9 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Wed, 26 Aug 2026 19:49:23 +0000 Subject: [PATCH 20/23] perf(graph): reuse recovered digest buffer --- crates/tracedecay-graph-db/src/generation.rs | 214 ++++++++++++------ .../src/generation/recovered.rs | 121 +++++----- .../src/generation_runtime.rs | 9 +- 3 files changed, 208 insertions(+), 136 deletions(-) diff --git a/crates/tracedecay-graph-db/src/generation.rs b/crates/tracedecay-graph-db/src/generation.rs index 27b91098f0..b9c80dfbb7 100644 --- a/crates/tracedecay-graph-db/src/generation.rs +++ b/crates/tracedecay-graph-db/src/generation.rs @@ -682,6 +682,8 @@ thread_local! { const { std::cell::Cell::new(0) }; static MANIFEST_CANONICALIZATIONS: std::cell::Cell = const { std::cell::Cell::new(0) }; + static CANONICAL_BUFFER_ALLOCATION_GROWTHS: std::cell::Cell = + const { std::cell::Cell::new(0) }; } #[cfg(test)] @@ -704,6 +706,16 @@ pub(crate) fn manifest_canonicalizations() -> usize { MANIFEST_CANONICALIZATIONS.with(std::cell::Cell::get) } +#[cfg(test)] +pub(crate) fn reset_canonical_buffer_allocation_growths() { + CANONICAL_BUFFER_ALLOCATION_GROWTHS.with(|count| count.set(0)); +} + +#[cfg(test)] +pub(crate) fn canonical_buffer_allocation_growths() -> usize { + CANONICAL_BUFFER_ALLOCATION_GROWTHS.with(std::cell::Cell::get) +} + fn physical_namespace_projection_map( manifest: &GraphGenerationManifest, ) -> Result, GraphDbError> { @@ -769,81 +781,66 @@ fn recovered_generation_digest( } = manifest; let mut digest = Sha256::new(); let mut writer = CheckedDigestWriter::new(&mut digest, check); - for (tag, value) in [ - ( - "format", - checked_canonical_bytes( - "tracedecay.graph-generation.v1", - check, - "recovered generation format", - MAX_GRAPH_REPLAY_SOURCE_BYTES_V1, - ), - ), - ( - "projection", - checked_canonical_bytes( - projection, - check, - "recovered generation projection", - MAX_GRAPH_REPLAY_SOURCE_BYTES_V1, - ), - ), - ( - "generation", - checked_canonical_bytes( - generation, - check, - "recovered generation identity", - MAX_GRAPH_REPLAY_SOURCE_BYTES_V1, - ), - ), - ( - "source_generation", - checked_canonical_bytes( - source_generation, - check, - "recovered source generation", - MAX_GRAPH_REPLAY_SOURCE_BYTES_V1, - ), - ), - ( - "watermark", - checked_canonical_bytes( - watermark, - check, - "recovered generation watermark", - MAX_GRAPH_REPLAY_SOURCE_BYTES_V1, - ), - ), - ( - "dependencies", - checked_canonical_bytes( - dependencies, - check, - "recovered generation dependencies", - MAX_GRAPH_REPLAY_SOURCE_BYTES_V1, - ), - ), - ] { - write_frame(&mut writer, tag, &value?)?; - } + let mut canonical = CheckedVecWriter::new(check, MAX_GRAPH_REPLAY_SOURCE_BYTES_V1)?; + write_canonical_frame( + &mut writer, + &mut canonical, + "format", + "tracedecay.graph-generation.v1", + "recovered generation format", + )?; + write_canonical_frame( + &mut writer, + &mut canonical, + "projection", + projection, + "recovered generation projection", + )?; + write_canonical_frame( + &mut writer, + &mut canonical, + "generation", + generation, + "recovered generation identity", + )?; + write_canonical_frame( + &mut writer, + &mut canonical, + "source_generation", + source_generation, + "recovered source generation", + )?; + write_canonical_frame( + &mut writer, + &mut canonical, + "watermark", + watermark, + "recovered generation watermark", + )?; + write_canonical_frame( + &mut writer, + &mut canonical, + "dependencies", + dependencies, + "recovered generation dependencies", + )?; for entity in entities { - let bytes = checked_canonical_bytes( + write_canonical_frame( + &mut writer, + &mut canonical, + "entity", entity, - check, "recovered generation entity", - MAX_GRAPH_REPLAY_SOURCE_BYTES_V1, )?; - write_frame(&mut writer, "entity", &bytes)?; } for relation in relations { - let bytes = checked_canonical_bytes( + write_canonical_frame( + &mut writer, + &mut canonical, + "relation", relation, - check, "recovered generation relation", - MAX_GRAPH_REPLAY_SOURCE_BYTES_V1, )?; - write_frame(&mut writer, "relation", &bytes)?; } writer.finish()?; Ok(encode_lowercase_hex(&digest.finalize())) @@ -864,6 +861,17 @@ fn write_frame( write_digest_bytes(writer, bytes) } +fn write_canonical_frame( + writer: &mut CheckedDigestWriter<'_>, + canonical: &mut CheckedVecWriter<'_>, + tag: &str, + value: &T, + subject: &str, +) -> Result<(), GraphDbError> { + let bytes = canonical.encode(value, subject)?; + write_frame(writer, tag, bytes) +} + fn write_digest_bytes( writer: &mut CheckedDigestWriter<'_>, bytes: &[u8], @@ -967,6 +975,26 @@ impl<'a> CheckedVecWriter<'a> { Ok(self.bytes) } + fn encode( + &mut self, + value: &T, + subject: &str, + ) -> Result<&[u8], GraphDbError> { + self.bytes.clear(); + self.bytes_since_check = 0; + self.failure = None; + (self.check)()?; + let encoded = serde_json::to_writer(&mut *self, value); + if let Some(error) = self.failure.take() { + return Err(error); + } + (self.check)()?; + encoded.map_err(|error| { + GraphDbError::invalid(format!("failed to encode {subject}: {error}")) + })?; + Ok(&self.bytes) + } + #[cfg(test)] fn allocation_growths(&self) -> usize { self.allocation_growths @@ -1016,6 +1044,9 @@ impl Write for CheckedVecWriter<'_> { #[cfg(test)] if self.bytes.capacity() != capacity_before_reserve { self.allocation_growths += 1; + CANONICAL_BUFFER_ALLOCATION_GROWTHS.with(|count| { + count.set(count.get().saturating_add(1)); + }); } } let length = u64::try_from(bytes.len()) @@ -1059,10 +1090,20 @@ fn checked_canonical_bytes( #[cfg(test)] mod checked_vec_writer_tests { + use std::collections::{BTreeMap, BTreeSet}; + use sha2::{Digest, Sha256}; use tracedecay_domain::canonical_text::encode_lowercase_hex; - use super::{CheckedVecWriter, GraphDbError, checked_canonical_bytes}; + use super::{ + CheckedVecWriter, GraphDbError, GraphGenerationManifest, + canonical_buffer_allocation_growths, checked_canonical_bytes, recovered_generation_digest, + reset_canonical_buffer_allocation_growths, + }; + use crate::{ + GraphEntity, GraphEntityId, GraphGenerationId, GraphNamespace, GraphProjectionId, + GraphProjectionIdentity, GraphWatermark, SourceGeneration, + }; #[test] fn many_tiny_serde_writes_use_bounded_amortized_growth() { @@ -1117,4 +1158,43 @@ mod checked_vec_writer_tests { assert!(writer.bytes.len() <= max_bytes); assert!(writer.bytes.capacity() <= max_bytes); } + + #[test] + fn recovered_digest_reuses_canonical_buffer_across_manifest_rows() { + let manifest = GraphGenerationManifest::new( + GraphProjectionIdentity::new( + GraphNamespace::new("allocation-probe").unwrap(), + GraphProjectionId::new("manifest").unwrap(), + ), + GraphGenerationId::new("generation-allocation-probe").unwrap(), + SourceGeneration::new("source-allocation-probe").unwrap(), + GraphWatermark::new("watermark-allocation-probe").unwrap(), + vec![], + (0..4_096) + .map(|index| { + GraphEntity::new( + GraphEntityId::new(format!("entity:{index:05}")).unwrap(), + BTreeSet::new(), + BTreeMap::new(), + ) + .unwrap() + }) + .collect(), + vec![], + ) + .unwrap(); + + reset_canonical_buffer_allocation_growths(); + let digest = recovered_generation_digest(&manifest, &|| Ok(())).unwrap(); + let allocation_growths = canonical_buffer_allocation_growths(); + assert_eq!( + digest, + "786f46a4a0f263e5c67927f2a196ce95bd7071733478fb94560c5736dce44f9f" + ); + + assert!( + allocation_growths <= 8, + "4,096 manifest rows caused {allocation_growths} canonical-buffer allocation growths" + ); + } } diff --git a/crates/tracedecay-graph-db/src/generation/recovered.rs b/crates/tracedecay-graph-db/src/generation/recovered.rs index 0ed2354a3e..4ad666fcad 100644 --- a/crates/tracedecay-graph-db/src/generation/recovered.rs +++ b/crates/tracedecay-graph-db/src/generation/recovered.rs @@ -10,8 +10,8 @@ use crate::state::{ }; use super::{ - CheckedDigestWriter, GraphGenerationManifest, GraphGenerationRelation, checked_canonical_bytes, - physical_namespace_projection_map, recovered_entity_ref, write_frame, + CheckedDigestWriter, CheckedVecWriter, GraphGenerationManifest, GraphGenerationRelation, + physical_namespace_projection_map, recovered_entity_ref, write_canonical_frame, }; pub(crate) fn recovered_generation_digest_from_database( @@ -21,64 +21,49 @@ pub(crate) fn recovered_generation_digest_from_database( ) -> Result { let mut digest = Sha256::new(); let mut writer = CheckedDigestWriter::new(&mut digest, check); - for (tag, value) in [ - ( - "format", - checked_canonical_bytes( - "tracedecay.graph-generation.v1", - check, - "recovered generation format", - MAX_GRAPH_REPLAY_SOURCE_BYTES_V1, - ), - ), - ( - "projection", - checked_canonical_bytes( - &manifest.projection, - check, - "recovered generation projection", - MAX_GRAPH_REPLAY_SOURCE_BYTES_V1, - ), - ), - ( - "generation", - checked_canonical_bytes( - &manifest.generation, - check, - "recovered generation identity", - MAX_GRAPH_REPLAY_SOURCE_BYTES_V1, - ), - ), - ( - "source_generation", - checked_canonical_bytes( - &manifest.source_generation, - check, - "recovered source generation", - MAX_GRAPH_REPLAY_SOURCE_BYTES_V1, - ), - ), - ( - "watermark", - checked_canonical_bytes( - &manifest.watermark, - check, - "recovered generation watermark", - MAX_GRAPH_REPLAY_SOURCE_BYTES_V1, - ), - ), - ( - "dependencies", - checked_canonical_bytes( - &manifest.dependencies, - check, - "recovered generation dependencies", - MAX_GRAPH_REPLAY_SOURCE_BYTES_V1, - ), - ), - ] { - write_frame(&mut writer, tag, &value?)?; - } + let mut canonical = CheckedVecWriter::new(check, MAX_GRAPH_REPLAY_SOURCE_BYTES_V1)?; + write_canonical_frame( + &mut writer, + &mut canonical, + "format", + "tracedecay.graph-generation.v1", + "recovered generation format", + )?; + write_canonical_frame( + &mut writer, + &mut canonical, + "projection", + &manifest.projection, + "recovered generation projection", + )?; + write_canonical_frame( + &mut writer, + &mut canonical, + "generation", + &manifest.generation, + "recovered generation identity", + )?; + write_canonical_frame( + &mut writer, + &mut canonical, + "source_generation", + &manifest.source_generation, + "recovered source generation", + )?; + write_canonical_frame( + &mut writer, + &mut canonical, + "watermark", + &manifest.watermark, + "recovered generation watermark", + )?; + write_canonical_frame( + &mut writer, + &mut canonical, + "dependencies", + &manifest.dependencies, + "recovered generation dependencies", + )?; let physical_namespace = manifest.physical_namespace()?; for (_, node) in projection_entity_nodes_sorted_checked( @@ -89,13 +74,13 @@ pub(crate) fn recovered_generation_digest_from_database( )? { check()?; let entity = load_entity_by_node(database, node)?.entity; - let bytes = checked_canonical_bytes( + write_canonical_frame( + &mut writer, + &mut canonical, + "entity", &entity, - check, "recovered generation entity", - MAX_GRAPH_REPLAY_SOURCE_BYTES_V1, )?; - write_frame(&mut writer, "entity", &bytes)?; } let namespace_projection = physical_namespace_projection_map(manifest)?; @@ -116,13 +101,13 @@ pub(crate) fn recovered_generation_digest_from_database( stored.relation.kind, stored.relation.properties, )?; - let bytes = checked_canonical_bytes( + write_canonical_frame( + &mut writer, + &mut canonical, + "relation", &relation, - check, "recovered generation relation", - MAX_GRAPH_REPLAY_SOURCE_BYTES_V1, )?; - write_frame(&mut writer, "relation", &bytes)?; } writer.finish()?; Ok(encode_lowercase_hex(&digest.finalize())) diff --git a/crates/tracedecay-graph-db/src/generation_runtime.rs b/crates/tracedecay-graph-db/src/generation_runtime.rs index 07bb12e2b2..be13fff68e 100644 --- a/crates/tracedecay-graph-db/src/generation_runtime.rs +++ b/crates/tracedecay-graph-db/src/generation_runtime.rs @@ -1340,7 +1340,8 @@ mod tests { use tracedecay_store::runtime::GraphRecoveredGenerationDigestV1; use crate::generation::{ - manifest_canonicalizations, recovered_generation_enumerations, + canonical_buffer_allocation_growths, manifest_canonicalizations, + recovered_generation_enumerations, reset_canonical_buffer_allocation_growths, reset_manifest_canonicalizations, reset_recovered_generation_enumerations, }; use crate::projection::{ @@ -1497,11 +1498,17 @@ mod tests { let sealed = sealed_digest(&manifest); reset_recovered_generation_enumerations(); + reset_canonical_buffer_allocation_growths(); database .reopen_and_verify_existing_generation(&manifest, &sealed, &|| Ok(())) .unwrap(); assert_eq!(recovered_generation_enumerations(), 1); + let allocation_growths = canonical_buffer_allocation_growths(); + assert!( + allocation_growths <= 8, + "5,000 stored rows caused {allocation_growths} canonical-buffer allocation growths" + ); owner.close().unwrap(); } From b1074d111960f1fe356d27a91922ecf6c90e5c93 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Wed, 26 Aug 2026 20:05:11 +0000 Subject: [PATCH 21/23] perf(graph): widen native generation staging --- .../src/graph_projection.rs | 2 +- .../interactive/tests/imports.rs | 9 +- .../graph_projection_publication.rs | 27 ++--- .../src/generation_runtime.rs | 112 +++++++++++------- crates/tracedecay-graph-db/src/limits.rs | 9 ++ 5 files changed, 98 insertions(+), 61 deletions(-) diff --git a/crates/tracedecay-code-index/src/graph_projection.rs b/crates/tracedecay-code-index/src/graph_projection.rs index 5f3cee2519..7b913be1f0 100644 --- a/crates/tracedecay-code-index/src/graph_projection.rs +++ b/crates/tracedecay-code-index/src/graph_projection.rs @@ -58,7 +58,7 @@ const FILE_SYMBOL_EDGE_KIND: &str = "CodeFileContainsSymbol"; const CHUNK_SYMBOL_EDGE_KIND: &str = "CodeChunkDescribesSymbol"; const SOURCE_EDGE_KIND: &str = "CodeRelationSource"; const TARGET_EDGE_KIND: &str = "CodeRelationTarget"; -pub const CODE_GRAPH_PROJECTOR_REVISION: &str = "code-graph-projector.v4"; +pub const CODE_GRAPH_PROJECTOR_REVISION: &str = "code-graph-projector.v5"; #[derive(Clone, Debug, Error, PartialEq, Eq)] pub enum CodeGraphProjectionError { diff --git a/crates/tracedecay-code-index/src/graph_projection/interactive/tests/imports.rs b/crates/tracedecay-code-index/src/graph_projection/interactive/tests/imports.rs index f0765a5281..fa1ebc9243 100644 --- a/crates/tracedecay-code-index/src/graph_projection/interactive/tests/imports.rs +++ b/crates/tracedecay-code-index/src/graph_projection/interactive/tests/imports.rs @@ -494,18 +494,17 @@ fn corrupt_import_relation_identity_and_properties_are_refused() { } #[test] -fn projector_v4_is_accepted_and_v3_is_a_generation_mismatch() { - assert_eq!(CODE_GRAPH_PROJECTOR_REVISION, "code-graph-projector.v4"); +fn current_projector_is_accepted_and_v4_is_a_generation_mismatch() { let (files, imports) = two_import_fixture(); let reader = import_reader(&files, &imports); assert_eq!(reader.generation(), &generation()); - let v3 = import_manifest(&files, &imports, "code-graph-projector.v3"); - let snapshot = VerifiedGraphSnapshot::memory(v3, Arc::new(NeverCancelled)) + let v4 = import_manifest(&files, &imports, "code-graph-projector.v4"); + let snapshot = VerifiedGraphSnapshot::memory(v4, Arc::new(NeverCancelled)) .expect("open legacy-revision snapshot"); assert_eq!( CodeGraphProjectionStore::from_verified_snapshot(snapshot, generation()) - .expect_err("v3 snapshot must not satisfy the v4 reader"), + .expect_err("v4 snapshot must not satisfy the current reader"), CodeGraphProjectionError::GenerationMismatch ); } diff --git a/crates/tracedecay-code-index/tests/code_index_suite/graph_projection_publication.rs b/crates/tracedecay-code-index/tests/code_index_suite/graph_projection_publication.rs index cb5855c30e..7cfad89cee 100644 --- a/crates/tracedecay-code-index/tests/code_index_suite/graph_projection_publication.rs +++ b/crates/tracedecay-code-index/tests/code_index_suite/graph_projection_publication.rs @@ -250,30 +250,29 @@ fn sealed_generation_replay_rebuilds_identical_import_manifest_and_digest() { } #[test] -fn projector_v4_changes_generation_identity_without_a_v3_alias() { - assert_eq!(CODE_GRAPH_PROJECTOR_REVISION, "code-graph-projector.v4"); +fn current_projector_changes_generation_identity_without_a_v4_alias() { let generation = published_import_generation(); - let v4 = current_projector_revision(); - let v3 = GraphProjectorRevision::try_from("code-graph-projector.v3".to_owned()) + let current = current_projector_revision(); + let v4 = GraphProjectorRevision::try_from("code-graph-projector.v4".to_owned()) .expect("prior projector revision remains valid data"); + let current_identity = code_graph_generation_id(&generation.manifest().generation_id, ¤t) + .expect("current graph generation identity"); let v4_identity = code_graph_generation_id(&generation.manifest().generation_id, &v4) .expect("v4 graph generation identity"); - let v3_identity = code_graph_generation_id(&generation.manifest().generation_id, &v3) - .expect("v3 graph generation identity"); - assert_ne!(v4_identity, v3_identity); + assert_ne!(current_identity, v4_identity); + + let current_manifest = projection_manifest(&generation, ¤t); + assert_eq!(current_manifest.generation, current_identity); + let _store = verified_store(current_manifest, &generation); let v4_manifest = projection_manifest(&generation, &v4); assert_eq!(v4_manifest.generation, v4_identity); - let _store = verified_store(v4_manifest, &generation); - - let v3_manifest = projection_manifest(&generation, &v3); - assert_eq!(v3_manifest.generation, v3_identity); - let v3_snapshot = VerifiedGraphSnapshot::memory(v3_manifest, Arc::new(NeverCancelled)) + let v4_snapshot = VerifiedGraphSnapshot::memory(v4_manifest, Arc::new(NeverCancelled)) .expect("prior graph snapshot is structurally valid"); let error = CodeGraphProjectionStore::from_verified_snapshot( - v3_snapshot, + v4_snapshot, generation.manifest().generation_id.clone(), ) - .expect_err("a v3 graph snapshot cannot serve the v4 generation authority"); + .expect_err("a v4 graph snapshot cannot serve the current generation authority"); assert_eq!(error, CodeGraphProjectionError::GenerationMismatch); } diff --git a/crates/tracedecay-graph-db/src/generation_runtime.rs b/crates/tracedecay-graph-db/src/generation_runtime.rs index be13fff68e..20f632f5cc 100644 --- a/crates/tracedecay-graph-db/src/generation_runtime.rs +++ b/crates/tracedecay-graph-db/src/generation_runtime.rs @@ -12,6 +12,9 @@ use crate::lease::{ GenerationLocator, VerifiedGenerationLease, VerifiedGraphSnapshot, VerifiedTraversalResult, VerifiedTraversalVisit, }; +use crate::limits::{ + MAX_NATIVE_GENERATION_STAGE_LIVE_BYTES, MAX_NATIVE_GENERATION_STAGE_MUTATIONS, +}; use crate::projection::graph_properties_live_bytes; use crate::recovery::{ checkpoint_recovered_database, is_database_fault, open_recovered_database, @@ -27,9 +30,8 @@ use crate::state::{ use crate::{ GraphBudgetKind, GraphCancellation, GraphCommit, GraphDb, GraphDbError, GraphEntityRef, GraphGenerationManifest, GraphGenerationRelation, GraphIdempotencyKey, GraphMutation, - GraphNamespace, GraphRelationRef, GraphTraversalDirection, GraphWriteBatch, - MAX_VERIFIED_GENERATION_BATCH_LIVE_BYTES, MAX_VERIFIED_GENERATION_BATCH_MUTATIONS, - TraversalRequest, mutation, + GraphNamespace, GraphRelationRef, GraphTraversalDirection, GraphWriteBatch, TraversalRequest, + mutation, }; #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -1142,7 +1144,7 @@ fn generation_relation_live_bytes( fn stage_live_bytes_exhausted() -> GraphDbError { GraphDbError::budget_exhausted_count( GraphBudgetKind::Write, - MAX_VERIFIED_GENERATION_BATCH_LIVE_BYTES, + MAX_NATIVE_GENERATION_STAGE_LIVE_BYTES, ) } @@ -1156,16 +1158,16 @@ fn append_generation_stage_pages( let mut live_bytes = 0usize; for index in 0..count { let next_bytes = property_bytes(index)?; - if next_bytes > MAX_VERIFIED_GENERATION_BATCH_LIVE_BYTES { + if next_bytes > MAX_NATIVE_GENERATION_STAGE_LIVE_BYTES { return Err(GraphDbError::budget_exhausted_count( GraphBudgetKind::Write, - MAX_VERIFIED_GENERATION_BATCH_LIVE_BYTES, + MAX_NATIVE_GENERATION_STAGE_LIVE_BYTES, )); } - let page_is_full = index - start == MAX_VERIFIED_GENERATION_BATCH_MUTATIONS; + let page_is_full = index - start == MAX_NATIVE_GENERATION_STAGE_MUTATIONS; let bytes_would_overflow = live_bytes .checked_add(next_bytes) - .is_none_or(|bytes| bytes > MAX_VERIFIED_GENERATION_BATCH_LIVE_BYTES); + .is_none_or(|bytes| bytes > MAX_NATIVE_GENERATION_STAGE_LIVE_BYTES); if index > start && (page_is_full || bytes_would_overflow) { pages.push(GenerationStagePage { ordinal: pages.len(), @@ -1179,7 +1181,7 @@ fn append_generation_stage_pages( live_bytes = live_bytes.checked_add(next_bytes).ok_or_else(|| { GraphDbError::budget_exhausted_count( GraphBudgetKind::Write, - MAX_VERIFIED_GENERATION_BATCH_LIVE_BYTES, + MAX_NATIVE_GENERATION_STAGE_LIVE_BYTES, ) })?; } @@ -1244,12 +1246,12 @@ fn prepare_generation_stage_batch( page: &GenerationStagePage, check: &dyn Fn() -> Result<(), GraphDbError>, ) -> Result<(GraphWriteBatch, mutation::RelationEndpointNamespaces), GraphDbError> { - if page.mutation_count() > MAX_VERIFIED_GENERATION_BATCH_MUTATIONS - || page.live_bytes() > MAX_VERIFIED_GENERATION_BATCH_LIVE_BYTES + if page.mutation_count() > MAX_NATIVE_GENERATION_STAGE_MUTATIONS + || page.live_bytes() > MAX_NATIVE_GENERATION_STAGE_LIVE_BYTES { return Err(GraphDbError::budget_exhausted_count( GraphBudgetKind::Write, - MAX_VERIFIED_GENERATION_BATCH_LIVE_BYTES, + MAX_NATIVE_GENERATION_STAGE_LIVE_BYTES, )); } let mut endpoint_namespaces = mutation::RelationEndpointNamespaces::new(); @@ -1344,6 +1346,9 @@ mod tests { recovered_generation_enumerations, reset_canonical_buffer_allocation_growths, reset_manifest_canonicalizations, reset_recovered_generation_enumerations, }; + use crate::limits::{ + MAX_NATIVE_GENERATION_STAGE_LIVE_BYTES, MAX_NATIVE_GENERATION_STAGE_MUTATIONS, + }; use crate::projection::{ batch_canonicalizations, max_canonical_batch_mutations, reset_batch_canonicalizations, }; @@ -1353,8 +1358,7 @@ mod tests { GraphGenerationId, GraphGenerationManifest, GraphIdempotencyKey, GraphNamespace, GraphProjectionId, GraphProjectionIdentity, GraphProperty, GraphPropertyName, GraphVector, GraphVectorIndexRequest, GraphVectorIndexStatus, GraphWatermark, - MAX_VERIFIED_GENERATION_BATCH_LIVE_BYTES, MAX_VERIFIED_GENERATION_BATCH_MUTATIONS, - NeverCancelled, SourceGeneration, VectorMetric, + MAX_VERIFIED_GENERATION_BATCH_MUTATIONS, NeverCancelled, SourceGeneration, VectorMetric, }; use super::{GenerationLocator, GenerationStageOutcome, generation_stage_pages}; @@ -1531,8 +1535,8 @@ mod tests { .unwrap(); assert_eq!( batch_canonicalizations(), - 3, - "two bounded row pages plus final metadata bind are hashed once each" + 2, + "one bounded native row page plus final metadata bind are hashed once each" ); (owner, database, manifest) } @@ -1813,8 +1817,8 @@ mod tests { .unwrap(); assert_eq!( batch_canonicalizations(), - 3, - "a different generation must stage two bounded pages and its final metadata bind" + 2, + "a different generation must stage one native page and its final metadata bind" ); assert_eq!(commit_b.source_generation.as_str(), "source-b"); assert_eq!(commit_b.watermark.as_str(), "watermark-b"); @@ -1879,26 +1883,22 @@ mod tests { } #[test] - fn generation_stage_pages_bound_mutations_and_live_property_bytes() { - let property = "x".repeat(1024 * 1024); + fn native_generation_stages_sixty_five_thousand_rows_in_one_durable_page() { let manifest = GraphGenerationManifest::new( GraphProjectionIdentity::new( - GraphNamespace::new("bounded-stage").unwrap(), - GraphProjectionId::new("properties").unwrap(), + GraphNamespace::new("wide-native-stage").unwrap(), + GraphProjectionId::new("entities").unwrap(), ), - GraphGenerationId::new("generation-bounded").unwrap(), - SourceGeneration::new("source-bounded").unwrap(), - GraphWatermark::new("watermark-bounded").unwrap(), + GraphGenerationId::new("generation-wide-native-stage").unwrap(), + SourceGeneration::new("source-wide-native-stage").unwrap(), + GraphWatermark::new("watermark-wide-native-stage").unwrap(), vec![], - (0..33) + (0..65_536) .map(|index| { GraphEntity::new( - GraphEntityId::new(format!("entity:{index:02}")).unwrap(), + GraphEntityId::new(format!("entity:{index:05}")).unwrap(), BTreeSet::new(), - BTreeMap::from([( - GraphPropertyName::new("payload").unwrap(), - GraphProperty::String(property.clone()), - )]), + BTreeMap::new(), ) .unwrap() }) @@ -1906,23 +1906,53 @@ mod tests { vec![], ) .unwrap(); - let pages = generation_stage_pages(&manifest).unwrap(); + assert_eq!( + pages.len(), + 1, + "native generation staging must avoid a full-graph Grafeo commit scan every 4,096 rows" + ); + + let temp = TempDir::new().unwrap(); + let (owner, database) = persistent_database(&temp); + reset_batch_canonicalizations(); + database + .apply_generation_unverified(&manifest, &|| Ok(())) + .unwrap(); + assert_eq!( + batch_canonicalizations(), + 2, + "one native data page and one metadata bind must be committed" + ); + owner.close().unwrap(); + } + + #[test] + fn generation_stage_page_planner_bounds_mutations_and_live_property_bytes() { + let mut pages = Vec::new(); + super::append_generation_stage_pages( + &mut pages, + super::GenerationStagePageKind::Entities, + 17, + |_| Ok(8 * 1024 * 1024), + ) + .unwrap(); + assert!( pages.len() > 1, "the property-byte ceiling must split this input" ); assert!(pages.iter().all(|page| { - page.mutation_count() <= MAX_VERIFIED_GENERATION_BATCH_MUTATIONS - && page.live_bytes() <= MAX_VERIFIED_GENERATION_BATCH_LIVE_BYTES + page.mutation_count() <= MAX_NATIVE_GENERATION_STAGE_MUTATIONS + && page.live_bytes() <= MAX_NATIVE_GENERATION_STAGE_LIVE_BYTES })); assert_eq!( pages .iter() .map(|page| page.mutation_count()) .sum::(), - manifest.entities.len() + manifest.relations.len() + 17 ); } @@ -1992,8 +2022,8 @@ mod tests { }; assert_eq!( batch_canonicalizations(), - 2, - "the exact first-page receipt must skip rebuilding that page on resume" + 1, + "the exact native-page receipt must skip directly to finalization on resume" ); reset_recovered_generation_enumerations(); @@ -2037,13 +2067,13 @@ mod tests { let pages = generation_stage_pages(&manifest).unwrap(); assert_eq!( pages.len(), - 2, - "the fixture must stage in exactly two pages" + 1, + "the fixture must stage in exactly one native page" ); let sealed = sealed_digest(&manifest); let physical_namespace = manifest.physical_namespace().unwrap(); let (last_page_key, _) = - super::generation_stage_page_receipt(&manifest, &sealed, &pages[1]).unwrap(); + super::generation_stage_page_receipt(&manifest, &sealed, &pages[0]).unwrap(); let cancel_before_finalization = || { let Ok(database_guard) = database.inner.database.try_read() else { return Ok(()); @@ -2105,7 +2135,7 @@ mod tests { #[test] fn later_generation_page_creates_its_first_native_vector_index() { let vector_property = GraphPropertyName::new("embedding").unwrap(); - let mut entities = (0..MAX_VERIFIED_GENERATION_BATCH_MUTATIONS) + let mut entities = (0..MAX_NATIVE_GENERATION_STAGE_MUTATIONS) .map(|index| { GraphEntity::new( GraphEntityId::new(format!("entity:{index:05}")).unwrap(), diff --git a/crates/tracedecay-graph-db/src/limits.rs b/crates/tracedecay-graph-db/src/limits.rs index ca2bfd5757..e9866e2e3b 100644 --- a/crates/tracedecay-graph-db/src/limits.rs +++ b/crates/tracedecay-graph-db/src/limits.rs @@ -15,6 +15,15 @@ pub const MAX_VERIFIED_GENERATION_BATCH_MUTATIONS: usize = 4_096; /// transaction (identifiers, labels, endpoint identities, and properties). /// The mutation-count ceiling independently bounds fixed record overhead. pub const MAX_VERIFIED_GENERATION_BATCH_LIVE_BYTES: usize = 32 * 1024 * 1024; +/// Native full-generation staging uses wider pages than incremental vector +/// writes and retirement. Grafeo 0.5.42 recomputes whole-graph counts and +/// finalizes every visible version epoch on each transaction commit, so using +/// the 4,096-mutation incremental ceiling here makes a large generation +/// quadratic in its page count. The daemon's resident-memory admission owns +/// the full manifest; this additional page clone remains bounded independently +/// and cancellation is observed before and after every durable page. +pub(crate) const MAX_NATIVE_GENERATION_STAGE_MUTATIONS: usize = 65_536; +pub(crate) const MAX_NATIVE_GENERATION_STAGE_LIVE_BYTES: usize = 128 * 1024 * 1024; pub const MAX_GRAPH_VECTOR_DIMENSION: usize = 4_096; pub const MAX_GRAPH_IDENTIFIER_BYTES: usize = 1_024; pub const MAX_GRAPH_ENTITY_LABELS: usize = 128; From 13e5b39e1a072182b9bec5152ddbfac21f023db0 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Wed, 26 Aug 2026 20:23:58 +0000 Subject: [PATCH 22/23] fix(graph): migrate legacy native stage receipts --- .../src/generation_runtime.rs | 232 ++++++++++++++++-- 1 file changed, 215 insertions(+), 17 deletions(-) diff --git a/crates/tracedecay-graph-db/src/generation_runtime.rs b/crates/tracedecay-graph-db/src/generation_runtime.rs index 20f632f5cc..638b04a3a0 100644 --- a/crates/tracedecay-graph-db/src/generation_runtime.rs +++ b/crates/tracedecay-graph-db/src/generation_runtime.rs @@ -14,6 +14,7 @@ use crate::lease::{ }; use crate::limits::{ MAX_NATIVE_GENERATION_STAGE_LIVE_BYTES, MAX_NATIVE_GENERATION_STAGE_MUTATIONS, + MAX_VERIFIED_GENERATION_BATCH_LIVE_BYTES, MAX_VERIFIED_GENERATION_BATCH_MUTATIONS, }; use crate::projection::graph_properties_live_bytes; use crate::recovery::{ @@ -186,6 +187,8 @@ impl GraphDb { return Ok(GenerationStageOutcome::Reseated(commit)); } let pages = generation_stage_pages(manifest)?; + let adopt_legacy_partial = + self.has_exact_legacy_stage_prefix(manifest, expected, &context, pages.first())?; #[cfg(feature = "hotpath")] { let generation_bytes = pages.iter().map(GenerationStagePage::live_bytes).sum(); @@ -207,6 +210,7 @@ impl GraphDb { &context, index.checked_sub(1).and_then(|prior| pages.get(prior)), page, + adopt_legacy_partial && index == 0, check, )?; // This is the exact cancellation boundary: the page transaction @@ -259,6 +263,7 @@ impl GraphDb { context: &GenerationStageContext, predecessor: Option<&GenerationStagePage>, page: &GenerationStagePage, + adopt_legacy_partial: bool, check: &dyn Fn() -> Result<(), GraphDbError>, ) -> Result { let (idempotency_key, input_digest) = @@ -295,14 +300,21 @@ impl GraphDb { if prior.input_digest != prior_input { return Err(GraphDbError::Conflict); } - } else if latest_projection( + } else if let Some(existing) = latest_projection( database, &context.physical_namespace, &manifest.projection.projection, - )? - .is_some() - { - return Err(GraphDbError::Conflict); + )? { + // A finalized generation always carries its dependency + // digest. Only an exact unfinished legacy stage may let + // the wider first page replace its old prefix. + let exact_incomplete_legacy = adopt_legacy_partial + && existing.commit.source_generation == manifest.source_generation + && existing.commit.watermark == manifest.watermark + && existing.commit.generation_dependency_digest.is_none(); + if !exact_incomplete_legacy { + return Err(GraphDbError::Conflict); + } } let (batch, endpoint_namespaces) = prepare_generation_stage_batch(manifest, context, page, check)?; @@ -329,6 +341,45 @@ impl GraphDb { ) } + fn has_exact_legacy_stage_prefix( + &self, + manifest: &GraphGenerationManifest, + expected: &GraphRecoveredGenerationDigestV1, + context: &GenerationStageContext, + native_first: Option<&GenerationStagePage>, + ) -> Result { + let legacy_first = first_generation_stage_page_with_limits( + manifest, + MAX_VERIFIED_GENERATION_BATCH_MUTATIONS, + MAX_VERIFIED_GENERATION_BATCH_LIVE_BYTES, + )?; + let Some(legacy_first) = legacy_first.as_ref() else { + return Ok(false); + }; + if native_first == Some(legacy_first) { + return Ok(false); + } + // The legacy receipt binds the exact manifest identity, recovered + // digest, page range, and live-byte count. Its presence is the durable + // proof that replacing the obsolete prefix does not adopt foreign rows. + let (legacy_key, legacy_input) = + generation_stage_page_receipt(manifest, expected, legacy_first)?; + let guard = self.read_guard()?; + let database = guard.as_ref().ok_or(GraphDbError::Closed)?; + let Some(existing) = + crate::state::publication(database, &context.physical_namespace, &legacy_key)? + else { + return Ok(false); + }; + if existing.input_digest != legacy_input + || existing.commit.source_generation != manifest.source_generation + || existing.commit.watermark != manifest.watermark + { + return Err(GraphDbError::Conflict); + } + Ok(true) + } + fn finalize_staged_generation( &self, manifest: &GraphGenerationManifest, @@ -1089,23 +1140,73 @@ fn typed_entity_ref( fn generation_stage_pages( manifest: &GraphGenerationManifest, +) -> Result, GraphDbError> { + generation_stage_pages_with_limits( + manifest, + MAX_NATIVE_GENERATION_STAGE_MUTATIONS, + MAX_NATIVE_GENERATION_STAGE_LIVE_BYTES, + ) +} + +fn generation_stage_pages_with_limits( + manifest: &GraphGenerationManifest, + maximum_mutations: usize, + maximum_live_bytes: usize, ) -> Result, GraphDbError> { let mut pages = Vec::new(); - append_generation_stage_pages( + append_generation_stage_pages_with_limits( &mut pages, GenerationStagePageKind::Entities, manifest.entities.len(), |index| generation_entity_live_bytes(&manifest.entities[index]), + maximum_mutations, + maximum_live_bytes, )?; - append_generation_stage_pages( + append_generation_stage_pages_with_limits( &mut pages, GenerationStagePageKind::Relations, manifest.relations.len(), |index| generation_relation_live_bytes(&manifest.relations[index]), + maximum_mutations, + maximum_live_bytes, )?; Ok(pages) } +fn first_generation_stage_page_with_limits( + manifest: &GraphGenerationManifest, + maximum_mutations: usize, + maximum_live_bytes: usize, +) -> Result, GraphDbError> { + let mut pages = Vec::with_capacity(2); + if manifest.entities.is_empty() { + append_generation_stage_pages_with_limits( + &mut pages, + GenerationStagePageKind::Relations, + manifest + .relations + .len() + .min(maximum_mutations.saturating_add(1)), + |index| generation_relation_live_bytes(&manifest.relations[index]), + maximum_mutations, + maximum_live_bytes, + )?; + } else { + append_generation_stage_pages_with_limits( + &mut pages, + GenerationStagePageKind::Entities, + manifest + .entities + .len() + .min(maximum_mutations.saturating_add(1)), + |index| generation_entity_live_bytes(&manifest.entities[index]), + maximum_mutations, + maximum_live_bytes, + )?; + } + Ok(pages.into_iter().next()) +} + fn generation_entity_live_bytes(entity: &crate::GraphEntity) -> Result { entity .labels @@ -1148,26 +1249,28 @@ fn stage_live_bytes_exhausted() -> GraphDbError { ) } -fn append_generation_stage_pages( +fn append_generation_stage_pages_with_limits( pages: &mut Vec, kind: GenerationStagePageKind, count: usize, property_bytes: impl Fn(usize) -> Result, + maximum_mutations: usize, + maximum_live_bytes: usize, ) -> Result<(), GraphDbError> { let mut start = 0usize; let mut live_bytes = 0usize; for index in 0..count { let next_bytes = property_bytes(index)?; - if next_bytes > MAX_NATIVE_GENERATION_STAGE_LIVE_BYTES { + if next_bytes > maximum_live_bytes { return Err(GraphDbError::budget_exhausted_count( GraphBudgetKind::Write, - MAX_NATIVE_GENERATION_STAGE_LIVE_BYTES, + maximum_live_bytes, )); } - let page_is_full = index - start == MAX_NATIVE_GENERATION_STAGE_MUTATIONS; + let page_is_full = index - start == maximum_mutations; let bytes_would_overflow = live_bytes .checked_add(next_bytes) - .is_none_or(|bytes| bytes > MAX_NATIVE_GENERATION_STAGE_LIVE_BYTES); + .is_none_or(|bytes| bytes > maximum_live_bytes); if index > start && (page_is_full || bytes_would_overflow) { pages.push(GenerationStagePage { ordinal: pages.len(), @@ -1179,10 +1282,7 @@ fn append_generation_stage_pages( live_bytes = 0; } live_bytes = live_bytes.checked_add(next_bytes).ok_or_else(|| { - GraphDbError::budget_exhausted_count( - GraphBudgetKind::Write, - MAX_NATIVE_GENERATION_STAGE_LIVE_BYTES, - ) + GraphDbError::budget_exhausted_count(GraphBudgetKind::Write, maximum_live_bytes) })?; } if start < count { @@ -1931,11 +2031,13 @@ mod tests { #[test] fn generation_stage_page_planner_bounds_mutations_and_live_property_bytes() { let mut pages = Vec::new(); - super::append_generation_stage_pages( + super::append_generation_stage_pages_with_limits( &mut pages, super::GenerationStagePageKind::Entities, 17, |_| Ok(8 * 1024 * 1024), + MAX_NATIVE_GENERATION_STAGE_MUTATIONS, + MAX_NATIVE_GENERATION_STAGE_LIVE_BYTES, ) .unwrap(); @@ -2057,6 +2159,102 @@ mod tests { second_owner.close().unwrap(); } + #[test] + fn wider_native_stage_adopts_an_exact_legacy_partial_receipt() { + let mut manifest = large_manifest("legacy-page-resume"); + manifest.entities.extend((5_000..9_000).map(|index| { + GraphEntity::new( + GraphEntityId::new(format!("entity:{index:05}")).unwrap(), + BTreeSet::new(), + BTreeMap::new(), + ) + .unwrap() + })); + let sealed = sealed_digest(&manifest); + let temp = TempDir::new().unwrap(); + let (owner, database) = persistent_database(&temp); + let legacy_pages = super::generation_stage_pages_with_limits( + &manifest, + MAX_VERIFIED_GENERATION_BATCH_MUTATIONS, + crate::MAX_VERIFIED_GENERATION_BATCH_LIVE_BYTES, + ) + .unwrap(); + assert_eq!(legacy_pages.len(), 3); + assert_eq!(legacy_pages[0].range, 0..4_096); + assert_eq!(legacy_pages[1].range, 4_096..8_192); + assert_eq!( + super::first_generation_stage_page_with_limits( + &manifest, + MAX_VERIFIED_GENERATION_BATCH_MUTATIONS, + crate::MAX_VERIFIED_GENERATION_BATCH_LIVE_BYTES, + ) + .unwrap(), + Some(legacy_pages[0].clone()), + "the bounded compatibility probe must reproduce the legacy first receipt" + ); + let context = super::GenerationStageContext { + locator: GenerationLocator::new( + manifest.projection.clone(), + manifest.generation.clone(), + ), + physical_namespace: manifest.physical_namespace().unwrap(), + dependency_namespaces: database.require_exact_dependencies(&manifest).unwrap(), + dependency_digest: manifest.dependency_closure_digest(&|| Ok(())).unwrap(), + }; + for (index, legacy_page) in legacy_pages.iter().take(2).enumerate() { + database + .apply_generation_stage_page_with_context( + &manifest, + &sealed, + &context, + index + .checked_sub(1) + .and_then(|prior| legacy_pages.get(prior)), + legacy_page, + false, + &|| Ok(()), + ) + .unwrap(); + } + + let mut divergent = manifest.clone(); + divergent.source_generation = SourceGeneration::new("source-divergent").unwrap(); + let divergent_sealed = sealed_digest(&divergent); + reset_batch_canonicalizations(); + assert!( + matches!( + database.apply_generation_unverified_with_digest_observed( + &divergent, + &divergent_sealed, + &|| Ok(()), + ), + Err(GraphDbError::Conflict) + ), + "a legacy prefix may be replaced only by its exact source authority" + ); + assert_eq!( + batch_canonicalizations(), + 0, + "a divergent legacy migration must fail before writing" + ); + + reset_batch_canonicalizations(); + let outcome = database + .apply_generation_unverified_with_digest_observed(&manifest, &sealed, &|| Ok(())) + .expect("an exact legacy partial stage must migrate to the wider page layout"); + assert!(outcome.was_applied()); + assert_eq!( + batch_canonicalizations(), + 2, + "migration must write one wide data page and one final metadata bind" + ); + let (_, recovered) = database + .reopen_and_verify_existing_generation(&manifest, &sealed, &|| Ok(())) + .unwrap(); + assert_eq!(recovered, sealed); + owner.close().unwrap(); + } + #[test] fn near_complete_cancelled_stage_retires_in_bounded_idempotent_pages() { let manifest = large_manifest("bounded-retirement"); From 6f6d6e4062e7baf608c9a99a3e8f0295e2c9db3a Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Wed, 26 Aug 2026 21:43:50 +0000 Subject: [PATCH 23/23] fix(graph): replay historical projector generations --- .../session_registry/code_graph.rs | 81 +++++++++++++++++-- .../code_graph/sealed_publication_tests.rs | 45 ++++++++++- .../session_registry/code_graph_manifest.rs | 9 ++- 3 files changed, 124 insertions(+), 11 deletions(-) diff --git a/src/daemon/store_runtime/session_registry/code_graph.rs b/src/daemon/store_runtime/session_registry/code_graph.rs index aaa5ce8a6e..003764e263 100644 --- a/src/daemon/store_runtime/session_registry/code_graph.rs +++ b/src/daemon/store_runtime/session_registry/code_graph.rs @@ -64,6 +64,50 @@ const SEALED_PROJECTION_DEADLINE_CEILING: Duration = Duration::from_mins(15); /// across a few reconcile passes rather than blocking forever. const MAX_PENDING_REPLAY_COMPLETIONS_V1: usize = 8; +#[derive(Clone, Copy)] +enum CodeGraphPublicationConflictStageV1 { + ActiveReplayPublish, + RetiredReplay, + PendingCompletionLimit, + PendingPredecessorPublish, + VerifiedHeadRefreshLimit, + ReplayAppend, + FinalPublish, +} + +impl CodeGraphPublicationConflictStageV1 { + const fn as_str(self) -> &'static str { + match self { + Self::ActiveReplayPublish => "active_replay_publish", + Self::RetiredReplay => "retired_replay", + Self::PendingCompletionLimit => "pending_completion_limit", + Self::PendingPredecessorPublish => "pending_predecessor_publish", + Self::VerifiedHeadRefreshLimit => "verified_head_refresh_limit", + Self::ReplayAppend => "replay_append", + Self::FinalPublish => "final_publish", + } + } +} + +fn observe_code_graph_publication( + stage: CodeGraphPublicationConflictStageV1, + result: std::result::Result, +) -> std::result::Result { + result.map_err(|error| { + if matches!(error, GraphDbError::Conflict) { + let reason = stage.as_str(); + tracing::warn!( + event = "code_graph_publication_conflict", + reason, + "code graph publication reached a conflicting durable authority" + ); + #[cfg(feature = "hotpath")] + hotpath::val!("code_graph.publication.conflict_reason").set(&reason); + } + error + }) +} + fn sealed_projection_deadline(sealed_bytes: u64) -> Duration { let scaled = sealed_projection_scaled_deadline(sealed_bytes); SEALED_PROJECTION_DEADLINE_FLOOR @@ -951,10 +995,18 @@ impl RetainedCodeGraphRuntimeV1 { ); } let _replay_pool_lock = verify_durable_source()?; - let publication = publish(&mut storage, &publication_key, Some(manifest))?; + let publication = observe_code_graph_publication( + CodeGraphPublicationConflictStageV1::ActiveReplayPublish, + publish(&mut storage, &publication_key, Some(manifest)), + )?; return Ok(publication.snapshot); } - GraphPublicationReplayLookupV1::Retired(_) => return Err(GraphDbError::Conflict), + GraphPublicationReplayLookupV1::Retired(_) => { + return observe_code_graph_publication( + CodeGraphPublicationConflictStageV1::RetiredReplay, + Err(GraphDbError::Conflict), + ); + } GraphPublicationReplayLookupV1::Missing => {} } let replay_pool_lock = verify_durable_source()?; @@ -1010,10 +1062,16 @@ impl RetainedCodeGraphRuntimeV1 { | GraphReplayAppendOutcomeV1::ExactVerifiedReplay { .. } => break, GraphReplayAppendOutcomeV1::PendingReplayConflict { pending } => { if completed_predecessors >= MAX_PENDING_REPLAY_COMPLETIONS_V1 { - return Err(GraphDbError::Conflict); + return observe_code_graph_publication( + CodeGraphPublicationConflictStageV1::PendingCompletionLimit, + Err(GraphDbError::Conflict), + ); } completed_predecessors += 1; - publish(&mut storage, &pending.publication.key, None)?; + observe_code_graph_publication( + CodeGraphPublicationConflictStageV1::PendingPredecessorPublish, + publish(&mut storage, &pending.publication.key, None), + )?; let prior = storage .verified_head(&relational_projection, &context) .map_err(map_publication_error)?; @@ -1024,19 +1082,28 @@ impl RetainedCodeGraphRuntimeV1 { // read and this append; the refreshed head is the only // thing that was wrong with the replay. if completed_predecessors >= MAX_PENDING_REPLAY_COMPLETIONS_V1 { - return Err(GraphDbError::Conflict); + return observe_code_graph_publication( + CodeGraphPublicationConflictStageV1::VerifiedHeadRefreshLimit, + Err(GraphDbError::Conflict), + ); } completed_predecessors += 1; replay = build_replay(actual)?; } GraphReplayAppendOutcomeV1::Conflict { .. } | GraphReplayAppendOutcomeV1::RetiredReplayConflict { .. } => { - return Err(GraphDbError::Conflict); + return observe_code_graph_publication( + CodeGraphPublicationConflictStageV1::ReplayAppend, + Err(GraphDbError::Conflict), + ); } } } drop(replay_pool_lock); - let publication = publish(&mut storage, &replay.key, Some(manifest))?; + let publication = observe_code_graph_publication( + CodeGraphPublicationConflictStageV1::FinalPublish, + publish(&mut storage, &replay.key, Some(manifest)), + )?; Ok(publication.snapshot) } diff --git a/src/daemon/store_runtime/session_registry/code_graph/sealed_publication_tests.rs b/src/daemon/store_runtime/session_registry/code_graph/sealed_publication_tests.rs index f1df567f6f..b256e2fdf3 100644 --- a/src/daemon/store_runtime/session_registry/code_graph/sealed_publication_tests.rs +++ b/src/daemon/store_runtime/session_registry/code_graph/sealed_publication_tests.rs @@ -600,9 +600,12 @@ async fn sealed_generation_publishes_and_republishes_without_eager_replay_payloa #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn offered_decode_hydrates_without_reading_the_sealed_payload_again() { use tracedecay_graph_db::{ - GraphGenerationManifestProvider, GraphNamespace, SealedGraphStateDigest, + GraphGenerationManifest, GraphGenerationManifestProvider, GraphNamespace, + SealedGraphStateDigest, + }; + use tracedecay_store::{ + BrainId, GraphNamespaceV1, GraphPublicationInputDigestV1, StoreShardIdV1, UserProfileId, }; - use tracedecay_store::{BrainId, GraphNamespaceV1, StoreShardIdV1, UserProfileId}; use super::super::code_graph_manifest::DaemonCodeGraphManifestProviderV1; @@ -719,6 +722,44 @@ async fn offered_decode_hydrates_without_reading_the_sealed_payload_again() { .expect("hydration reuses the offered decode without reading the seal"); assert_eq!(manifest.projection.namespace.as_str(), namespace.as_str()); + // A pending predecessor owns the projector revision recorded in its + // durable replay, even after the current reader has advanced. Rebuild that + // exact historical manifest and prove the replay digests accept it; this + // is what lets an interrupted predecessor finish before the current + // publication appends. + let legacy_revision = GraphProjectorRevision::try_from("code-graph-projector.v4".to_owned()) + .expect("persisted predecessor revision"); + let legacy_source = SealedCodeGenerationReplay { + projector_revision: legacy_revision.clone(), + ..source.clone() + }; + let legacy_manifest = + tracedecay_code_index::graph_projection::build_published_code_graph_manifest_checked( + projection, + &decoded, + &legacy_revision, + &|| Ok(()), + ) + .expect("build the predecessor manifest"); + let legacy_replay = legacy_manifest + .relational_sealed_replay( + shard, + tracedecay_code_index::graph_projection::code_graph_idempotency_key( + &generation_id, + &legacy_revision, + ) + .expect("predecessor idempotency key"), + GraphPublicationInputDigestV1::new(format!("sha256:{}", "c".repeat(64))) + .expect("predecessor input digest"), + None, + legacy_source, + &|| Ok(()), + ) + .expect("predecessor relational replay"); + let reconstructed = GraphGenerationManifest::from_replay(&legacy_replay, &provider, &|| Ok(())) + .expect("the exact historical predecessor must hydrate and verify"); + assert_eq!(reconstructed, *legacy_manifest); + // A different sealed payload must never be answered from this offer. let foreign = SealedCodeGenerationReplay { sealed_state_digest: SealedGraphStateDigest::try_from(format!("sha256:{}", "b".repeat(64))) diff --git a/src/daemon/store_runtime/session_registry/code_graph_manifest.rs b/src/daemon/store_runtime/session_registry/code_graph_manifest.rs index 30dc338080..352e84c1d8 100644 --- a/src/daemon/store_runtime/session_registry/code_graph_manifest.rs +++ b/src/daemon/store_runtime/session_registry/code_graph_manifest.rs @@ -494,8 +494,6 @@ impl GraphGenerationManifestProvider for DaemonCodeGraphManifestProviderV1 { })?; if owner.shard_id != binding.project_shard || !binding.repositories.contains(&source.repository) - || source.projector_revision.as_str() - != tracedecay_code_index::graph_projection::CODE_GRAPH_PROJECTOR_REVISION { return Err(GraphDbError::Conflict); } @@ -547,6 +545,13 @@ impl GraphGenerationManifestProvider for DaemonCodeGraphManifestProviderV1 { GraphNamespace::new(owner.namespace.as_str())?, GraphProjectionId::new(owner.projection.as_str())?, ); + // The replay, not the current reader, owns the projector revision at + // this boundary. An interrupted historical publication must be able + // to reconstruct its exact manifest so the ordered journal can + // advance. `GraphGenerationManifest::from_replay` compares the + // rebuilt dependency closure and recovered digest with the durable + // replay before any rows are served, while current graph readers keep + // enforcing the current revision independently. tracedecay_code_index::graph_projection::build_published_code_graph_manifest_checked( projection, generation,