diff --git a/crates/tracedecay-code-index-runtime/src/code_graph_seat.rs b/crates/tracedecay-code-index-runtime/src/code_graph_seat.rs index 79d7ed13b3..1e80c8d9b4 100644 --- a/crates/tracedecay-code-index-runtime/src/code_graph_seat.rs +++ b/crates/tracedecay-code-index-runtime/src/code_graph_seat.rs @@ -20,7 +20,6 @@ use std::path::PathBuf; use std::pin::Pin; use std::sync::{Arc, atomic::AtomicBool}; -use tracedecay_code_index::production::CodeIndexPublishedGenerationV1; use tracedecay_domain::errors::Result; use tracedecay_domain::{CodeGenerationId, ProjectId, RefId, RepositoryId, WorktreeId}; use tracedecay_graph_db::{GraphDbError, SealedGraphStateDigest, VerifiedGraphSnapshot}; @@ -41,9 +40,10 @@ pub struct CodeGraphReplayBindingV1 { pub trait CodeGraphSeatLeaseV1: Send { fn authority(&self) -> Arc; + /// Publishes the retained sealed generation's graph head, building its + /// rows from the sealed segments when the head has not landed yet. fn publish_verified_snapshot( &self, - generation: &CodeIndexPublishedGenerationV1, request_cancelled: Arc, ) -> std::result::Result; @@ -76,6 +76,5 @@ pub trait CodeGraphSeatRuntimePortV1: Send + Sync { generation_id: CodeGenerationId, project_database: Arc, replay_binding: CodeGraphReplayBindingV1, - decoded_generation: Option>, ) -> CodeGraphSeatLeaseFutureV1<'_>; } diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/graph_activation.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/graph_activation.rs index 58b98df863..3137c9b56a 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/graph_activation.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/graph_activation.rs @@ -392,7 +392,6 @@ impl CodeGraphActivationAuthorityV1 { generation_id, Arc::clone(project_database), replay_binding, - None, ), label = "code_graph.activation.recover_head.retain_runtime" ) @@ -428,6 +427,64 @@ impl CodeGraphActivationAuthorityV1 { } } + /// Publishes a sealed generation's graph head straight from its segments + /// on disk, without decoding the generation. + /// + /// Graph prepare runs this before the serving decode so the corpus-sized + /// graph build and the decoded generation are never resident together; + /// the activation that follows recovers the head this published instead + /// of building it. `Ok(false)` abstains for a refused policy or a + /// non-persistent authority. + #[hotpath::measure(future = true, label = "code_graph.activation.publish_sealed")] + pub async fn publish_sealed_graph( + &self, + project_id: &ProjectId, + repository_id: &RepositoryId, + worktree_id: &WorktreeId, + latest: &LatestCodeTextGenerationV1, + replay_binding: CodeGraphReplayBindingV1, + cancellation: Arc, + ) -> Result { + if self.policy() == CodeGraphActivationPolicyV1::RefusedByConfiguration { + return Ok(false); + } + match self { + Self::Persistent { + runtime, + project_database, + .. + } => { + let retained = hotpath::future!( + runtime.retain_code_graph_runtime( + project_id.clone(), + repository_id.clone(), + worktree_id.clone(), + latest.metadata().snapshot().reference.clone(), + latest.metadata().manifest().generation_id.clone(), + Arc::clone(project_database), + replay_binding, + ), + label = "code_graph.activation.publish_sealed.retain_runtime" + ) + .await + .map_err(|error| CodeIndexSchedulerErrorV1::GraphActivation(error.to_string()))?; + tokio::task::spawn_blocking(move || { + retained.publish_verified_snapshot(cancellation).map(drop) + }) + .await + .map_err(|error| { + CodeIndexSchedulerErrorV1::GraphActivation(format!( + "sealed graph publication task failed: {error}" + )) + })? + .map_err(CodeGraphProjectionError::from)?; + Ok(true) + } + #[cfg(any(test, feature = "test-helpers"))] + Self::Memory { .. } => Ok(false), + } + } + #[hotpath::measure(future = true, label = "code_graph.activation.total")] pub async fn activate( &self, @@ -464,7 +521,6 @@ impl CodeGraphActivationAuthorityV1 { generation_id, Arc::clone(project_database), replay_binding, - Some(latest.generation_handle()), ), label = "code_graph.activation.retain_runtime" ) @@ -660,7 +716,7 @@ impl LatestCompleteCodeIndexV1 { let snapshot = hotpath::measure_block!( "code_graph.activation.publish_verified_snapshot", retained - .publish_verified_snapshot(&self.generation, Arc::clone(&cancellation)) + .publish_verified_snapshot(Arc::clone(&cancellation)) .map_err(CodeGraphProjectionError::from) .inspect_err(|error| { // The publication stopped at the measured-RSS watermark. diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs index 1a41ae9022..951665dca4 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs @@ -477,6 +477,7 @@ pub(super) struct GenerationDecodeBudgetV1 { } const GENERATION_DECODE_RESIDENT_COMPONENT_V1: &str = "code-index-generation-decode-v1"; +const SEALED_GRAPH_BUILD_RESIDENT_COMPONENT_V1: &str = "code-graph-sealed-build-v1"; /// The resident cost of materializing the active generation. #[derive(Clone, Debug, PartialEq, Eq)] @@ -2298,6 +2299,28 @@ impl DaemonCodeIndexPublicationStoreV1 { /// generation is measured resident like every other owner. fn admit_active_decode( &self, + ) -> Result, CodeIndexPublicationStoreErrorV1> { + self.admit_active_generation_work(GENERATION_DECODE_RESIDENT_COMPONENT_V1, "decoding") + } + + /// Charge building the active generation's code graph from its sealed + /// segments, the same way and the same bytes as decoding it: the build + /// holds the generation's cross-file resolution inputs and then its + /// compact graph store, both bounded by the generation it projects. The + /// caller holds the reservation for the build. + pub(super) fn admit_sealed_graph_build( + &self, + ) -> Result, CodeIndexPublicationStoreErrorV1> { + self.admit_active_generation_work( + SEALED_GRAPH_BUILD_RESIDENT_COMPONENT_V1, + "building the code graph of", + ) + } + + fn admit_active_generation_work( + &self, + component: &'static str, + work: &str, ) -> Result, CodeIndexPublicationStoreErrorV1> { let Some(admission) = self .decode_admission @@ -2339,7 +2362,7 @@ impl DaemonCodeIndexPublicationStoreV1 { Ok(()) } else { Err(format!( - "decoding generation {generation_id} needs {} resident bytes; {available} are \ + "{work} generation {generation_id} needs {} resident bytes; {available} are \ available below the {watermark}-byte admission watermark", requested.get() )) @@ -2366,8 +2389,7 @@ impl DaemonCodeIndexPublicationStoreV1 { ); admissible().map_err(CodeIndexPublicationStoreErrorV1::ResidentMemoryRefused)?; } - let component = ResidentMemoryComponentIdV1::new(GENERATION_DECODE_RESIDENT_COMPONENT_V1) - .map_err(Self::unavailable)?; + let component = ResidentMemoryComponentIdV1::new(component).map_err(Self::unavailable)?; admission .resident_memory .reserve( diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/registry/mount.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/registry/mount.rs index bfaddb1c67..1955937fd7 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/registry/mount.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/registry/mount.rs @@ -1683,6 +1683,89 @@ impl CodeIndexSchedulerRegistryV1 { } let mut result = match source_result { Ok(mut outcome) if prepare_graph => { + // Publish the graph head from the sealed segments + // before the serving decode below. The graph build and + // the decoded generation are this step's two + // corpus-sized working sets and must not be resident + // together; the activation after the decode recovers + // the head published here. + let mut graph_publish_refusal = None; + if let Some(text) = graph_text.as_ref() { + let generation_id = text.metadata().manifest().generation_id.clone(); + let binding_scheduler = Arc::clone(&worker_scheduler); + let shutting_down = Arc::clone(&worker_shutting_down); + let binding_passes = Arc::clone(&worker_reconcile_in_progress); + // The build is admitted like the decode it + // replaces: charged before it runs, parked when it + // does not fit, and holding its reservation until + // the head is published. + let admitted_binding = tokio::task::spawn_blocking(move || { + let (_step, scheduler) = Self::lock_scheduler_for_graph_step( + &binding_scheduler, + &shutting_down, + &binding_passes, + )?; + let binding = + scheduler.code_graph_replay_binding(&generation_id)?; + let admission = scheduler + .active_generation_decoder() + .map(|decoder| decoder.admit_sealed_graph_build()) + .transpose(); + Ok::<_, CodeIndexSchedulerErrorV1>((binding, admission)) + }) + .await; + match admitted_binding { + Ok(Ok(( + _, + Err(CodeIndexPublicationStoreErrorV1::ResidentMemoryRefused( + detail, + )), + ))) => graph_publish_refusal = Some(detail), + Ok(Ok((_, Err(error)))) => tracing::warn!( + event = "code_index_graph_publish_admission_failed", + error = %error, + "sealed graph build admission failed; activation publishes \ + the graph after the serving decode" + ), + Ok(Ok((replay_binding, Ok(reservation)))) => { + let published = worker_graph_activation + .publish_sealed_graph( + &worker_project_id, + &worker_repository_id, + &worker_worktree_id, + text, + replay_binding, + Arc::clone(&worker_shutting_down), + ) + .await; + drop(reservation); + match published { + Ok(_) => {} + Err(error) if error.is_resident_memory_graph_refusal() => { + graph_publish_refusal = Some(error.to_string()); + } + Err(error) => tracing::warn!( + event = "code_index_graph_publish_before_decode_failed", + error = %error, + "sealed graph publication failed before the serving \ + decode; activation retries it after the decode" + ), + } + } + Ok(Err(error)) => tracing::warn!( + event = "code_index_graph_publish_binding_unavailable", + error = %error, + "sealed replay binding is unavailable; activation publishes \ + the graph after the serving decode" + ), + Err(error) => tracing::warn!( + event = "code_index_graph_publish_binding_task_failed", + error = %error, + "sealed replay binding task failed; activation publishes the \ + graph after the serving decode" + ), + } + } let graph_scheduler = Arc::clone(&worker_scheduler); let graph_text = graph_text.clone(); let shutting_down = Arc::clone(&worker_shutting_down); @@ -1691,6 +1774,11 @@ impl CodeIndexSchedulerRegistryV1 { let prepare_wake = Arc::clone(&worker_wake); match hotpath::future!( tokio::task::spawn_blocking(move || { + // A graph build the memory watermark stopped + // parks exactly like a decode that does not fit. + if let Some(detail) = graph_publish_refusal { + return Ok((None, None, false, Some(detail))); + } let decoder = Self::lock_scheduler_for_graph_step( &graph_scheduler, &shutting_down, diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/reconcile.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/reconcile.rs index 0e9a99c5ee..0f5c115d8c 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/reconcile.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/reconcile.rs @@ -1414,16 +1414,57 @@ fn occurrence_graph_store_is_available_before_catalog_warm() { tracedecay_graph_db::GraphNamespace::new("code-graph").expect("graph namespace"), ) .expect("projection identity"); - let manifest = - crate::code_index::graph_projection::build_published_code_graph_manifest_checked( + // Build the graph the way publication does: from the sealed segments on + // disk, one window of files at a time. + let binding = scheduler + .code_graph_replay_binding(&generation_id) + .expect("sealed replay binding"); + let digest = tracedecay_domain::sha256_hex_suffix(binding.sealed_state_digest.as_str()) + .expect("sha256 sealed digest"); + let sealed_manifest = std::fs::read( + binding + .generations_root + .join(format!("generation-{digest}.json")), + ) + .expect("sealed manifest"); + let segments_root = + tracedecay_code_index_retention::code_index_generations::code_generation_segments_root( + binding.generations_root.parent().expect("store root"), + ); + let source = + crate::code_index::production::SealedGenerationFileWindowsV1::open(&sealed_manifest) + .expect("sealed manifest opens"); + let scratch = TempDir::new().expect("graph row scratch"); + let manifest = crate::code_index::graph_projection::build_sealed_code_graph_rows( + projection.clone(), + &source, + &mut |request, buffer| { + let crate::code_index::production::SealedGenerationSegmentReadV1::Whole { + digest, .. + } = request + else { + panic!("the graph build reads whole file segments"); + }; + *buffer = std::fs::read(segments_root.join(format!( + "segment-{}.json", + tracedecay_domain::sha256_hex_suffix(digest.as_str()).expect("segment digest") + ))) + .expect("sealed segment"); + Ok(()) + }, + &projector_revision, + tracedecay_graph_db::GraphGenerationRowSpill::create( + scratch.path().join("rows"), projection, - latest.generation(), - &projector_revision, - &|| Ok(()), ) - .expect("code graph manifest"); + .expect("row spill"), + &|| Ok(()), + ) + .expect("code graph rows") + .materialize(&|| Ok(())) + .expect("code graph manifest"); let snapshot = tracedecay_graph_db::VerifiedGraphSnapshot::memory( - manifest.as_ref().clone(), + manifest, Arc::new(tracedecay_graph_db::NeverCancelled), ) .expect("verified graph snapshot"); diff --git a/crates/tracedecay-code-index/src/graph_projection.rs b/crates/tracedecay-code-index/src/graph_projection.rs index 3fa98fd13b..8b319cc925 100644 --- a/crates/tracedecay-code-index/src/graph_projection.rs +++ b/crates/tracedecay-code-index/src/graph_projection.rs @@ -16,15 +16,16 @@ use tracedecay_domain::{ EdgeAuthorityV1, FileOccurrenceId, LanguageDescriptorRevision, RelationEdgeKindV1, RepositoryId, SourceFreshness, SourceSpan, SymbolOccurrenceId, canonical_sha256, }; +#[cfg(any(feature = "test-helpers", feature = "eval-helpers"))] +use tracedecay_graph_db::NeverCancelled; use tracedecay_graph_db::{ GraphCancellation, GraphConflictContextV1, GraphDbError, GraphEntity, GraphEntityId, - GraphEntityRef, GraphGenerationId, GraphGenerationManifest, GraphIdempotencyKey, GraphLabel, - GraphNamespace, GraphProjectionId, GraphProjectionIdentity, GraphProjectorRevision, - GraphProperty, GraphPropertyName, GraphServingEnginePin, GraphTraversalDirection, - SourceGeneration, TraversalRequest, VerifiedGraphSnapshot, + GraphEntityRef, GraphGenerationId, GraphGenerationManifest, GraphGenerationManifestIdentity, + GraphIdempotencyKey, GraphLabel, GraphNamespace, GraphProjectionId, GraphProjectionIdentity, + GraphProjectorRevision, GraphProperty, GraphPropertyName, GraphServingEnginePin, + GraphTraversalDirection, GraphWatermark, SourceGeneration, TraversalRequest, + VerifiedGraphSnapshot, }; -#[cfg(any(feature = "test-helpers", feature = "eval-helpers"))] -use tracedecay_graph_db::{GraphWatermark, NeverCancelled}; mod builder; mod interactive; @@ -32,7 +33,8 @@ mod reader; mod schema; mod traversal; -pub use self::builder::build_published_code_graph_manifest_checked; +pub use self::builder::build_sealed_code_graph_rows; +pub(crate) use self::builder::unresolved_call_limitations; use self::builder::{ProductionCodeGraphInputs, build_projection}; use self::interactive::InteractiveCatalogCache; pub use self::interactive::{ @@ -178,6 +180,23 @@ pub enum CodeGraphProjectionError { Closed, } +/// Why a sealed generation's graph rows could not be built: its segments did +/// not decode into a readable generation, or the rows it decoded did not +/// project. +#[derive(Debug, Error)] +pub enum SealedCodeGraphRowsError { + #[error(transparent)] + Source(#[from] crate::production::CodeIndexProductionErrorV1), + #[error(transparent)] + Projection(#[from] CodeGraphProjectionError), +} + +impl From for SealedCodeGraphRowsError { + fn from(error: GraphDbError) -> Self { + Self::Projection(error.into()) + } +} + impl From for CodeGraphProjectionError { fn from(error: GraphDbError) -> Self { match error { @@ -819,6 +838,23 @@ pub fn code_graph_generation_id( GraphGenerationId::new(format!("code-graph:{}", digest.as_str())).map_err(Into::into) } +/// The identity half of the graph generation one sealed code generation +/// publishes: everything that names it and binds it to its (empty) +/// dependency closure, derived without a single row. +pub fn code_graph_manifest_identity( + projection: GraphProjectionIdentity, + generation: &CodeGenerationId, + projector_revision: &GraphProjectorRevision, +) -> Result { + Ok(GraphGenerationManifestIdentity::new( + projection, + code_graph_generation_id(generation, projector_revision)?, + source_generation(generation)?, + GraphWatermark::new(stable_identity("watermark", generation.as_str()))?, + Vec::new(), + )) +} + pub fn code_graph_idempotency_key( generation: &CodeGenerationId, projector_revision: &GraphProjectorRevision, diff --git a/crates/tracedecay-code-index/src/graph_projection/builder.rs b/crates/tracedecay-code-index/src/graph_projection/builder.rs index f93ca89d1e..6f67d69e01 100644 --- a/crates/tracedecay-code-index/src/graph_projection/builder.rs +++ b/crates/tracedecay-code-index/src/graph_projection/builder.rs @@ -1,4 +1,4 @@ -use std::collections::{BTreeMap, BTreeSet}; +use std::collections::{BTreeMap, BTreeSet, HashSet}; use std::panic::{AssertUnwindSafe, catch_unwind}; use std::sync::Arc; @@ -9,15 +9,19 @@ use crate::chunks::{ typescript_family_path, }; use crate::lineage::{GenerationSymbolIndexV1, LineageSymbolRecordV1}; -use crate::production::CodeIndexPublishedGenerationV1; +use crate::production::{ + CodeGraphFileBatchV1, CodeGraphResolutionV1, SealedGenerationFileWindowsV1, + SealedGenerationSegmentReaderV1, +}; use tracedecay_domain::{ CanonicalRelationEdgeV1, CodeGenerationId, CodeSearchChunkV1, EdgeAuthorityV1, - FileOccurrenceId, RelationEdgeKindV1, SanitizedCodeFileV1, SymbolOccurrenceId, + FileOccurrenceId, RelationEdgeKindV1, SanitizedCodeFileV1, SnapshotFileDispositionV1, + SymbolOccurrenceId, }; use tracedecay_graph_db::{ - GraphDbError, GraphEntity, GraphEntityId, GraphEntityRef, GraphGenerationManifest, - GraphGenerationRelation, GraphLabel, GraphProjectionIdentity, GraphProjectorRevision, - GraphPropertyName, GraphRelationId, GraphRelationKind, GraphWatermark, + GraphDbError, GraphEntity, GraphEntityId, GraphEntityRef, GraphGenerationRelation, + GraphGenerationRowSpill, GraphLabel, GraphProjectionIdentity, GraphProjectorRevision, + GraphPropertyName, GraphRelationId, GraphRelationKind, GraphWatermark, SpilledGraphGeneration, }; use super::schema::{ @@ -27,39 +31,141 @@ use super::schema::{ }; use super::{ CodeGraphProjectionError, CodeGraphSymbolBindingV1, EDGE_LABEL, EDGE_RECORD_PROPERTY, - FILE_SYMBOL_EDGE_KIND, SymbolRecordV1, TARGET_EDGE_KIND, - build_code_graph_manifest_inputs_checked, compare_edges, current_generation_entity, + FILE_SYMBOL_EDGE_KIND, SealedCodeGraphRowsError, SymbolRecordV1, TARGET_EDGE_KIND, + code_graph_manifest_identity, compare_edges, current_generation_entity, projection, source_edge_kind, symbol_entity, symbol_entity_id, validate_edge, }; -#[hotpath::measure(label = "code_index.graph.build_manifest")] -pub fn build_published_code_graph_manifest_checked( +/// Builds a sealed generation's code graph from its on-disk file segments and +/// spills the rows, never assembling the generation. +/// +/// Two passes over the segments, one bounded window of files at a time: +/// 1. Resolution keeps only what cross-file resolution reads (symbols, +/// unresolved references, imports, and per-file edges) and derives the +/// cross-file edges, the bound symbol set, and the unresolved-call +/// limitations. Everything else a window decoded is dropped with it. +/// 2. Emission re-reads each window, emits its file, import, symbol, and +/// edge rows through the same emitter the whole-set build uses, and +/// pushes them to `spill`. The window's decoded segments are released +/// before the next is read. +/// +/// The spill sorts and merges the rows on disk into the canonical order the +/// sealed store and the recovered digest require, so the result is +/// byte-identical to the manifest the whole generation would project. +#[hotpath::measure(label = "code_index.graph.build_rows")] +pub fn build_sealed_code_graph_rows( projection: GraphProjectionIdentity, - generation: &CodeIndexPublishedGenerationV1, + source: &SealedGenerationFileWindowsV1, + read_segment: &mut SealedGenerationSegmentReaderV1<'_>, projector_revision: &GraphProjectorRevision, + mut spill: GraphGenerationRowSpill, check: &dyn Fn() -> Result<(), GraphDbError>, -) -> Result, CodeGraphProjectionError> { +) -> Result { check()?; + if projection.projection != self::projection()? { + return Err(CodeGraphProjectionError::Contract( + "code graph projection identity uses a foreign projector".to_owned(), + ) + .into()); + } + let generation = source.generation_id().clone(); generation .validate() .map_err(|error| CodeGraphProjectionError::Contract(error.to_string()))?; - let generation_id = &generation.manifest().generation_id; - if generation.symbols().generation_id != *generation_id { - return Err(CodeGraphProjectionError::GenerationMismatch); - } - // A published generation is immutable, so this manifest is a pure function - // of (generation, projection identity, projector revision). Seat retries - // and the seat/reconcile duplicate publication of one sealed generation - // reuse the first complete build instead of re-serializing and re-hashing - // every entity and relation. Fail-closed: only a fully successful build is - // memoized, an interrupted or deadline-exceeded build records nothing, - // and the `check` above refuses a cancelled or expired request before a - // memo hit can be served. - if let Some(manifest) = generation.memoized_graph_manifest(&projection, projector_revision) { - return Ok(manifest); - } + let resolution: CodeGraphResolutionV1 = hotpath::measure_block!( + "code_index.graph.build_rows.resolve", + source.resolve_code_graph(read_segment, check) + )?; + let unresolved_by_source = group_unresolved_calls(&resolution.unresolved_calls, check)?; + let snapshot = source.snapshot(); + let files = snapshot + .files + .iter() + .map(|file| (&file.file_occurrence_id, file)) + .collect::>(); + let context = CodeGraphRowContext { + projection: &projection, + generation: &generation, + files: Some(&files), + bound: &resolution.bound, + unresolved_by_source: &unresolved_by_source, + }; + hotpath::measure_block!("code_index.graph.build_rows.emit", { + source.for_each_code_graph_batch(read_segment, &mut |batch: CodeGraphFileBatchV1< + '_, + >| { + check()?; + let rows = emit_code_graph_rows( + &context, + &CodeGraphRowBatch { + files: &batch.files, + imports: &batch.imports, + chunks: &batch.chunks, + symbols: &batch.symbols, + edges: &batch.edges, + }, + check, + )?; + drop(batch); + spill.push_batch(rows.entities, rows.relations, check)?; + Ok::<(), SealedCodeGraphRowsError>(()) + })?; + // The rows no window owns: snapshot files sealed without a segment + // and the cross-file edges resolution derived. + let unsegmented = snapshot + .files + .iter() + .filter(|file| file.disposition != SnapshotFileDispositionV1::Present) + .collect::>(); + let rows = emit_code_graph_rows( + &context, + &CodeGraphRowBatch { + files: &unsegmented, + imports: &[], + chunks: &[], + symbols: &[], + edges: &resolution.cross_file_edges, + }, + check, + )?; + spill.push_batch(rows.entities, rows.relations, check)?; + Ok::<(), SealedCodeGraphRowsError>(()) + })?; + drop(resolution); + // The generation marker counts every entity, itself included. + let projection_node_count = spill.distinct_entities().checked_add(1).ok_or_else(|| { + CodeGraphProjectionError::Contract("code graph projection node count overflowed".to_owned()) + })?; + spill.push_batch( + vec![current_generation_entity( + &generation, + projection_node_count, + )?], + Vec::new(), + check, + )?; + let identity = code_graph_manifest_identity(projection, &generation, projector_revision)?; + hotpath::measure_block!( + "code_index.graph.build_rows.merge", + spill.finish(identity, check) + ) + .map_err(Into::into) +} + +/// The unresolved receiver and import calls a graph discloses on their source +/// symbols, derived from every retained reference and edge of a generation. +/// +/// A dotted Rust-style call stays a limitation unless the canonical resolver +/// bound its exact receiver site; TypeScript member calls are decided by the +/// module resolver and arrive in `typescript_unresolved`. +pub(crate) fn unresolved_call_limitations<'a>( + references: &[(&str, &'a CodeIndexUnresolvedReferenceV1)], + edges: impl Iterator, + typescript_unresolved: Vec, + check: &dyn Fn() -> Result<(), GraphDbError>, +) -> Result, CodeGraphProjectionError> { let mut site_candidates = BTreeMap::new(); - for (_, reference) in generation.unresolved_references() { + for &(_, reference) in references { check()?; reference .validate() @@ -72,7 +178,7 @@ pub fn build_published_code_graph_manifest_checked( } } let mut resolved_sites = BTreeMap::new(); - for edge in generation.edges() { + for edge in edges { check()?; if edge.kind == RelationEdgeKindV1::Calls && edge.authority == EdgeAuthorityV1::NameResolved { @@ -90,10 +196,10 @@ pub fn build_published_code_graph_manifest_checked( } } let mut unresolved_calls = Vec::new(); - for (logical_path, reference) in generation.unresolved_references() { + for &(logical_path, reference) in references { check()?; // TypeScript member calls are retained only through an imported - // namespace; the module resolver below decides which are gaps. + // namespace; the module resolver decides which are gaps. if typescript_family_path(logical_path) { continue; } @@ -120,29 +226,25 @@ pub fn build_published_code_graph_manifest_checked( // A TypeScript call whose import names project code the seal could not // bind is the same kind of disclosed gap as an unresolved Rust receiver. check()?; - unresolved_calls.extend(generation.unresolved_typescript_import_calls()); + unresolved_calls.extend(typescript_unresolved); unresolved_calls.sort(); unresolved_calls.dedup(); - let manifest = Arc::new(build_code_graph_manifest_inputs_checked( - projection.clone(), - generation_id, - generation.edges(), - generation.chunks().chunks(), - Some(ProductionCodeGraphInputs { - files: &generation.snapshot().files, - symbols: generation.symbols(), - imports: generation.imports(), - unresolved_calls: &unresolved_calls, - }), - projector_revision, - check, - )?); - generation.memoize_graph_manifest( - projection, - projector_revision.clone(), - Arc::clone(&manifest), - ); - Ok(manifest) + Ok(unresolved_calls) +} + +fn group_unresolved_calls<'a>( + unresolved_calls: &'a [CodeIndexUnresolvedReferenceV1], + check: &dyn Fn() -> Result<(), GraphDbError>, +) -> Result>, GraphDbError> { + let mut by_source = BTreeMap::<_, Vec<_>>::new(); + for reference in unresolved_calls { + check()?; + by_source + .entry(&reference.from_occurrence) + .or_default() + .push(reference.clone()); + } + Ok(by_source) } pub(super) struct BuiltProjection { @@ -203,6 +305,34 @@ where .map_err(|error| CodeGraphProjectionError::Unavailable(error.to_string()))? } +/// What the whole generation contributes to every batch's rows: the snapshot +/// files bindings and imports must belong to, the symbols some batch binds or +/// describes, and the unresolved calls each source symbol discloses. +struct CodeGraphRowContext<'a> { + projection: &'a GraphProjectionIdentity, + generation: &'a CodeGenerationId, + /// `None` for a hermetic publish without a snapshot, whose chunks must + /// name the serving generation instead. + files: Option<&'a BTreeMap<&'a FileOccurrenceId, &'a SanitizedCodeFileV1>>, + bound: &'a HashSet, + unresolved_by_source: &'a BTreeMap<&'a SymbolOccurrenceId, Vec>, +} + +/// One batch of a generation's rows: the files it owns and the chunks, +/// symbols, imports, and edges those files produced. +struct CodeGraphRowBatch<'a> { + files: &'a [&'a SanitizedCodeFileV1], + imports: &'a [CodeIndexImportEvidenceV1], + chunks: &'a [Arc], + symbols: &'a [Arc], + edges: &'a [CanonicalRelationEdgeV1], +} + +struct EmittedRows { + entities: Vec, + relations: Vec, +} + pub(super) fn build_projection( projection: &GraphProjectionIdentity, generation: &CodeGenerationId, @@ -214,51 +344,95 @@ pub(super) fn build_projection( generation .validate() .map_err(|error| CodeGraphProjectionError::Contract(error.to_string()))?; - let mut unresolved_by_source = BTreeMap::<_, Vec<_>>::new(); - for reference in production - .into_iter() - .flat_map(|inputs| inputs.unresolved_calls) - { - check()?; - unresolved_by_source - .entry(&reference.from_occurrence) - .or_default() - .push(reference.clone()); - } - let (files, symbol_metadata, imports, bindings, retained_edges, occurrences) = + let unresolved_by_source = group_unresolved_calls( + production.map_or(&[], |inputs| inputs.unresolved_calls), + check, + )?; + let files = production.map(|inputs| { + inputs + .files + .iter() + .map(|file| (&file.file_occurrence_id, file)) + .collect::>() + }); + let symbols = production.map_or(&[][..], |inputs| inputs.symbols.symbols.as_slice()); + // The whole set is one batch, so every symbol it binds or describes is + // bound for edge retention exactly as the batch itself sees it. + let bound = chunks + .iter() + .filter_map(|chunk| chunk.anchor.symbol_occurrence_id.clone()) + .chain(symbols.iter().map(|symbol| symbol.occurrence.clone())) + .collect::>(); + let file_rows = files + .as_ref() + .map(|files| files.values().copied().collect::>()) + .unwrap_or_default(); + let context = CodeGraphRowContext { + projection, + generation, + files: files.as_ref(), + bound: &bound, + unresolved_by_source: &unresolved_by_source, + }; + let EmittedRows { + mut entities, + relations, + } = emit_code_graph_rows( + &context, + &CodeGraphRowBatch { + files: &file_rows, + imports: production.map_or(&[], |inputs| inputs.imports), + chunks, + symbols, + edges, + }, + check, + )?; + let projection_node_count = entities.len().checked_add(1).ok_or_else(|| { + CodeGraphProjectionError::Contract("code graph projection node count overflowed".to_owned()) + })?; + entities.push(current_generation_entity( + generation, + projection_node_count, + )?); + Ok(BuiltProjection { + watermark: GraphWatermark::new(stable_identity("watermark", generation.as_str()))?, + entities, + relations, + }) +} + +/// Emits one batch's rows. Every row a generation projects belongs to exactly +/// one batch, except that an edge target no batch binds or describes is +/// emitted by each batch whose edges reach it, identically, so the union of +/// all batches, sorted and deduplicated, is the whole-set projection. +fn emit_code_graph_rows( + context: &CodeGraphRowContext<'_>, + batch: &CodeGraphRowBatch<'_>, + check: &dyn Fn() -> Result<(), GraphDbError>, +) -> Result { + let projection = context.projection; + let (symbol_metadata, bindings, retained_edges, occurrences) = hotpath::measure_block!("code_index.seal.collect.bind", { - let files = production - .map(|inputs| { - inputs - .files - .iter() - .map(|file| (file.file_occurrence_id.clone(), file)) - .collect::>() - }) - .unwrap_or_default(); - let symbol_metadata = production - .map(|inputs| { - inputs - .symbols - .symbols - .iter() - .map(|symbol| (symbol.occurrence.clone(), symbol)) - .collect::>() - }) - .unwrap_or_default(); - let imports: &[CodeIndexImportEvidenceV1] = - production.map_or(&[], |inputs| inputs.imports); - for import in imports { + let symbol_metadata = batch + .symbols + .iter() + .map(|symbol| (symbol.occurrence.clone(), symbol)) + .collect::>(); + for import in batch.imports { check()?; import .validate() .map_err(|error| CodeGraphProjectionError::Contract(error.to_string()))?; - let file = files.get(&import.file_occurrence_id).ok_or_else(|| { - CodeGraphProjectionError::Contract( - "code graph import refers to a file outside its immutable snapshot" - .to_owned(), - ) - })?; + let file = context + .files + .and_then(|files| files.get(&import.file_occurrence_id)) + .ok_or_else(|| { + CodeGraphProjectionError::Contract( + "code graph import refers to a file outside its immutable snapshot" + .to_owned(), + ) + })?; if file.logical_path != import.logical_path { return Err(CodeGraphProjectionError::Contract( "code graph import logical path does not match its file occurrence" @@ -267,8 +441,8 @@ pub(super) fn build_projection( } } let mut bindings = BTreeMap::::new(); - let symbol_spans = published_symbol_spans(chunks.iter().map(AsRef::as_ref)); - for chunk in chunks { + let symbol_spans = published_symbol_spans(batch.chunks.iter().map(AsRef::as_ref)); + for chunk in batch.chunks { check()?; chunk .validate() @@ -277,29 +451,29 @@ pub(super) fn build_projection( // the serving binding and file-page generation_id is extraction // provenance. Hermetic publishes without a snapshot still require // the chunk to name the serving generation. - match production { - Some(_) => { - if !files.contains_key(&chunk.anchor.file_occurrence_id) { + let logical_path = match context.files { + Some(files) => { + let Some(file) = files.get(&chunk.anchor.file_occurrence_id) else { return Err(CodeGraphProjectionError::Contract( "code graph chunk refers to a file outside its immutable snapshot" .to_owned(), )); - } + }; + Some(file.logical_path.clone()) } None => { - if chunk.anchor.generation_id != *generation { + if chunk.anchor.generation_id != *context.generation { return Err(CodeGraphProjectionError::GenerationMismatch); } + None } - } + }; let Some(symbol) = chunk.anchor.symbol_occurrence_id.clone() else { continue; }; let candidate = CodeGraphSymbolBindingV1 { file: chunk.anchor.file_occurrence_id.clone(), - logical_path: files - .get(&chunk.anchor.file_occurrence_id) - .map(|file| file.logical_path.clone()), + logical_path, source_span: symbol_spans.get(&symbol).copied(), chunk: Some(chunk.id.clone()), language_descriptor_revision: chunk.language_descriptor_revision.clone(), @@ -328,12 +502,10 @@ pub(super) fn build_projection( } let mut retained_edges = Vec::new(); - for edge in edges { + for edge in batch.edges { check()?; validate_edge(edge)?; - if bindings.contains_key(&edge.from_occurrence) - || symbol_metadata.contains_key(&edge.from_occurrence) - { + if context.bound.contains(&edge.from_occurrence) { retained_edges.push(edge.clone()); } } @@ -346,14 +518,14 @@ pub(super) fn build_projection( .cloned() .collect::>(); for edge in &retained_edges { - occurrences.push(edge.to_occurrence.clone()); + if !context.bound.contains(&edge.to_occurrence) { + occurrences.push(edge.to_occurrence.clone()); + } } occurrences.sort(); occurrences.dedup(); Ok::<_, CodeGraphProjectionError>(( - files, symbol_metadata, - imports, bindings, retained_edges, occurrences, @@ -366,49 +538,41 @@ pub(super) fn build_projection( // only multiplied every graph artifact by the chunk count. hotpath::measure_block!("code_index.seal.collect.emit", { let mut entities = Vec::with_capacity( - files + batch + .files .len() - .saturating_add(imports.len()) + .saturating_add(batch.imports.len()) .saturating_add(occurrences.len()) - .saturating_add(retained_edges.len()) - .saturating_add(1), + .saturating_add(retained_edges.len()), ); let mut relations = Vec::with_capacity( retained_edges .len() .saturating_mul(2) .saturating_add(bindings.len()) - .saturating_add(imports.len()), + .saturating_add(batch.imports.len()), ); - // Every stable identity below is a serialize-and-hash; each is computed - // exactly once and reused by the entity and every relation that names it, - // instead of being re-derived per emission site. - let mut file_ids = BTreeMap::::new(); - for file in files.values() { + for file in batch.files { check()?; - let identity = file_entity_id(&file.file_occurrence_id)?; - entities.push(file_entity(identity.clone(), file)?); - file_ids.insert(file.file_occurrence_id.clone(), identity); + entities.push(file_entity( + file_entity_id(&file.file_occurrence_id)?, + file, + )?); } - for import in imports { + for import in batch.imports { check()?; let identity = import_entity_id(import)?; - let file_id = file_ids - .get(&import.file_occurrence_id) - .cloned() - .ok_or_else(|| { - CodeGraphProjectionError::Contract( - "code graph import refers to a file outside its immutable snapshot" - .to_owned(), - ) - })?; + let file_id = file_entity_id(&import.file_occurrence_id)?; relations.push(file_import_relation( projection, import, file_id, &identity, )?); entities.push(import_entity(identity, import)?); } + // Every stable identity below is a serialize-and-hash; each symbol's + // is computed once and reused by its entity and every relation that + // names it. An edge target another batch owns is derived on use. let mut symbol_ids = BTreeMap::::new(); let row_window = crate::parallelism::indexing_workers() .max(1) @@ -430,7 +594,8 @@ pub(super) fn build_projection( .get(occurrence) .map(|record| LineageSymbolRecordV1::clone(record)), occurrence: occurrence.clone(), - unresolved_calls: unresolved_by_source + unresolved_calls: context + .unresolved_by_source .get(occurrence) .cloned() .unwrap_or_default(), @@ -438,19 +603,14 @@ pub(super) fn build_projection( symbol_entity(identity, record) })?); } - if production.is_some() { + if context.files.is_some() { let binding_rows = bindings.iter().collect::>(); for window in binding_rows.chunks(row_window) { check()?; relations.extend(collect_graph_rows_ordered( window, |&(occurrence, binding)| { - let file_id = file_ids.get(&binding.file).cloned().ok_or_else(|| { - CodeGraphProjectionError::Contract( - "code graph binding refers to a file outside its immutable snapshot" - .to_owned(), - ) - })?; + let file_id = file_entity_id(&binding.file)?; let symbol_id = require_symbol_id(&symbol_ids, occurrence)?; file_symbol_relation(projection, binding, file_id, occurrence, symbol_id) }, @@ -467,18 +627,7 @@ pub(super) fn build_projection( relations.push(target); } } - let projection_node_count = entities.len().checked_add(1).ok_or_else(|| { - CodeGraphProjectionError::Contract( - "code graph projection node count overflowed".to_owned(), - ) - })?; - entities.push(current_generation_entity( - generation, - projection_node_count, - )?); - - Ok(BuiltProjection { - watermark: GraphWatermark::new(stable_identity("watermark", generation.as_str()))?, + Ok(EmittedRows { entities, relations, }) @@ -496,6 +645,18 @@ fn require_symbol_id<'ids>( }) } +/// A batch's own symbol identity, or the derived identity of an edge target +/// another batch emits. +fn endpoint_symbol_id( + symbol_ids: &BTreeMap, + occurrence: &SymbolOccurrenceId, +) -> Result { + match symbol_ids.get(occurrence) { + Some(identity) => Ok(identity.clone()), + None => symbol_entity_id(occurrence), + } +} + /// One retained edge's entity plus both endpoint relations, sharing a single /// serialization and identity derivation of the edge payload. fn edge_artifacts( @@ -520,11 +681,11 @@ fn edge_artifacts( record_property(payload)?, )]), )?; - let from = require_symbol_id(symbol_ids, &edge.from_occurrence)?; - let to = require_symbol_id(symbol_ids, &edge.to_occurrence)?; + let from = endpoint_symbol_id(symbol_ids, &edge.from_occurrence)?; + let to = endpoint_symbol_id(symbol_ids, &edge.to_occurrence)?; let source = GraphGenerationRelation::new( GraphRelationId::new(stable_identity("source", identity.as_str()))?, - GraphEntityRef::new(projection.clone(), from.clone()), + GraphEntityRef::new(projection.clone(), from), GraphEntityRef::new(projection.clone(), identity.clone()), GraphRelationKind::new(source_edge_kind(edge.kind))?, BTreeMap::new(), @@ -532,7 +693,7 @@ fn edge_artifacts( let target = GraphGenerationRelation::new( GraphRelationId::new(stable_identity("target", identity.as_str()))?, GraphEntityRef::new(projection.clone(), identity), - GraphEntityRef::new(projection.clone(), to.clone()), + GraphEntityRef::new(projection.clone(), to), GraphRelationKind::new(TARGET_EDGE_KIND)?, BTreeMap::new(), )?; diff --git a/crates/tracedecay-code-index/src/production/graph_inputs.rs b/crates/tracedecay-code-index/src/production/graph_inputs.rs new file mode 100644 index 0000000000..0245291209 --- /dev/null +++ b/crates/tracedecay-code-index/src/production/graph_inputs.rs @@ -0,0 +1,185 @@ +//! What a sealed generation's file segments contribute to its code graph, +//! read back one window at a time. + +use std::collections::HashSet; +use std::sync::Arc; + +use tracedecay_domain::{ + CanonicalRelationEdgeV1, CodeSearchChunkV1, SanitizedCodeFileV1, SymbolOccurrenceId, +}; +use tracedecay_graph_db::GraphDbError; + +use crate::chunks::{ + CodeFileChunksV1, CodeFileIndexArtifactsV1, CodeIndexImportEvidenceV1, + CodeIndexUnresolvedReferenceV1, ExactExtractionAuthorityV1, +}; +use crate::graph_projection::{SealedCodeGraphRowsError, unresolved_call_limitations}; +use crate::lineage::LineageSymbolRecordV1; + +use super::helpers::{resolve_cross_file_references, unresolved_typescript_import_calls}; +use super::partitioned_codec::{SealedGenerationFileWindowsV1, SealedGenerationSegmentReaderV1}; +use super::sealed_codec::PersistedFileGenerationArtifactsV1; +use super::{CodeIndexProductionErrorV1, FileGenerationArtifactsV1}; + +/// The whole-generation inputs cross-file resolution derives, the only state +/// resident across the row-emission pass. +pub(crate) struct CodeGraphResolutionV1 { + /// Every symbol occurrence some file binds through a chunk or describes + /// with metadata; only edges from these are retained. + pub(crate) bound: HashSet, + /// The edges sealing derives across files; no file segment carries them. + pub(crate) cross_file_edges: Vec, + /// The call limitations each source symbol discloses, canonically ordered. + pub(crate) unresolved_calls: Vec, +} + +/// The rows one window of sealed files owns. +pub(crate) struct CodeGraphFileBatchV1<'a> { + pub(crate) files: Vec<&'a SanitizedCodeFileV1>, + pub(crate) imports: Vec, + pub(crate) chunks: Vec>, + pub(crate) symbols: Vec>, + pub(crate) edges: Vec, +} + +impl SealedGenerationFileWindowsV1 { + /// Reads every segment once and derives the cross-file graph inputs. + /// + /// Each file is reduced to what resolution reads, its symbols, imports, + /// edges, unresolved references, and document, as its window decodes; + /// chunk rows and clone streams are dropped with the window. + pub(crate) fn resolve_code_graph( + &self, + read_segment: &mut SealedGenerationSegmentReaderV1<'_>, + check: &dyn Fn() -> Result<(), GraphDbError>, + ) -> Result { + let mut bound = HashSet::new(); + let mut files = Vec::new(); + self.for_each_file_window(read_segment, |window| { + check()?; + for (_, page) in window { + bound.extend( + page.artifacts + .chunks + .chunks + .iter() + .filter_map(|chunk| chunk.anchor.symbol_occurrence_id.clone()), + ); + files.push(resolution_file(page)?); + } + Ok::<(), SealedCodeGraphRowsError>(()) + })?; + bound.extend( + files + .iter() + .flat_map(|file| file.artifacts.symbols.iter()) + .map(|symbol| symbol.occurrence.clone()), + ); + check()?; + let cross_file_edges = resolve_cross_file_references(&files)?; + check()?; + let typescript_unresolved = unresolved_typescript_import_calls(&files); + let references = files + .iter() + .flat_map(|file| { + file.artifacts + .unresolved_references + .iter() + .map(|reference| (file.authority.logical_path.as_str(), reference)) + }) + .collect::>(); + let unresolved_calls = unresolved_call_limitations( + &references, + files + .iter() + .flat_map(|file| file.artifacts.edges.iter()) + .chain(&cross_file_edges), + typescript_unresolved, + check, + )?; + Ok(CodeGraphResolutionV1 { + bound, + cross_file_edges, + unresolved_calls, + }) + } + + /// Reads every segment again and hands each window's graph rows to + /// `visit`, which owns them until it returns. + pub(crate) fn for_each_code_graph_batch( + &self, + read_segment: &mut SealedGenerationSegmentReaderV1<'_>, + visit: &mut dyn FnMut(CodeGraphFileBatchV1<'_>) -> Result<(), E>, + ) -> Result<(), E> + where + E: From, + { + self.for_each_file_window(read_segment, |window| { + let mut batch = CodeGraphFileBatchV1 { + files: Vec::with_capacity(window.len()), + imports: Vec::new(), + chunks: Vec::new(), + symbols: Vec::new(), + edges: Vec::new(), + }; + for (file, page) in window { + let CodeFileIndexArtifactsV1 { + chunks, + symbols, + edges, + imports, + .. + } = page.artifacts; + batch.files.push(file); + batch.imports.extend(imports); + batch.chunks.extend(chunks.chunks); + batch.symbols.extend(symbols); + batch.edges.extend(edges); + } + visit(batch) + }) + } +} + +/// A file reduced to the fields cross-file resolution reads. Its document +/// and exact authority describe the chunk rows it keeps, which are none. +fn resolution_file( + page: PersistedFileGenerationArtifactsV1, +) -> Result, CodeIndexProductionErrorV1> { + let PersistedFileGenerationArtifactsV1 { + authority, + extraction, + artifacts, + } = page; + let CodeFileIndexArtifactsV1 { + chunks, + symbols, + edges, + imports, + unresolved_references, + .. + } = artifacts; + let mut document = chunks.document; + document.chunk_ids = Vec::new(); + let chunks = CodeFileChunksV1 { + document, + chunks: Vec::new(), + }; + let exact_authority = + ExactExtractionAuthorityV1::restore(&chunks).map_err(CodeIndexProductionErrorV1::Chunk)?; + Ok(Arc::new(FileGenerationArtifactsV1 { + authority, + extraction, + artifacts: CodeFileIndexArtifactsV1 { + chunks, + symbols, + edges, + edge_abstentions: Vec::new(), + imports, + clone_bodies: Vec::new(), + schema_evidence: None, + unresolved_references, + }, + exact_authority, + })) +} diff --git a/crates/tracedecay-code-index/src/production/helpers.rs b/crates/tracedecay-code-index/src/production/helpers.rs index 9387f5e9fa..d0e7ffa49a 100644 --- a/crates/tracedecay-code-index/src/production/helpers.rs +++ b/crates/tracedecay-code-index/src/production/helpers.rs @@ -392,7 +392,7 @@ where /// authority and stay unresolved. Bound edges carry the `NameResolved` /// authority class, not `SyntaxExact`. #[hotpath::measure(label = "code_index.seal.resolve")] -fn resolve_cross_file_references( +pub(crate) fn resolve_cross_file_references( files: &[T], ) -> Result, CodeIndexProductionErrorV1> where diff --git a/crates/tracedecay-code-index/src/production/mod.rs b/crates/tracedecay-code-index/src/production/mod.rs index 9fd4e15886..974a04c1f3 100644 --- a/crates/tracedecay-code-index/src/production/mod.rs +++ b/crates/tracedecay-code-index/src/production/mod.rs @@ -21,9 +21,6 @@ use tracedecay_domain::{ SymbolOccurrenceId, TestAttributionEvidenceClassV1, UtcMicros, ValidatedCodeFileV1, WorktreeId, canonical_sha256, }; -use tracedecay_graph_db::{ - GraphGenerationManifest, GraphProjectionIdentity, GraphProjectorRevision, -}; use super::{ capabilities::{ @@ -98,11 +95,14 @@ pub use lexical_page_source::{ VerifiedSealedLexicalSourceReceiptV1, VerifiedSealedLexicalSymbolDisplayV1, VerifiedSealedTextGenerationMetadataV1, }; +mod graph_inputs; +pub(crate) use graph_inputs::{CodeGraphFileBatchV1, CodeGraphResolutionV1}; mod partitioned_codec; pub(crate) mod resident_bytes; pub use partitioned_codec::{ - SealedGenerationSegmentIdentityV1, SealedGenerationSegmentPublicationV1, - SealedGenerationSegmentReadV1, + SealedGenerationFileWindowsV1, SealedGenerationSegmentIdentityV1, + SealedGenerationSegmentPublicationV1, SealedGenerationSegmentReadV1, + SealedGenerationSegmentReaderV1, }; mod sealed_codec; pub use sealed_codec::{ @@ -797,26 +797,10 @@ pub struct CodeIndexPublishedGenerationV1 { /// every chunk on each `active_generation` call re-derived a value that is /// a pure function of the immutable generation. chunk_policy: OnceLock, - /// Reclaimable code-graph publication manifest. Concurrent seat retries - /// share a complete build while a publication caller owns it, but the - /// generation does not pin the full entity/relation projection after the - /// durable graph has consumed it. The key remains first-success-wins so a - /// foreign projection identity can never replace the canonical memo. - graph_manifest: OnceLock>>, /// [`Self::retained_bytes`] of the immutable decode, measured once. retained_bytes: OnceLock, } -/// One successfully built code-graph publication manifest, pinned to the -/// exact projection identity and projector revision it was derived under. A -/// lookup under any other identity is a memo miss, never an aliased manifest. -#[derive(Clone, Debug)] -struct CodeGraphManifestMemoV1 { - projection: GraphProjectionIdentity, - projector_revision: GraphProjectorRevision, - manifest: Weak, -} - /// The chunk policy-revision census of one immutable generation: no chunks at /// all, one uniform revision, or disagreeing revisions (which no owner /// configuration can ever be compatible with). @@ -1245,49 +1229,6 @@ impl CodeIndexPublishedGenerationV1 { }) } - /// The memoized code-graph publication manifest for exactly this - /// projection identity and projector revision, if a prior complete build - /// recorded one. A key mismatch is a miss, never a substituted manifest. - pub(crate) fn memoized_graph_manifest( - &self, - projection: &GraphProjectionIdentity, - projector_revision: &GraphProjectorRevision, - ) -> Option> { - let memo = self.graph_manifest.get()?; - let memo = match memo.lock() { - Ok(memo) => memo, - Err(poisoned) => poisoned.into_inner(), - }; - (memo.projection == *projection && memo.projector_revision == *projector_revision) - .then(|| memo.manifest.upgrade()) - .flatten() - } - - /// Record one complete, successfully built code-graph publication - /// manifest. First success wins; the generation is immutable, so any - /// competing build under the same key produced an identical manifest. - pub(crate) fn memoize_graph_manifest( - &self, - projection: GraphProjectionIdentity, - projector_revision: GraphProjectorRevision, - manifest: Arc, - ) { - let memo = self.graph_manifest.get_or_init(|| { - Arc::new(Mutex::new(CodeGraphManifestMemoV1 { - projection: projection.clone(), - projector_revision: projector_revision.clone(), - manifest: Weak::new(), - })) - }); - let mut memo = match memo.lock() { - Ok(memo) => memo, - Err(poisoned) => poisoned.into_inner(), - }; - if memo.projection == projection && memo.projector_revision == projector_revision { - memo.manifest = Arc::downgrade(&manifest); - } - } - /// Return chunks re-admitted through their parser-backed exact authority. /// Downstream exact/phrase/BM25 projections must consume this value rather /// than raw chunks, preserving the non-demotable exact tier. @@ -2245,7 +2186,6 @@ where admitted: OnceLock::new(), attribution: OnceLock::new(), chunk_policy: OnceLock::new(), - graph_manifest: OnceLock::new(), retained_bytes: OnceLock::new(), }; hotpath::measure_block!( diff --git a/crates/tracedecay-code-index/src/production/partitioned_codec.rs b/crates/tracedecay-code-index/src/production/partitioned_codec.rs index 680f8ebedc..d768df151e 100644 --- a/crates/tracedecay-code-index/src/production/partitioned_codec.rs +++ b/crates/tracedecay-code-index/src/production/partitioned_codec.rs @@ -1670,6 +1670,124 @@ fn decode_segment_window( }) } +/// Reads one sealed segment's bytes into the buffer it is handed. +pub type SealedGenerationSegmentReaderV1<'a> = dyn FnMut(SealedGenerationSegmentReadV1<'_>, &mut Vec) -> Result<(), CodeIndexProductionErrorV1> + + 'a; + +/// Files one window of a sealed generation's segments decodes at a time. +const FILE_WINDOW_FILES_PER_WORKER_V1: usize = 4; + +/// A sealed generation's file segments, decoded back one bounded window of +/// files at a time without assembling the generation. +/// +/// Only the authenticated partitioned manifest is resident: the snapshot and +/// the ordered segment descriptors. Every window's segments are verified +/// against their content addresses as they decode, and a window's decoded +/// rows are the caller's to drop before the next window is read. +pub struct SealedGenerationFileWindowsV1 { + generation: PartitionedPublishedGenerationV1, + scope: FileScopeIdentityV1, +} + +impl std::fmt::Debug for SealedGenerationFileWindowsV1 { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("SealedGenerationFileWindowsV1") + .field("generation_id", &self.generation.manifest.generation_id) + .field("files", &self.generation.file_segments.len()) + .finish_non_exhaustive() + } +} + +impl SealedGenerationFileWindowsV1 { + /// Authenticates the partitioned manifest `manifest_bytes` and indexes its + /// file segments. No segment is read. + pub fn open(manifest_bytes: &[u8]) -> Result { + let generation = parse_partitioned_manifest(manifest_bytes)?; + let scope = FileScopeIdentityV1::of(&generation.manifest, &generation.snapshot); + Ok(Self { generation, scope }) + } + + #[must_use] + pub fn generation_id(&self) -> &CodeGenerationId { + &self.generation.manifest.generation_id + } + + #[must_use] + pub fn snapshot(&self) -> &SanitizedCodeSnapshotV1 { + &self.generation.snapshot + } + + #[must_use] + pub fn manifest(&self) -> &CodeGenerationManifestV1 { + &self.generation.manifest + } + + /// Decodes every file segment in manifest order, handing each window's + /// files to `visit` with the snapshot record each file was sealed from. + pub(super) fn for_each_file_window( + &self, + read_segment: &mut SealedGenerationSegmentReaderV1<'_>, + mut visit: impl FnMut( + Vec<(&SanitizedCodeFileV1, PersistedFileGenerationArtifactsV1)>, + ) -> Result<(), E>, + ) -> Result<(), E> + where + E: From, + { + let descriptors = &self.generation.file_segments; + let window_files = crate::parallelism::indexing_workers() + .max(1) + .saturating_mul(FILE_WINDOW_FILES_PER_WORKER_V1); + let mut buffers = vec![Vec::new(); window_files]; + let mut start = 0; + while start < descriptors.len() { + let pending = &descriptors[start..]; + let read = read_segment_window( + pending, + &mut buffers, + LEXICAL_FILE_PREFETCH_BYTES_V1, + |descriptor, segment| { + read_segment( + SealedGenerationSegmentReadV1::Whole { + digest: &descriptor.segment_digest, + size_bytes: descriptor.segment_size_bytes, + }, + segment, + ) + }, + )?; + let window = &pending[..read]; + let decoded = decode_segment_window( + window, + &buffers[..read], + &self.generation.manifest.generation_id, + &self.generation.manifest.snapshot_digest, + &self.scope, + )?; + let files = window + .iter() + .zip(decoded) + .map(|(descriptor, page)| { + self.generation + .snapshot + .files + .get(descriptor.file_key as usize) + .map(|file| (file, page)) + .ok_or_else(|| { + CodeIndexProductionErrorV1::Contract( + "sealed generation file key is outside its snapshot".to_owned(), + ) + }) + }) + .collect::, _>>()?; + start += read; + visit(files)?; + } + Ok(()) + } +} + impl VerifiedSealedLexicalPageSourceV1 { pub fn open_partitioned_sealed( manifest_bytes: &[u8], diff --git a/crates/tracedecay-code-index/src/production/sealed_codec.rs b/crates/tracedecay-code-index/src/production/sealed_codec.rs index e24ba9ee72..a638c1c02c 100644 --- a/crates/tracedecay-code-index/src/production/sealed_codec.rs +++ b/crates/tracedecay-code-index/src/production/sealed_codec.rs @@ -1197,7 +1197,6 @@ pub(super) fn assemble_published_generation( admitted: OnceLock::new(), attribution: OnceLock::new(), chunk_policy: OnceLock::new(), - graph_manifest: OnceLock::new(), retained_bytes: OnceLock::new(), }; hotpath::measure_block!( 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 4af2f6a6c2..8389f05ae8 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 @@ -5,8 +5,7 @@ use tracedecay_code_index::{ chunks::CodeIndexImportEvidenceV1, graph_projection::{ CODE_GRAPH_PROJECTOR_REVISION, CodeGraphProjectionError, CodeGraphProjectionStore, - build_published_code_graph_manifest_checked, code_graph_generation_id, - code_graph_projection_identity, + code_graph_generation_id, code_graph_projection_identity, }, parallelism, production::{ @@ -100,17 +99,12 @@ fn projection_manifest( generation: &CodeIndexPublishedGenerationV1, revision: &GraphProjectorRevision, ) -> GraphGenerationManifest { - Arc::unwrap_or_clone( - build_published_code_graph_manifest_checked( - code_graph_projection_identity( - GraphNamespace::new("code-graph-import-publication").expect("graph namespace"), - ) - .expect("projection identity"), - generation, - revision, - &|| Ok(()), + PartitionedSealV1::of(generation).graph_manifest( + code_graph_projection_identity( + GraphNamespace::new("code-graph-import-publication").expect("graph namespace"), ) - .expect("published generation projects"), + .expect("projection identity"), + revision, ) } diff --git a/crates/tracedecay-code-index/tests/code_index_suite/production_orchestration.rs b/crates/tracedecay-code-index/tests/code_index_suite/production_orchestration.rs index 46e8b38193..ad45d86e6f 100644 --- a/crates/tracedecay-code-index/tests/code_index_suite/production_orchestration.rs +++ b/crates/tracedecay-code-index/tests/code_index_suite/production_orchestration.rs @@ -14,8 +14,8 @@ use tracedecay_code_index::{ chunks::{CodeIndexImportEvidenceV1, ExtractionAdmittedCodeSearchChunkV1, content_digest}, clones::{CloneBodyEligibilityV1, CloneBodyOccurrenceV1}, graph_projection::{ - CODE_GRAPH_PROJECTOR_REVISION, CodeGraphProjectionError, - build_published_code_graph_manifest_checked, code_graph_projection_identity, + CODE_GRAPH_PROJECTOR_REVISION, CodeGraphProjectionError, SealedCodeGraphRowsError, + code_graph_projection_identity, }, production::{ CodeIndexAtomicPublicationPort, CodeIndexBuildRequestV1, CodeIndexCapturedFileV1, @@ -1401,13 +1401,8 @@ fn published_graph_manifest_projects_files_chunks_symbols_and_replays_byte_ident let projection = code_graph_projection_identity(GraphNamespace::new("code-graph-test").expect("namespace")) .expect("projection identity"); - let manifest = build_published_code_graph_manifest_checked( - projection.clone(), - &generation, - &projector_revision, - &|| Ok(()), - ) - .expect("published generation projects"); + let sealed = PartitionedSealV1::of(&generation); + let manifest = sealed.graph_manifest(projection.clone(), &projector_revision); let label_count = |label: &str| { manifest @@ -1446,14 +1441,11 @@ fn published_graph_manifest_projects_files_chunks_symbols_and_replays_byte_ident "the graph must not scale with the chunk count" ); - let restored = PartitionedSealV1::of(&generation).restored(); - let replayed = build_published_code_graph_manifest_checked( - projection, - &restored, - &projector_revision, - &|| Ok(()), - ) - .expect("restored generation projects"); + // The restored generation reseals to the same segments, so its graph is + // the same rows and the same recovered digest. + let replayed = + PartitionedSealV1::of(&sealed.restored()).graph_manifest(projection, &projector_revision); + assert_eq!(manifest, replayed); assert_eq!( manifest .expected_recovered_digest(&|| Ok(())) @@ -1464,179 +1456,68 @@ fn published_graph_manifest_projects_files_chunks_symbols_and_replays_byte_ident ); } -/// The graph publication manifest is a pure function of the immutable -/// generation, so seat retries and the seat/reconcile duplicate publication of -/// one sealed generation must not re-examine every chunk, symbol, and edge. -/// The memo is fail-closed: a deadline mid-build records nothing, a memo hit -/// still refuses an expired request, and a foreign projection identity or -/// projector revision rebuilds in full instead of aliasing the cached -/// manifest. +/// A graph build interrupted mid-stream answers the typed interruption and +/// publishes nothing, and the same seal then builds in full: the build reads +/// its segments item by item, so an expired request stops it between reads. #[test] -fn repeated_graph_manifest_builds_reuse_the_memo_without_reexamining_the_generation() { +fn an_interrupted_graph_build_fails_typed_and_the_next_build_completes() { let store = SharedPublicationStore::default(); let mut owner = CodeIndexProductionOwnerV1::new(config(), store, ApplyingProjectionSink) .expect("production owner"); let generation = owner - .build_and_publish(request("file.graph-memo", 1_260_000), &ActiveControl) + .build_and_publish(request("file.graph-interrupt", 1_260_000), &ActiveControl) .expect("generation publishes"); let projector_revision = GraphProjectorRevision::try_from(CODE_GRAPH_PROJECTOR_REVISION.to_owned()) .expect("projector revision"); - let projection = - code_graph_projection_identity(GraphNamespace::new("code-graph-memo").expect("namespace")) - .expect("projection identity"); + let projection = code_graph_projection_identity( + GraphNamespace::new("code-graph-interrupt").expect("namespace"), + ) + .expect("projection identity"); + let sealed = PartitionedSealV1::of(&generation); - // A deadline mid-build is a failed generation build that memoizes nothing. - let interrupted_checks = Cell::new(0usize); - let interrupted = build_published_code_graph_manifest_checked( - projection.clone(), - &generation, - &projector_revision, - &|| { - interrupted_checks.set(interrupted_checks.get() + 1); - if interrupted_checks.get() > 3 { + let checks = Cell::new(0usize); + let interrupted = sealed + .graph_manifest_checked(projection.clone(), &projector_revision, &|| { + checks.set(checks.get() + 1); + if checks.get() > 3 { Err(GraphDbError::DeadlineExceeded) } else { Ok(()) } - }, - ) - .expect_err("a deadline mid-build fails the build"); - assert_eq!(interrupted, CodeGraphProjectionError::DeadlineExceeded); - - let first_checks = Cell::new(0usize); - let first = build_published_code_graph_manifest_checked( - projection.clone(), - &generation, - &projector_revision, - &|| { - first_checks.set(first_checks.get() + 1); - Ok(()) - }, - ) - .expect("first complete build"); - let second_checks = Cell::new(0usize); - let second = build_published_code_graph_manifest_checked( - projection.clone(), - &generation, - &projector_revision, - &|| { - second_checks.set(second_checks.get() + 1); - Ok(()) - }, - ) - .expect("memoized build"); - let first_weak = Arc::downgrade(&first); - assert!( - Arc::ptr_eq(&first, &second), - "a live memo hit must return the exact manifest allocation" - ); - assert_eq!(first, second, "the memo returns the identical manifest"); - assert!( - !first.entities.is_empty(), - "fixture must publish graph entities" - ); - assert!( - !first.relations.is_empty(), - "fixture must publish graph relations" - ); - assert_eq!( - first.entities.as_ptr(), - second.entities.as_ptr(), - "a memo hit must share the immutable entity buffer instead of deep-cloning it" - ); - assert_eq!( - first.relations.as_ptr(), - second.relations.as_ptr(), - "a memo hit must share the immutable relation buffer instead of deep-cloning it" - ); - assert!( - first_checks.get() > 3, - "the interrupted build must not have been memoized (first build saw {} checks)", - first_checks.get() - ); - assert!( - first_checks.get() > first.entities.len() / 4, - "a fresh build examines the generation item by item ({} checks over {} entities)", - first_checks.get(), - first.entities.len() - ); - assert_eq!( - second_checks.get(), - 1, - "a memo hit performs the admission check only, with no per-item examination" - ); - - // A memo hit still refuses an already-expired request. - let refused = build_published_code_graph_manifest_checked( - projection.clone(), - &generation, - &projector_revision, - &|| Err(GraphDbError::DeadlineExceeded), - ) - .expect_err("an expired request is refused before the memo serves"); - assert_eq!(refused, CodeGraphProjectionError::DeadlineExceeded); - - drop(first); - drop(second); + }) + .expect_err("a deadline mid-build fails the build"); assert!( - first_weak.upgrade().is_none(), - "the generation must not pin a graph manifest after its callers release it" + matches!( + interrupted, + SealedCodeGraphRowsError::Projection(CodeGraphProjectionError::DeadlineExceeded) + ), + "unexpected interruption: {interrupted:?}" ); - let rebuilt_checks = Cell::new(0usize); - let rebuilt_same_key = build_published_code_graph_manifest_checked( - projection.clone(), - &generation, - &projector_revision, - &|| { - rebuilt_checks.set(rebuilt_checks.get() + 1); - Ok(()) - }, - ) - .expect("an expired same-key memo rebuilds"); - assert!( - rebuilt_checks.get() > 3, - "an expired weak memo must rebuild the manifest" - ); - let refreshed_checks = Cell::new(0usize); - let refreshed = build_published_code_graph_manifest_checked( - projection.clone(), - &generation, - &projector_revision, - &|| { - refreshed_checks.set(refreshed_checks.get() + 1); + let complete_checks = Cell::new(0usize); + let complete = sealed + .graph_manifest_checked(projection, &projector_revision, &|| { + complete_checks.set(complete_checks.get() + 1); Ok(()) - }, - ) - .expect("the rebuilt manifest refreshes the memo"); - assert!(Arc::ptr_eq(&rebuilt_same_key, &refreshed)); + }) + .expect("an uninterrupted build completes"); assert_eq!( - refreshed_checks.get(), - 1, - "a live refreshed memo performs only the admission check" + complete + .entities + .iter() + .filter(|entity| entity + .labels + .iter() + .any(|label| label.as_str() == "CodeFile")) + .count(), + generation.snapshot().files.len() ); - - // A foreign projection identity is a memo miss that rebuilds in full. - let foreign = code_graph_projection_identity( - GraphNamespace::new("code-graph-memo-other").expect("namespace"), - ) - .expect("projection identity"); - let foreign_checks = Cell::new(0usize); - let rebuilt = build_published_code_graph_manifest_checked( - foreign.clone(), - &generation, - &projector_revision, - &|| { - foreign_checks.set(foreign_checks.get() + 1); - Ok(()) - }, - ) - .expect("foreign projection rebuilds"); - assert_eq!(rebuilt.projection, foreign); assert!( - foreign_checks.get() > 1, - "a foreign projection identity cannot serve the cached manifest" + complete_checks.get() > complete.entities.len(), + "a build checks for interruption per row ({} checks over {} entities)", + complete_checks.get(), + complete.entities.len() ); } diff --git a/crates/tracedecay-code-index/tests/code_index_suite/support.rs b/crates/tracedecay-code-index/tests/code_index_suite/support.rs index 432aebc4c3..811fa3604b 100644 --- a/crates/tracedecay-code-index/tests/code_index_suite/support.rs +++ b/crates/tracedecay-code-index/tests/code_index_suite/support.rs @@ -9,10 +9,13 @@ use flate2::write::DeflateEncoder; use serde_json::Value; use sha2::{Digest, Sha256}; use tracedecay_code_index::chunks::content_digest; +use tracedecay_code_index::graph_projection::{ + SealedCodeGraphRowsError, build_sealed_code_graph_rows, +}; use tracedecay_code_index::intake::{CodeIndexIntake, ReceiptBoundCodeFileV1, SanitizedCodeIntake}; use tracedecay_code_index::languages::{LanguageRegistry, StaticLanguageRegistry}; use tracedecay_code_index::production::{ - CodeIndexProductionErrorV1, CodeIndexPublishedGenerationV1, + CodeIndexProductionErrorV1, CodeIndexPublishedGenerationV1, SealedGenerationFileWindowsV1, SealedGenerationSegmentPublicationV1, SealedGenerationSegmentReadV1, VerifiedSealedLexicalPageSourceV1, }; @@ -21,6 +24,10 @@ use tracedecay_domain::{ ProjectId, RepositoryId, SanitizationReceiptId, SanitizedCodeFileV1, SanitizedCodeSnapshotV1, SanitizerRevision, SnapshotFileDispositionV1, UtcMicros, ValidatedCodeFileV1, }; +use tracedecay_graph_db::{ + GraphDbError, GraphGenerationManifest, GraphGenerationRowSpill, GraphProjectionIdentity, + GraphProjectorRevision, +}; pub const RUST_SOURCE: &str = "//! Module documentation.\n\nuse std::collections::HashMap;\n\n/// Increment a value.\npub fn alpha(value: u32) -> u32 {\n value + 1\n}\n\npub struct Holder {\n map: HashMap,\n}\n\nimpl Holder {\n pub fn get(&self, key: u32) -> Option {\n self.map.get(&key).copied()\n }\n}\n\n// trailing window text\n"; @@ -124,28 +131,67 @@ impl PartitionedSealV1 { manifest: &[u8], ) -> Result { CodeIndexPublishedGenerationV1::decode_partitioned_sealed(manifest, |request, buffer| { - let (digest, offset, length) = match request { - SealedGenerationSegmentReadV1::Whole { digest, size_bytes } => { - (digest, 0, size_bytes) - } - SealedGenerationSegmentReadV1::Range { - digest, - offset, - length, - .. - } => (digest, offset, length), - }; - let bytes = self.segments.get(digest.as_str()).ok_or_else(|| { - CodeIndexProductionErrorV1::Contract("fixture segment is missing".to_owned()) - })?; - let start = usize::try_from(offset).expect("segment offset fits usize"); - let end = start + usize::try_from(length).expect("segment length fits usize"); - buffer.clear(); - buffer.extend_from_slice(&bytes[start..end]); - Ok(()) + self.read_segment(request, buffer) }) } + fn read_segment( + &self, + request: SealedGenerationSegmentReadV1<'_>, + buffer: &mut Vec, + ) -> Result<(), CodeIndexProductionErrorV1> { + let (digest, offset, length) = match request { + SealedGenerationSegmentReadV1::Whole { digest, size_bytes } => (digest, 0, size_bytes), + SealedGenerationSegmentReadV1::Range { + digest, + offset, + length, + .. + } => (digest, offset, length), + }; + let bytes = self.segments.get(digest.as_str()).ok_or_else(|| { + CodeIndexProductionErrorV1::Contract("fixture segment is missing".to_owned()) + })?; + let start = usize::try_from(offset).expect("segment offset fits usize"); + let end = start + usize::try_from(length).expect("segment length fits usize"); + buffer.clear(); + buffer.extend_from_slice(&bytes[start..end]); + Ok(()) + } + + /// The code graph this seal projects, built the way publication builds + /// it, from the segments one window of files at a time, then read back + /// as one manifest. + pub fn graph_manifest( + &self, + projection: GraphProjectionIdentity, + revision: &GraphProjectorRevision, + ) -> GraphGenerationManifest { + self.graph_manifest_checked(projection, revision, &|| Ok(())) + .expect("sealed generation projects") + } + + pub fn graph_manifest_checked( + &self, + projection: GraphProjectionIdentity, + revision: &GraphProjectorRevision, + check: &dyn Fn() -> Result<(), GraphDbError>, + ) -> Result { + let scratch = tempfile::tempdir().expect("graph row scratch directory"); + let spill = + GraphGenerationRowSpill::create(scratch.path().join("rows"), projection.clone())?; + let source = SealedGenerationFileWindowsV1::open(&self.manifest)?; + let spilled = build_sealed_code_graph_rows( + projection, + &source, + &mut |request, buffer| self.read_segment(request, buffer), + revision, + spill, + check, + )?; + Ok(spilled.materialize(&|| Ok(()))?) + } + pub fn restored(&self) -> CodeIndexPublishedGenerationV1 { self.restore(&self.manifest) .expect("partitioned generation restores") diff --git a/crates/tracedecay-code-index/tests/code_index_suite/typescript_module_resolution.rs b/crates/tracedecay-code-index/tests/code_index_suite/typescript_module_resolution.rs index d2563bb0d1..215bb7de22 100644 --- a/crates/tracedecay-code-index/tests/code_index_suite/typescript_module_resolution.rs +++ b/crates/tracedecay-code-index/tests/code_index_suite/typescript_module_resolution.rs @@ -11,7 +11,7 @@ use tracedecay_code_index::{ chunks::content_digest, graph_projection::{ CODE_GRAPH_PROJECTOR_REVISION, CodeGraphInteractiveReader, CodeGraphProjectionStore, - build_published_code_graph_manifest_checked, code_graph_projection_identity, + code_graph_projection_identity, }, production::{ CodeIndexCapturedFileV1, CodeIndexProductionOwnerV1, CodeIndexPublishedGenerationV1, @@ -177,20 +177,16 @@ fn callers_with_authority( } fn reader(generation: &CodeIndexPublishedGenerationV1) -> CodeGraphInteractiveReader { - let manifest = build_published_code_graph_manifest_checked( + let manifest = PartitionedSealV1::of(generation).graph_manifest( code_graph_projection_identity( GraphNamespace::new("code-graph-ts-monorepo").expect("graph namespace"), ) .expect("projection identity"), - generation, &GraphProjectorRevision::try_from(CODE_GRAPH_PROJECTOR_REVISION.to_owned()) .expect("projector revision"), - &|| Ok(()), - ) - .expect("published generation projects"); - let snapshot = - VerifiedGraphSnapshot::memory(Arc::unwrap_or_clone(manifest), Arc::new(NeverCancelled)) - .expect("verified graph snapshot"); + ); + let snapshot = VerifiedGraphSnapshot::memory(manifest, Arc::new(NeverCancelled)) + .expect("verified graph snapshot"); CodeGraphProjectionStore::from_verified_snapshot( snapshot, generation.manifest().generation_id.clone(), diff --git a/crates/tracedecay-code-index/tests/resident_accounting.rs b/crates/tracedecay-code-index/tests/resident_accounting.rs index 06849ab944..e7dd298fa3 100644 --- a/crates/tracedecay-code-index/tests/resident_accounting.rs +++ b/crates/tracedecay-code-index/tests/resident_accounting.rs @@ -1,21 +1,26 @@ //! `CodeIndexPublishedGenerationV1::retained_bytes` is what admission charges //! for decoding a sealed generation and what the resident-memory inventory //! reports for the decode it holds. This binary counts every allocation, so -//! the bytes a real decode leaves live are the reference it is held to. +//! the bytes a real decode leaves live are the reference it is held to, and +//! the bytes a sealed generation's graph build holds are bounded against it. use std::alloc::{GlobalAlloc, Layout, System}; use std::collections::{BTreeMap, BTreeSet}; -use std::sync::Arc; use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex, PoisonError}; use sha2::{Digest, Sha256}; use tracedecay_code_index::chunks::content_digest; +use tracedecay_code_index::graph_projection::{ + CODE_GRAPH_PROJECTOR_REVISION, build_sealed_code_graph_rows, code_graph_projection_identity, +}; use tracedecay_code_index::production::{ CodeIndexAtomicPublicationPort, CodeIndexBuildRequestV1, CodeIndexCapturedFileV1, CodeIndexExecutionControlV1, CodeIndexGenerationScopeV1, CodeIndexProductionConfigV1, CodeIndexProductionErrorV1, CodeIndexProductionOwnerV1, CodeIndexPublicationStoreErrorV1, CodeIndexPublishedGenerationV1, CodeIndexRepositoryParseIdentityV1, - SealedGenerationSegmentPublicationV1, SealedGenerationSegmentReadV1, + SealedGenerationFileWindowsV1, SealedGenerationSegmentPublicationV1, + SealedGenerationSegmentReadV1, }; use tracedecay_code_index::projection::{ ChunkProjectionDecisionV1, CodeChunkProjectionSink, ProjectionReceiptBuilderV1, @@ -29,6 +34,7 @@ use tracedecay_domain::{ RepositoryId, SanitizationReceiptId, SanitizedCodeFileV1, SanitizedCodeSnapshotV1, SanitizerRevision, SensitivityLevelV1, SnapshotFileDispositionV1, UtcMicros, }; +use tracedecay_graph_db::{GraphGenerationRowSpill, GraphNamespace, GraphProjectorRevision}; struct CountingAllocator; @@ -62,6 +68,9 @@ unsafe impl GlobalAlloc for CountingAllocator { #[global_allocator] static ALLOCATOR: CountingAllocator = CountingAllocator; +/// The counters are process-wide, so measurements run one at a time. +static MEASUREMENT: Mutex<()> = Mutex::new(()); + #[derive(Default)] struct Publication; @@ -234,31 +243,90 @@ fn seal(generation: &CodeIndexPublishedGenerationV1) -> (Vec, BTreeMap>, + request: SealedGenerationSegmentReadV1<'_>, + buffer: &mut Vec, +) -> Result<(), CodeIndexProductionErrorV1> { + let (digest, offset, length) = match request { + SealedGenerationSegmentReadV1::Whole { digest, size_bytes } => (digest, 0, size_bytes), + SealedGenerationSegmentReadV1::Range { + digest, + offset, + length, + .. + } => (digest, offset, length), + }; + let bytes = segments + .get(digest.as_str()) + .ok_or_else(|| CodeIndexProductionErrorV1::Contract("segment missing".to_owned()))?; + let start = usize::try_from(offset).expect("offset"); + let end = start + usize::try_from(length).expect("length"); + buffer.clear(); + buffer.extend_from_slice(&bytes[start..end]); + Ok(()) +} + fn decode(manifest: &[u8], segments: &BTreeMap>) -> CodeIndexPublishedGenerationV1 { CodeIndexPublishedGenerationV1::decode_partitioned_sealed(manifest, |request, buffer| { - let (digest, offset, length) = match request { - SealedGenerationSegmentReadV1::Whole { digest, size_bytes } => (digest, 0, size_bytes), - SealedGenerationSegmentReadV1::Range { - digest, - offset, - length, - .. - } => (digest, offset, length), - }; - let bytes = segments - .get(digest.as_str()) - .ok_or_else(|| CodeIndexProductionErrorV1::Contract("segment missing".to_owned()))?; - let start = usize::try_from(offset).expect("offset"); - let end = start + usize::try_from(length).expect("length"); - buffer.clear(); - buffer.extend_from_slice(&bytes[start..end]); - Ok(()) + read_segment(segments, request, buffer) }) .expect("decode") } +/// Publishing a sealed generation's code graph reads its segments one window +/// at a time: the most the build ever holds above the sealed input stays +/// within a fixed budget, below the 22.3 MB decoding this generation alone +/// leaves live. Decoding the whole generation and projecting it in one piece +/// peaked at 38,827,295 bytes for the same 4,201 entities and 5,100 relations. +#[test] +fn a_sealed_graph_build_holds_windows_not_the_decoded_generation() { + const PEAK_BUDGET_BYTES: usize = 19_000_000; + let _measurement = MEASUREMENT.lock().unwrap_or_else(PoisonError::into_inner); + let built = CodeIndexProductionOwnerV1::new(config(), Publication, Projection) + .expect("owner") + .build_and_publish(request(300), &Active) + .expect("build"); + let (manifest, segments) = seal(&built); + drop(built); + let scratch = tempfile::tempdir().expect("scratch"); + let projection = + code_graph_projection_identity(GraphNamespace::new("code-graph-resident").expect("ns")) + .expect("projection"); + let revision = + GraphProjectorRevision::try_from(CODE_GRAPH_PROJECTOR_REVISION.to_owned()).expect("rev"); + let spill = GraphGenerationRowSpill::create(scratch.path().join("rows"), projection.clone()) + .expect("spill"); + + // One worker reads four files per window, so the 300-file fixture spans + // 75 windows the way a production corpus spans its windows. + tracedecay_code_index::parallelism::force_indexing_workers_for_test(1); + let before = LIVE.load(Ordering::Relaxed); + PEAK.store(before, Ordering::Relaxed); + let source = SealedGenerationFileWindowsV1::open(&manifest).expect("sealed manifest"); + let spilled = build_sealed_code_graph_rows( + projection, + &source, + &mut |request, buffer| read_segment(&segments, request, buffer), + &revision, + spill, + &|| Ok(()), + ) + .expect("sealed graph builds"); + let peak = PEAK.load(Ordering::Relaxed) - before; + tracedecay_code_index::parallelism::clear_forced_indexing_workers_for_test(); + eprintln!("GRAPH ROWS peak {peak}"); + + assert_eq!(spilled.row_counts(), (4_201, 5_100)); + assert!( + peak <= PEAK_BUDGET_BYTES, + "the graph build held {peak} bytes at peak, over its {PEAK_BUDGET_BYTES}-byte budget" + ); +} + #[test] fn retained_bytes_account_for_what_a_decode_leaves_live() { + let _measurement = MEASUREMENT.lock().unwrap_or_else(PoisonError::into_inner); let built = CodeIndexProductionOwnerV1::new(config(), Publication, Projection) .expect("owner") .build_and_publish(request(300), &Active) diff --git a/crates/tracedecay-graph-db/src/generation.rs b/crates/tracedecay-graph-db/src/generation.rs index a131c5a37a..7dbcc25454 100644 --- a/crates/tracedecay-graph-db/src/generation.rs +++ b/crates/tracedecay-graph-db/src/generation.rs @@ -15,9 +15,8 @@ use tracedecay_domain::canonical_text::{ use tracedecay_store::runtime::{ GraphDependencyGenerationClosureDigestV1, GraphDependencyGenerationIdentityV1, GraphGenerationIdV1, GraphNamespaceV1, GraphProjectionIdV1, GraphProjectionIdentityV1, - GraphPublicationIdempotencyKeyV1, GraphPublicationInputDigestV1, GraphPublicationKeyV1, - GraphPublicationReplayV1, GraphRecoveredGenerationDigestV1, GraphVerifiedHeadV1, - MAX_GRAPH_REPLAY_SOURCE_BYTES_V1, StoreShardIdV1, + GraphPublicationInputDigestV1, GraphPublicationReplayV1, GraphRecoveredGenerationDigestV1, + GraphVerifiedHeadV1, MAX_GRAPH_REPLAY_SOURCE_BYTES_V1, StoreShardIdV1, }; use crate::limits::{MAX_VERIFIED_GENERATION_ENTITIES, MAX_VERIFIED_GENERATION_RELATIONS}; @@ -42,6 +41,8 @@ mod identity; mod recovered; #[path = "generation/replay.rs"] mod replay; +#[path = "generation/spill.rs"] +mod spill; pub use identity::{ GraphEntityRef, GraphGenerationDependency, GraphProjectionIdentity, GraphRelationRef, }; @@ -55,8 +56,9 @@ pub use replay::{ GraphProjectorRevision, SealedCodeGenerationReplay, SealedGraphStateDigest, }; pub(crate) use replay::{ - checked_decode_replay_source, metadata_manifest_from_source, validate_supplied_manifest_binding, + checked_decode_replay_source, metadata_manifest_from_source, validate_supplied_rows_binding, }; +pub use spill::{GraphGenerationRowSpill, GraphGenerationRows, SpilledGraphGeneration}; #[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] #[serde(deny_unknown_fields)] @@ -421,34 +423,52 @@ impl GraphGenerationManifest { Ok(manifest) } - #[hotpath::measure( - label = "graph_db.generation.replay.hydrate", - impl_type = "GraphGenerationManifest" - )] - pub fn from_replay( + /// The manifest an inline replay journals, bound to its relational key. + pub fn from_inline_replay( publication: &GraphPublicationReplayV1, - provider: &dyn GraphGenerationManifestProvider, check: &dyn Fn() -> Result<(), GraphDbError>, ) -> Result { check()?; let source = checked_decode_replay_source(&publication.canonical_replay_source, check)?; - Self::from_replay_source(publication, source, provider, check) + let rows = GraphGenerationRows::from_replay_source( + publication, + source, + &InlineOnlyGraphGenerationManifestProvider, + || { + Err(GraphDbError::unavailable( + "an inline replay hydrates without a row spill", + )) + }, + check, + )?; + rows.into_manifest(check).map(Arc::unwrap_or_clone) } +} - /// [`Self::from_replay`] over an already-decoded `source`, for callers - /// that inspect the source first and must not decode the payload twice. +impl GraphGenerationRows { + /// Hydrates the rows a journaled replay names and binds them to its + /// relational key and digests. A sealed code generation is rebuilt by + /// `provider` into the spill `spill` creates. + #[hotpath::measure( + label = "graph_db.generation.replay.hydrate", + impl_type = "GraphGenerationRows" + )] pub(crate) fn from_replay_source( publication: &GraphPublicationReplayV1, source: GraphGenerationReplaySource, provider: &dyn GraphGenerationManifestProvider, + spill: impl FnOnce() -> Result, check: &dyn Fn() -> Result<(), GraphDbError>, ) -> Result { check()?; publication .validate() .map_err(|error| GraphDbError::invalid(error.to_string()))?; - let manifest = match source { - GraphGenerationReplaySource::InlineManifest(manifest) => *manifest, + let rows: Self = match source { + GraphGenerationReplaySource::InlineManifest(manifest) => { + manifest.validate_checked(check)?; + Self::Manifest(Arc::new(*manifest)) + } GraphGenerationReplaySource::MetadataOnlyManifest(_) => { return Err(GraphDbError::unavailable( "metadata-only replay requires verified native generation rows", @@ -456,59 +476,49 @@ impl GraphGenerationManifest { } GraphGenerationReplaySource::SealedCodeGeneration(source) => { validate_sealed_replay(&source)?; - provider.hydrate_sealed_code_generation( - &publication.key.projection, - &source, - check, - )? + provider + .hydrate_sealed_code_generation( + &publication.key.projection, + &source, + spill()?, + check, + )? + .into() } }; - manifest.validate_checked(check)?; + let identity = rows.identity(); let projection = &publication.key.projection; - if projection.namespace.as_str() != manifest.projection.namespace.as_str() - || projection.projection.as_str() != manifest.projection.projection.as_str() - || publication.key.generation.as_str() != manifest.generation.as_str() + if projection.namespace.as_str() != identity.projection.namespace.as_str() + || projection.projection.as_str() != identity.projection.projection.as_str() + || publication.key.generation.as_str() != identity.generation.as_str() { return Err(GraphDbError::invalid( "canonical graph replay identity does not match its relational key", )); } if publication.direct_dependency_generations - != manifest.relational_dependency_generations(&projection.shard_id)? + != relational_dependency_generations(&identity.dependencies, &projection.shard_id)? { return Err(GraphDbError::conflict("generation.from_replay")); } if publication.dependency_generation_closure_digest.as_str() - != manifest.dependency_closure_digest(check)?.as_str() + != rows.dependency_closure_digest(check)?.as_str() || publication.expected_recovered_digest.as_str() - != manifest.expected_recovered_digest(check)?.as_str() + != rows.expected_recovered_digest(check)?.as_str() { return Err(GraphDbError::conflict("generation.from_replay")); } check()?; - crate::hotpath_observe::record_counts( - manifest.entities.len(), - manifest.relations.len(), - 1, - 0, - ); + let (entities, relations) = rows.row_counts(); + crate::hotpath_observe::record_counts(entities, relations, 1, 0); crate::hotpath_observe::record_hydration_source( crate::hotpath_observe::HydrationSource::Replay, ); - Ok(manifest) - } - - pub fn from_inline_replay( - publication: &GraphPublicationReplayV1, - check: &dyn Fn() -> Result<(), GraphDbError>, - ) -> Result { - Self::from_replay( - publication, - &InlineOnlyGraphGenerationManifestProvider, - check, - ) + Ok(rows) } +} +impl GraphGenerationManifest { pub fn canonical_replay_source( &self, check: &dyn Fn() -> Result<(), GraphDbError>, @@ -677,38 +687,18 @@ impl GraphGenerationManifest { check: &dyn Fn() -> Result<(), GraphDbError>, ) -> Result { check()?; - let projection = GraphProjectionIdentityV1 { + // Proved on this instance first so the identity inherits the memo. + self.dependency_closure_digest(check)?; + let expected_recovered_digest = self.expected_recovered_digest(check)?; + self.identity().relational_replay_with_payload( shard_id, - namespace: GraphNamespaceV1::new(self.projection.namespace.as_str()) - .map_err(|error| GraphDbError::invalid(error.to_string()))?, - projection: GraphProjectionIdV1::new(self.projection.projection.as_str()) - .map_err(|error| GraphDbError::invalid(error.to_string()))?, - }; - let direct_dependencies = self.relational_dependency_generations(&projection.shard_id)?; - let key = GraphPublicationKeyV1::new( - projection, - GraphGenerationIdV1::new(self.generation.as_str()) - .map_err(|error| GraphDbError::invalid(error.to_string()))?, - GraphPublicationIdempotencyKeyV1::new(idempotency_key.as_str()) - .map_err(|error| GraphDbError::invalid(error.to_string()))?, - ); - GraphPublicationReplayV1::new( - key, + idempotency_key, input_digest, - self.dependency_closure_digest(check)?, - direct_dependencies, expected_prior_head, - self.expected_recovered_digest(check)?, + expected_recovered_digest, payload, + check, ) - .map_err(|error| GraphDbError::invalid(error.to_string())) - } - - fn relational_dependency_generations( - &self, - shard_id: &StoreShardIdV1, - ) -> Result, GraphDbError> { - relational_dependency_generations(&self.dependencies, shard_id) } pub(crate) fn validate_checked( diff --git a/crates/tracedecay-graph-db/src/generation/replay.rs b/crates/tracedecay-graph-db/src/generation/replay.rs index 4e4d5ba029..ba854aaec0 100644 --- a/crates/tracedecay-graph-db/src/generation/replay.rs +++ b/crates/tracedecay-graph-db/src/generation/replay.rs @@ -4,7 +4,10 @@ use tracedecay_store::runtime::{ GraphPublicationInputDigestV1, GraphPublicationReplayV1, GraphVerifiedHeadV1, StoreShardIdV1, }; -use super::{GraphDbError, GraphGenerationManifest, GraphIdempotencyKey}; +use super::{ + GraphDbError, GraphGenerationManifest, GraphGenerationRowSpill, GraphGenerationRows, + GraphIdempotencyKey, SpilledGraphGeneration, +}; #[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] #[serde(deny_unknown_fields)] @@ -130,9 +133,10 @@ fn validate_decoded_metadata_binding( validate_publication_manifest_identity(publication, manifest, validate_expected_digest, check) } -pub(crate) fn validate_supplied_manifest_binding( +/// Pins supplied rows to the journaled replay they claim to publish. +pub(crate) fn validate_supplied_rows_binding( publication: &GraphPublicationReplayV1, - manifest: &GraphGenerationManifest, + rows: &GraphGenerationRows, validate_expected_digest: bool, check: &dyn Fn() -> Result<(), GraphDbError>, ) -> Result<(), GraphDbError> { @@ -143,9 +147,36 @@ pub(crate) fn validate_supplied_manifest_binding( message: format!("graph publication replay is invalid: {error}"), }) })?; + let manifest = match rows { + GraphGenerationRows::Manifest(manifest) => manifest, + // Spilled rows only ever publish a sealed code generation, whose + // journal names its replay source rather than a manifest; the + // journaled digests were derived from these exact rows at append. + GraphGenerationRows::Spilled(spilled) => { + return match checked_decode_replay_source(&publication.canonical_replay_source, check)? + { + GraphGenerationReplaySource::SealedCodeGeneration(source) => { + validate_sealed_replay(&source)?; + validate_publication_identity( + publication, + &spilled.identity(), + validate_expected_digest + .then(|| spilled.expected_recovered_digest().clone()), + check, + ) + } + GraphGenerationReplaySource::InlineManifest(_) + | GraphGenerationReplaySource::MetadataOnlyManifest(_) => Err( + GraphDbError::conflict("replay.validate_supplied_manifest_binding"), + ), + }; + } + }; manifest.validate_checked(check)?; match checked_decode_replay_source(&publication.canonical_replay_source, check)? { - GraphGenerationReplaySource::InlineManifest(replayed) if replayed.as_ref() == manifest => { + GraphGenerationReplaySource::InlineManifest(replayed) + if replayed.as_ref() == manifest.as_ref() => + { validate_publication_manifest_identity( publication, manifest, @@ -190,20 +221,37 @@ fn validate_publication_manifest_identity( manifest: &GraphGenerationManifest, validate_expected_digest: bool, check: &dyn Fn() -> Result<(), GraphDbError>, +) -> Result<(), GraphDbError> { + let expected_digest = if validate_expected_digest { + Some(manifest.expected_recovered_digest(check)?) + } else { + None + }; + // Proved on the manifest first so the identity inherits the memo. + manifest.dependency_closure_digest(check)?; + validate_publication_identity(publication, &manifest.identity(), expected_digest, check) +} + +fn validate_publication_identity( + publication: &GraphPublicationReplayV1, + identity: &super::GraphGenerationManifestIdentity, + expected_digest: Option, + check: &dyn Fn() -> Result<(), GraphDbError>, ) -> Result<(), GraphDbError> { hotpath::measure_block!("graph_db.generation.replay.binding.identity", { - let direct_dependencies = - manifest.relational_dependency_generations(&publication.key.projection.shard_id)?; - if publication.key.projection.namespace.as_str() != manifest.projection.namespace.as_str() + let direct_dependencies = super::relational_dependency_generations( + &identity.dependencies, + &publication.key.projection.shard_id, + )?; + if publication.key.projection.namespace.as_str() != identity.projection.namespace.as_str() || publication.key.projection.projection.as_str() - != manifest.projection.projection.as_str() - || publication.key.generation.as_str() != manifest.generation.as_str() + != identity.projection.projection.as_str() + || publication.key.generation.as_str() != identity.generation.as_str() || publication.direct_dependency_generations != direct_dependencies || publication.dependency_generation_closure_digest - != manifest.dependency_closure_digest(check)? - || (validate_expected_digest - && publication.expected_recovered_digest - != manifest.expected_recovered_digest(check)?) + != identity.dependency_closure_digest(check)? + || expected_digest + .is_some_and(|expected| publication.expected_recovered_digest != expected) { return Err(GraphDbError::conflict( "replay.validate_publication_manifest_identity", @@ -288,13 +336,19 @@ fn validate_sha256(value: &str, subject: &str) -> Result<(), GraphDbError> { Ok(()) } +/// Rebuilds a sealed code generation's graph rows from its durable source. +/// +/// The registry hands the provider a spill under the store's own scratch +/// root; the provider pushes the rows in batches and finishes it, so a +/// replay never holds the whole generation in memory. pub trait GraphGenerationManifestProvider: Send + Sync { fn hydrate_sealed_code_generation( &self, owner: &tracedecay_store::GraphProjectionIdentityV1, source: &SealedCodeGenerationReplay, + spill: GraphGenerationRowSpill, check: &dyn Fn() -> Result<(), GraphDbError>, - ) -> Result; + ) -> Result; } pub(crate) struct InlineOnlyGraphGenerationManifestProvider; @@ -304,8 +358,9 @@ impl GraphGenerationManifestProvider for InlineOnlyGraphGenerationManifestProvid &self, _owner: &tracedecay_store::GraphProjectionIdentityV1, _source: &SealedCodeGenerationReplay, + _spill: GraphGenerationRowSpill, _check: &dyn Fn() -> Result<(), GraphDbError>, - ) -> Result { + ) -> Result { Err(GraphDbError::unavailable( "sealed code generation replay provider is not mounted", )) diff --git a/crates/tracedecay-graph-db/src/generation/spill.rs b/crates/tracedecay-graph-db/src/generation/spill.rs new file mode 100644 index 0000000000..3a86ae6501 --- /dev/null +++ b/crates/tracedecay-graph-db/src/generation/spill.rs @@ -0,0 +1,769 @@ +//! Generation rows produced in batches and published without holding the +//! whole row set in memory. +//! +//! A producer pushes entities and relations in any order, one bounded batch +//! at a time. Each batch is canonicalized, sorted by identity, and written as +//! a run under a private spill directory; only the entity identities stay +//! resident, because every relation endpoint resolves to its entity's position +//! in the global identity order. [`GraphGenerationRowSpill::finish`] merges the +//! runs into one sorted entity file and one sorted relation file and hashes the +//! merged stream through the same frames as the in-memory manifest proof, so a +//! spilled generation's recovered digest, sealed container, and replay binding +//! are byte-identical to the manifest built from the same rows. +//! +//! The spill directory is scratch space: it is deleted when the spill or the +//! finished generation drops, and a directory left behind by a killed process +//! is swept the next time its graph store opens. + +use std::cmp::{Ordering as CmpOrdering, Reverse}; +use std::collections::BinaryHeap; +use std::fs::File; +use std::io::{BufReader, BufWriter, Read, Write}; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use serde::de::DeserializeOwned; +use sha2::{Digest, Sha256}; +use tracedecay_domain::canonical_text::encode_lowercase_hex; +use tracedecay_store::runtime::{ + GraphDependencyGenerationClosureDigestV1, GraphGenerationIdV1, GraphNamespaceV1, + GraphProjectionIdV1, GraphProjectionIdentityV1, GraphPublicationIdempotencyKeyV1, + GraphPublicationInputDigestV1, GraphPublicationKeyV1, GraphPublicationReplayV1, + GraphRecoveredGenerationDigestV1, GraphVerifiedHeadV1, MAX_GRAPH_REPLAY_SOURCE_BYTES_V1, + StoreShardIdV1, +}; + +use crate::limits::{MAX_VERIFIED_GENERATION_ENTITIES, MAX_VERIFIED_GENERATION_RELATIONS}; +use crate::{GraphBudgetKind, GraphDbError, GraphEntity, GraphEntityId, GraphIdempotencyKey}; + +use super::{ + CheckedDigestWriter, CheckedVecWriter, GraphGenerationManifest, + GraphGenerationManifestIdentity, GraphGenerationRelation, GraphGenerationReplaySource, + GraphProjectionIdentity, SealedCodeGenerationReplay, checked_canonical_bytes, + relational_dependency_generations, validate_sealed_replay, write_frame, + write_generation_identity_frames, +}; + +/// Canonical bytes a spill buffers before it sorts and writes one run. +/// +/// Bounds the producer-side working set independently of the corpus: a run +/// is written as soon as a batch pushes the buffer past it. +const SPILL_RUN_BYTES: usize = 32 * 1024 * 1024; +/// Read and write buffer per open run or merged file. +const SPILL_IO_BUFFER_BYTES: usize = 256 * 1024; +const ENTITIES_FILE: &str = "entities.rows"; +const RELATIONS_FILE: &str = "relations.rows"; + +/// Scratch directory owned by one spill; removed with its owner. +struct SpillDirectory(PathBuf); + +impl SpillDirectory { + fn create(path: PathBuf) -> Result { + std::fs::create_dir(&path).map_err(|error| spill_io("directory create", error))?; + Ok(Self(path)) + } + + fn path(&self) -> &Path { + &self.0 + } +} + +impl Drop for SpillDirectory { + fn drop(&mut self) { + match std::fs::remove_dir_all(&self.0) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => tracing::warn!( + event = "graph_row_spill_remove_failed", + path = %self.0.display(), + error = %error, + "graph row spill directory could not be removed; the next store open sweeps it" + ), + } + } +} + +fn spill_io(context: &str, error: std::io::Error) -> GraphDbError { + GraphDbError::unavailable(format!("graph row spill {context} failed: {error}")) +} + +/// One canonical row as it sits in a run: its identity (the sort key), the +/// entity identities a relation's endpoints name (empty for entities), and +/// the canonical JSON the recovered digest hashes. +struct SpillRow { + identity: String, + endpoints: [String; 2], + canonical: Vec, +} + +impl SpillRow { + fn bytes(&self) -> usize { + self.identity.len() + + self.endpoints[0].len() + + self.endpoints[1].len() + + self.canonical.len() + } + + fn write(&self, writer: &mut impl Write) -> Result<(), GraphDbError> { + for field in [ + self.identity.as_bytes(), + self.endpoints[0].as_bytes(), + self.endpoints[1].as_bytes(), + self.canonical.as_slice(), + ] { + let length = u32::try_from(field.len()) + .map_err(|_| GraphDbError::invalid("graph row spill field exceeds u32 bytes"))?; + writer + .write_all(&length.to_be_bytes()) + .and_then(|()| writer.write_all(field)) + .map_err(|error| spill_io("run write", error))?; + } + Ok(()) + } + + /// Reads the next row, or `None` at a clean end of file. + fn read(reader: &mut impl Read) -> Result, GraphDbError> { + let mut fields: [Vec; 4] = Default::default(); + for (index, field) in fields.iter_mut().enumerate() { + let mut length = [0_u8; 4]; + match reader.read_exact(&mut length) { + Ok(()) => {} + Err(error) if index == 0 && error.kind() == std::io::ErrorKind::UnexpectedEof => { + return Ok(None); + } + Err(error) => return Err(spill_io("run read", error)), + } + field.resize(u32::from_be_bytes(length) as usize, 0); + reader + .read_exact(field) + .map_err(|error| spill_io("run read", error))?; + } + let [identity, from, to, canonical] = fields; + let text = |bytes: Vec| { + String::from_utf8(bytes).map_err(|_| GraphDbError::Corrupt { + message: "graph row spill identity is not UTF-8".to_owned(), + }) + }; + Ok(Some(Self { + identity: text(identity)?, + endpoints: [text(from)?, text(to)?], + canonical, + })) + } +} + +/// Buffered rows of one kind plus the runs already written for it. +struct RowRuns { + kind: &'static str, + buffer: Vec, + buffered_bytes: usize, + runs: Vec, + pushed: usize, +} + +impl RowRuns { + fn new(kind: &'static str) -> Self { + Self { + kind, + buffer: Vec::new(), + buffered_bytes: 0, + runs: Vec::new(), + pushed: 0, + } + } + + fn push(&mut self, row: SpillRow, directory: &Path) -> Result<(), GraphDbError> { + self.buffered_bytes = self.buffered_bytes.saturating_add(row.bytes()); + self.buffer.push(row); + self.pushed += 1; + if self.buffered_bytes >= SPILL_RUN_BYTES { + self.flush(directory)?; + } + Ok(()) + } + + /// Sorts the buffered rows and writes them as one run. + fn flush(&mut self, directory: &Path) -> Result<(), GraphDbError> { + if self.buffer.is_empty() { + return Ok(()); + } + let mut rows = std::mem::take(&mut self.buffer); + self.buffered_bytes = 0; + rows.sort_unstable_by(|left, right| { + left.identity + .cmp(&right.identity) + .then_with(|| left.canonical.cmp(&right.canonical)) + }); + let path = directory.join(format!("{}-{}.run", self.kind, self.runs.len())); + let file = File::create(&path).map_err(|error| spill_io("run create", error))?; + let mut writer = BufWriter::with_capacity(SPILL_IO_BUFFER_BYTES, file); + for row in &rows { + row.write(&mut writer)?; + } + writer + .flush() + .map_err(|error| spill_io("run flush", error))?; + self.runs.push(path); + Ok(()) + } +} + +/// Accepts one generation's rows in batches and spills them to sorted runs. +/// +/// Every row is validated as it arrives exactly as the manifest constructor +/// validates it; duplicate identities, dangling endpoints, and escaping +/// endpoints are refused when [`Self::finish`] merges the runs. A spilled +/// generation is dependency-free: every relation endpoint must be one of its +/// own entities. +pub struct GraphGenerationRowSpill { + directory: SpillDirectory, + projection: GraphProjectionIdentity, + entities: RowRuns, + relations: RowRuns, + entity_identities: Vec, +} + +impl GraphGenerationRowSpill { + /// Creates a spill whose scratch runs live in `directory`, which must not + /// exist yet and is removed with the spill. + pub fn create( + directory: PathBuf, + projection: GraphProjectionIdentity, + ) -> Result { + Ok(Self { + directory: SpillDirectory::create(directory)?, + projection, + entities: RowRuns::new("entities"), + relations: RowRuns::new("relations"), + entity_identities: Vec::new(), + }) + } + + /// Distinct entity identities pushed so far: the entity count of the + /// generation if no further entity is pushed. + pub fn distinct_entities(&mut self) -> usize { + self.entity_identities.sort_unstable(); + self.entity_identities.dedup(); + self.entity_identities.len() + } + + /// Adds one batch of rows. The batch may arrive in any order and may + /// repeat a row another batch already pushed; an identical repeat is one + /// row of the generation, a differing one fails the merge. + pub fn push_batch( + &mut self, + entities: Vec, + relations: Vec, + check: &dyn Fn() -> Result<(), GraphDbError>, + ) -> Result<(), GraphDbError> { + check()?; + self.entity_identities.reserve(entities.len()); + for entity in entities { + check()?; + entity.validate()?; + let canonical = canonical_row(&entity, "recovered generation entity", check)?; + let GraphEntity { identity, .. } = entity; + let row = SpillRow { + identity: identity.as_str().to_owned(), + endpoints: Default::default(), + canonical, + }; + self.entity_identities.push(identity); + self.entities.push(row, self.directory.path())?; + } + for relation in relations { + check()?; + relation.validate()?; + for endpoint in [&relation.from, &relation.to] { + if endpoint.projection != self.projection { + return Err(GraphDbError::invalid(format!( + "relation endpoint projection `{}` is not the candidate or an exact dependency", + endpoint.projection + ))); + } + } + let canonical = canonical_row(&relation, "recovered generation relation", check)?; + let row = SpillRow { + identity: relation.identity.as_str().to_owned(), + endpoints: [ + relation.from.identity.as_str().to_owned(), + relation.to.identity.as_str().to_owned(), + ], + canonical, + }; + self.relations.push(row, self.directory.path())?; + } + if self.entity_identities.len() > MAX_VERIFIED_GENERATION_ENTITIES.saturating_mul(2) { + // Identities are deduplicated at finish; this bounds the resident + // list against a producer that repeats rows without end. + self.distinct_entities(); + } + Ok(()) + } + + /// Merges every run into the generation's canonical row order and hashes + /// it into the recovered digest. + pub fn finish( + mut self, + identity: GraphGenerationManifestIdentity, + check: &dyn Fn() -> Result<(), GraphDbError>, + ) -> Result { + check()?; + if identity.projection != self.projection { + return Err(GraphDbError::invalid( + "a spilled graph generation names a foreign projection", + )); + } + if !identity.dependencies.is_empty() { + return Err(GraphDbError::invalid( + "a spilled graph generation must be dependency-free", + )); + } + if self.distinct_entities() > MAX_VERIFIED_GENERATION_ENTITIES { + return Err(GraphDbError::budget_exhausted_count( + GraphBudgetKind::Capacity, + MAX_VERIFIED_GENERATION_ENTITIES, + )); + } + self.entities.flush(self.directory.path())?; + self.relations.flush(self.directory.path())?; + + let mut digest = Sha256::new(); + let mut writer = CheckedDigestWriter::new(&mut digest, check); + let mut canonical = CheckedVecWriter::new(check, MAX_GRAPH_REPLAY_SOURCE_BYTES_V1)?; + write_generation_identity_frames( + &mut writer, + &mut canonical, + &identity.projection, + &identity.generation, + &identity.source_generation, + &identity.watermark, + &identity.dependencies, + )?; + drop(canonical); + let directory = self.directory.path().to_path_buf(); + let entity_identities = self.entity_identities; + let entity_count = merge_runs( + &self.entities.runs, + &directory.join(ENTITIES_FILE), + check, + |row| { + write_frame(&mut writer, "entity", &row.canonical)?; + Ok(()) + }, + )?; + if entity_count != entity_identities.len() { + return Err(GraphDbError::Corrupt { + message: "graph row spill merged a different entity set than it was pushed" + .to_owned(), + }); + } + let relation_count = merge_runs( + &self.relations.runs, + &directory.join(RELATIONS_FILE), + check, + |row| { + for endpoint in &row.endpoints { + if entity_identities + .binary_search_by(|entity| entity.as_str().cmp(endpoint)) + .is_err() + { + return Err(GraphDbError::invalid(format!( + "local relation endpoint `{endpoint}` is absent from the candidate generation" + ))); + } + } + write_frame(&mut writer, "relation", &row.canonical) + }, + )?; + if relation_count > MAX_VERIFIED_GENERATION_RELATIONS { + return Err(GraphDbError::budget_exhausted_count( + GraphBudgetKind::Capacity, + MAX_VERIFIED_GENERATION_RELATIONS, + )); + } + writer.finish()?; + let expected_recovered_digest = GraphRecoveredGenerationDigestV1::new(format!( + "sha256:{}", + encode_lowercase_hex(&digest.finalize()) + )) + .map_err(|error| GraphDbError::invalid(error.to_string()))?; + for run in self.entities.runs.iter().chain(&self.relations.runs) { + std::fs::remove_file(run).map_err(|error| spill_io("run remove", error))?; + } + crate::hotpath_observe::record_counts(entity_count, relation_count, 0, 0); + Ok(SpilledGraphGeneration { + identity, + directory: self.directory, + entity_identities, + relation_count, + expected_recovered_digest, + }) + } +} + +fn canonical_row( + value: &T, + subject: &str, + check: &dyn Fn() -> Result<(), GraphDbError>, +) -> Result, GraphDbError> { + checked_canonical_bytes(value, check, subject, MAX_GRAPH_REPLAY_SOURCE_BYTES_V1) +} + +/// K-way merges sorted runs into `output`, visiting each distinct row once in +/// identity order. An identical repeat collapses into one row; two rows that +/// share an identity with different bytes refuse the generation exactly as +/// the manifest constructor refuses a repeated identity. Returns the number +/// of rows written. +fn merge_runs( + runs: &[PathBuf], + output: &Path, + check: &dyn Fn() -> Result<(), GraphDbError>, + mut visit: impl FnMut(&SpillRow) -> Result<(), GraphDbError>, +) -> Result { + struct Head { + row: SpillRow, + run: usize, + } + impl PartialEq for Head { + fn eq(&self, other: &Self) -> bool { + self.cmp(other) == CmpOrdering::Equal + } + } + impl Eq for Head {} + impl PartialOrd for Head { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } + } + impl Ord for Head { + fn cmp(&self, other: &Self) -> CmpOrdering { + self.row + .identity + .cmp(&other.row.identity) + .then_with(|| self.row.canonical.cmp(&other.row.canonical)) + .then_with(|| self.run.cmp(&other.run)) + } + } + + let mut readers = runs + .iter() + .map(|run| { + File::open(run) + .map(|file| BufReader::with_capacity(SPILL_IO_BUFFER_BYTES, file)) + .map_err(|error| spill_io("run open", error)) + }) + .collect::, _>>()?; + let mut heap = BinaryHeap::with_capacity(readers.len()); + for (run, reader) in readers.iter_mut().enumerate() { + if let Some(row) = SpillRow::read(reader)? { + heap.push(Reverse(Head { row, run })); + } + } + let file = File::create(output).map_err(|error| spill_io("merge create", error))?; + let mut writer = BufWriter::with_capacity(SPILL_IO_BUFFER_BYTES, file); + let mut previous: Option = None; + let mut written = 0usize; + while let Some(Reverse(Head { row, run })) = heap.pop() { + check()?; + if let Some(next) = SpillRow::read(&mut readers[run])? { + heap.push(Reverse(Head { row: next, run })); + } + if let Some(previous) = previous.as_ref() + && previous.identity == row.identity + { + if previous.canonical == row.canonical { + continue; + } + return Err(GraphDbError::invalid( + "a graph generation repeats an entity or relation identity", + )); + } + visit(&row)?; + row.write(&mut writer)?; + written += 1; + previous = Some(row); + } + writer + .flush() + .map_err(|error| spill_io("merge flush", error))?; + Ok(written) +} + +/// A generation whose canonical rows sit merged on disk, sorted and unique, +/// with the recovered digest their stream hashes to. +/// +/// Only the sorted entity identities stay resident; readers stream the rows +/// back in canonical order. +pub struct SpilledGraphGeneration { + identity: GraphGenerationManifestIdentity, + directory: SpillDirectory, + entity_identities: Vec, + relation_count: usize, + expected_recovered_digest: GraphRecoveredGenerationDigestV1, +} + +impl std::fmt::Debug for SpilledGraphGeneration { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("SpilledGraphGeneration") + .field("generation", &self.identity.generation) + .field("entities", &self.entity_identities.len()) + .field("relations", &self.relation_count) + .field("expected_recovered_digest", &self.expected_recovered_digest) + .finish_non_exhaustive() + } +} + +impl SpilledGraphGeneration { + #[must_use] + pub fn identity(&self) -> GraphGenerationManifestIdentity { + self.identity.clone() + } + + /// `(entities, relations)` row counts. + #[must_use] + pub fn row_counts(&self) -> (usize, usize) { + (self.entity_identities.len(), self.relation_count) + } + + #[must_use] + pub fn expected_recovered_digest(&self) -> &GraphRecoveredGenerationDigestV1 { + &self.expected_recovered_digest + } + + /// The position of `identity` in the canonical entity order. + pub(crate) fn entity_index(&self, identity: &str) -> Option { + self.entity_identities + .binary_search_by(|entity| entity.as_str().cmp(identity)) + .ok() + } + + pub(crate) fn entities(&self) -> Result, GraphDbError> { + SpilledRows::open(&self.directory.path().join(ENTITIES_FILE)) + } + + pub(crate) fn relations(&self) -> Result, GraphDbError> { + SpilledRows::open(&self.directory.path().join(RELATIONS_FILE)) + } + + /// The whole generation as an in-memory manifest, for the staging lane + /// that has no sealed store to stream into. + pub fn materialize( + &self, + check: &dyn Fn() -> Result<(), GraphDbError>, + ) -> Result { + let entities = self + .entities()? + .map(|row| row.map(|(value, _)| value)) + .collect::, _>>()?; + let relations = self + .relations()? + .map(|row| row.map(|(value, _)| value)) + .collect::, _>>()?; + GraphGenerationManifest::new_checked( + self.identity.projection.clone(), + self.identity.generation.clone(), + self.identity.source_generation.clone(), + self.identity.watermark.clone(), + Vec::new(), + entities, + relations, + check, + ) + } +} + +/// Decoded rows of one merged spill file, in canonical order, each with the +/// endpoint identities it was spilled with. +pub(crate) struct SpilledRows { + reader: BufReader, + decoded: std::marker::PhantomData, +} + +impl SpilledRows { + fn open(path: &Path) -> Result { + let file = File::open(path).map_err(|error| spill_io("merged open", error))?; + Ok(Self { + reader: BufReader::with_capacity(SPILL_IO_BUFFER_BYTES, file), + decoded: std::marker::PhantomData, + }) + } +} + +impl Iterator for SpilledRows { + type Item = Result<(T, [String; 2]), GraphDbError>; + + fn next(&mut self) -> Option { + match SpillRow::read(&mut self.reader) { + Ok(Some(row)) => Some( + serde_json::from_slice(&row.canonical) + .map(|value| (value, row.endpoints)) + .map_err(|error| GraphDbError::Corrupt { + message: format!("graph row spill holds an undecodable row: {error}"), + }), + ), + Ok(None) => None, + Err(error) => Some(Err(error)), + } + } +} + +/// A generation's rows as a publisher hands them to the registry: an +/// in-memory manifest, or rows spilled to disk by a batch producer. +#[derive(Clone, Debug)] +pub enum GraphGenerationRows { + Manifest(Arc), + Spilled(Arc), +} + +impl From> for GraphGenerationRows { + fn from(manifest: Arc) -> Self { + Self::Manifest(manifest) + } +} + +impl From for GraphGenerationRows { + fn from(manifest: GraphGenerationManifest) -> Self { + Self::Manifest(Arc::new(manifest)) + } +} + +impl From for GraphGenerationRows { + fn from(spilled: SpilledGraphGeneration) -> Self { + Self::Spilled(Arc::new(spilled)) + } +} + +impl GraphGenerationRows { + #[must_use] + pub fn identity(&self) -> GraphGenerationManifestIdentity { + match self { + Self::Manifest(manifest) => manifest.identity(), + Self::Spilled(spilled) => spilled.identity(), + } + } + + #[must_use] + pub fn row_counts(&self) -> (usize, usize) { + match self { + Self::Manifest(manifest) => manifest.row_counts(), + Self::Spilled(spilled) => spilled.row_counts(), + } + } + + pub fn expected_recovered_digest( + &self, + check: &dyn Fn() -> Result<(), GraphDbError>, + ) -> Result { + match self { + Self::Manifest(manifest) => manifest.expected_recovered_digest(check), + Self::Spilled(spilled) => Ok(spilled.expected_recovered_digest.clone()), + } + } + + /// The dependency-closure digest, memoized on the rows' own instance. + pub fn dependency_closure_digest( + &self, + check: &dyn Fn() -> Result<(), GraphDbError>, + ) -> Result { + match self { + Self::Manifest(manifest) => manifest.dependency_closure_digest(check), + Self::Spilled(spilled) => spilled.identity.dependency_closure_digest(check), + } + } + + /// The in-memory manifest, materializing spilled rows. Only the staging + /// lane, which has no sealed store to stream into, pays this. + pub(crate) fn into_manifest( + self, + check: &dyn Fn() -> Result<(), GraphDbError>, + ) -> Result, GraphDbError> { + match self { + Self::Manifest(manifest) => Ok(manifest), + Self::Spilled(spilled) => spilled.materialize(check).map(Arc::new), + } + } + + /// The journaled replay of a sealed code generation publishing these rows. + pub fn relational_sealed_replay( + &self, + shard_id: StoreShardIdV1, + idempotency_key: GraphIdempotencyKey, + input_digest: GraphPublicationInputDigestV1, + expected_prior_head: Option, + source: SealedCodeGenerationReplay, + check: &dyn Fn() -> Result<(), GraphDbError>, + ) -> Result { + match self { + Self::Manifest(manifest) => manifest.relational_sealed_replay( + shard_id, + idempotency_key, + input_digest, + expected_prior_head, + source, + check, + ), + Self::Spilled(spilled) => { + validate_sealed_replay(&source)?; + let payload = checked_canonical_bytes( + &GraphGenerationReplaySource::SealedCodeGeneration(source), + check, + "canonical graph generation replay", + MAX_GRAPH_REPLAY_SOURCE_BYTES_V1, + )?; + spilled.identity.relational_replay_with_payload( + shard_id, + idempotency_key, + input_digest, + expected_prior_head, + spilled.expected_recovered_digest.clone(), + payload, + check, + ) + } + } + } +} + +impl GraphGenerationManifestIdentity { + #[allow(clippy::too_many_arguments)] + pub(super) fn relational_replay_with_payload( + &self, + shard_id: StoreShardIdV1, + idempotency_key: GraphIdempotencyKey, + input_digest: GraphPublicationInputDigestV1, + expected_prior_head: Option, + expected_recovered_digest: GraphRecoveredGenerationDigestV1, + payload: Vec, + check: &dyn Fn() -> Result<(), GraphDbError>, + ) -> Result { + check()?; + let projection = GraphProjectionIdentityV1 { + shard_id, + namespace: GraphNamespaceV1::new(self.projection.namespace.as_str()) + .map_err(|error| GraphDbError::invalid(error.to_string()))?, + projection: GraphProjectionIdV1::new(self.projection.projection.as_str()) + .map_err(|error| GraphDbError::invalid(error.to_string()))?, + }; + let direct_dependencies = + relational_dependency_generations(&self.dependencies, &projection.shard_id)?; + let key = GraphPublicationKeyV1::new( + projection, + GraphGenerationIdV1::new(self.generation.as_str()) + .map_err(|error| GraphDbError::invalid(error.to_string()))?, + GraphPublicationIdempotencyKeyV1::new(idempotency_key.as_str()) + .map_err(|error| GraphDbError::invalid(error.to_string()))?, + ); + GraphPublicationReplayV1::new( + key, + input_digest, + self.dependency_closure_digest(check)?, + direct_dependencies, + expected_prior_head, + expected_recovered_digest, + payload, + ) + .map_err(|error| GraphDbError::invalid(error.to_string())) + } +} diff --git a/crates/tracedecay-graph-db/src/lib.rs b/crates/tracedecay-graph-db/src/lib.rs index 073b5fb970..b91718386f 100644 --- a/crates/tracedecay-graph-db/src/lib.rs +++ b/crates/tracedecay-graph-db/src/lib.rs @@ -32,9 +32,10 @@ pub use error::{ pub use generation::{ GraphEntityRef, GraphGenerationDependency, GraphGenerationManifest, GraphGenerationManifestIdentity, GraphGenerationManifestProvider, GraphGenerationRelation, - GraphGenerationReplayMetadata, GraphGenerationReplaySource, GraphProjectionIdentity, - GraphProjectorRevision, GraphRelationRef, GraphReplayCollectionOutcome, - SealedCodeGenerationReplay, SealedGraphStateDigest, SupersededReplayRetirement, + GraphGenerationReplayMetadata, GraphGenerationReplaySource, GraphGenerationRowSpill, + GraphGenerationRows, GraphProjectionIdentity, GraphProjectorRevision, GraphRelationRef, + GraphReplayCollectionOutcome, SealedCodeGenerationReplay, SealedGraphStateDigest, + SpilledGraphGeneration, SupersededReplayRetirement, }; pub use generation_runtime::{SealedStagingRelease, SealedStagingRetentionReason}; pub use lease::{VerifiedGraphSnapshot, VerifiedTraversalResult, VerifiedTraversalVisit}; diff --git a/crates/tracedecay-graph-db/src/registry/publication.rs b/crates/tracedecay-graph-db/src/registry/publication.rs index ee2b5e06b4..13c0d3a0ef 100644 --- a/crates/tracedecay-graph-db/src/registry/publication.rs +++ b/crates/tracedecay-graph-db/src/registry/publication.rs @@ -24,7 +24,7 @@ use super::publication_support::{ validate_replay_cursor, }; use super::{GraphDbRegistration, GraphDbRegistry, check_registration_request}; -use crate::generation::{metadata_manifest_from_source, validate_supplied_manifest_binding}; +use crate::generation::{metadata_manifest_from_source, validate_supplied_rows_binding}; use crate::generation_runtime::{ GenerationContentsDeletion, GenerationStageOutcome, SealedReleaseReceiptAuthority, }; @@ -32,10 +32,10 @@ use crate::lease::{ GenerationLocator, VerifiedGenerationLease, VerifiedGraphSnapshot, generation_lease, }; use crate::{ - GraphCommit, GraphDb, GraphDbError, GraphDbLeaseV1, GraphGenerationManifest, - GraphGenerationManifestIdentity, GraphGenerationReplaySource, GraphProjectionIdentity, - GraphReplayCollectionOutcome, SealedStagingRelease, SealedStagingRetentionReason, - SupersededReplayRetirement, VerifiedGraphCommit, + GraphCommit, GraphDb, GraphDbError, GraphDbLeaseV1, GraphGenerationManifestIdentity, + GraphGenerationReplaySource, GraphGenerationRowSpill, GraphGenerationRows, GraphNamespace, + GraphProjectionId, GraphProjectionIdentity, GraphReplayCollectionOutcome, SealedStagingRelease, + SealedStagingRetentionReason, SupersededReplayRetirement, VerifiedGraphCommit, }; /// A publication whose durable generation proof completed but whose @@ -1202,7 +1202,7 @@ impl GraphDbRegistry { authority: &mut dyn GraphPublicationStoreV1, context: &GraphPublicationOperationContextV1<'_>, publication_key: &GraphPublicationKeyV1, - supplied_manifest: Option>, + supplied_rows: Option, ) -> Result { let operation = self.registered_operation(registration)?; self.publish_verified_inner( @@ -1210,7 +1210,7 @@ impl GraphDbRegistry { authority, context, publication_key, - supplied_manifest, + supplied_rows, ) } @@ -1230,7 +1230,7 @@ impl GraphDbRegistry { authority: &mut dyn GraphPublicationStoreV1, context: &GraphPublicationOperationContextV1<'_>, publication_key: &GraphPublicationKeyV1, - supplied_manifest: Option>, + supplied_rows: Option, ) -> Result { let operation = self.registered_operation(registration)?; self.prepare_verified_publication_inner( @@ -1238,7 +1238,7 @@ impl GraphDbRegistry { authority, context, publication_key, - supplied_manifest, + supplied_rows, ) } @@ -1259,6 +1259,18 @@ impl GraphDbRegistry { self.complete_verified_publication_inner(&operation, authority, context, proven) } + /// A row spill for one generation of `projection`, under the registered + /// store's scratch root. A batch producer fills it and hands the finished + /// generation to [`Self::prepare_verified_publication`]. + pub fn generation_row_spill( + &self, + registration: GraphDbRegistration, + projection: GraphProjectionIdentity, + ) -> Result { + let operation = self.registered_operation(registration)?; + operation.database().generation_row_spill(projection) + } + /// Publishes through an already-issued, registry-validated graph lease. /// /// The caller retains the exact operation lease through the publication; @@ -1282,14 +1294,14 @@ impl GraphDbRegistry { authority: &mut dyn GraphPublicationStoreV1, context: &GraphPublicationOperationContextV1<'_>, publication_key: &GraphPublicationKeyV1, - supplied_manifest: Option>, + supplied_rows: Option, ) -> Result { match self.prepare_verified_publication_inner( operation, authority, context, publication_key, - supplied_manifest, + supplied_rows, )? { GraphPublicationPreparationV1::Settled(commit) => Ok(*commit), GraphPublicationPreparationV1::Proven(proven) => { @@ -1314,7 +1326,7 @@ impl GraphDbRegistry { authority: &mut dyn GraphPublicationStoreV1, context: &GraphPublicationOperationContextV1<'_>, publication_key: &GraphPublicationKeyV1, - supplied_manifest: Option>, + supplied_rows: Option, ) -> Result { operation.check(self, context)?; operation.require_publication_binding(publication_key)?; @@ -1357,20 +1369,21 @@ impl GraphDbRegistry { let metadata_manifest = metadata_manifest_from_source(&replay.publication, &source, &check)?; let metadata_only = metadata_manifest.is_some(); - let has_supplied_manifest = supplied_manifest.is_some(); - let manifest = match supplied_manifest { - Some(manifest) => { - validate_supplied_manifest_binding(&replay.publication, &manifest, true, &check)?; - manifest + let has_supplied_manifest = supplied_rows.is_some(); + let manifest = match supplied_rows { + Some(rows) => { + validate_supplied_rows_binding(&replay.publication, &rows, true, &check)?; + rows } None => match metadata_manifest { - Some(manifest) => Arc::new(manifest), - None => Arc::new(GraphGenerationManifest::from_replay_source( + Some(manifest) => manifest.into(), + None => GraphGenerationRows::from_replay_source( &replay.publication, source, self.inner.manifest_provider.as_ref(), + || replay_row_spill(&database, &replay.publication.key.projection), &check, - )?), + )?, }, }; let apply_native = !metadata_only; @@ -1549,7 +1562,7 @@ impl GraphDbRegistry { ); database.remember_sealed_only_generation(&repair_lease)?; database.apply_generation_unverified_with_digest_observed( - manifest, + manifest.into_manifest(&repair_check)?, sealed_digest, &repair_check, )?; @@ -1606,7 +1619,7 @@ impl GraphDbRegistry { } else { let staged = database .apply_generation_unverified_with_digest_observed( - manifest, + manifest.into_manifest(&check)?, sealed_digest, &check, )?; @@ -1754,7 +1767,7 @@ impl GraphDbRegistry { } Ok(None) => { let staged = database.apply_generation_unverified_with_digest_observed( - manifest, + manifest.into_manifest(&check)?, sealed_digest, &check, )?; @@ -2244,11 +2257,12 @@ impl GraphDbRegistry { let metadata_manifest = metadata_manifest_from_source(&replay.publication, &source, &check)?; let manifest = match metadata_manifest { - Some(manifest) => manifest, - None => GraphGenerationManifest::from_replay_source( + Some(manifest) => manifest.into(), + None => GraphGenerationRows::from_replay_source( &replay.publication, source, self.inner.manifest_provider.as_ref(), + || replay_row_spill(database, &replay.publication.key.projection), &check, )?, }; @@ -2427,8 +2441,8 @@ fn describe_verified_head(head: Option<&GraphVerifiedHeadV1>) -> String { } } -/// Seals an eligible generation straight from its manifest, bypassing the -/// staging database entirely; see [`GraphDb::seal_generation_from_manifest`]. +/// Seals an eligible generation straight from its rows, bypassing the +/// staging database entirely; see [`GraphDb::seal_generation_directly`]. /// /// `Ok(None)` means the generation must be staged and proven the ordinary /// way: it is not eligible (its rows have no durable home outside staging, @@ -2436,7 +2450,7 @@ fn describe_verified_head(head: Option<&GraphVerifiedHeadV1>) -> String { /// lane cannot serve this database. fn direct_seal( database: &GraphDbLeaseV1, - manifest: &GraphGenerationManifest, + rows: &GraphGenerationRows, expected: &GraphRecoveredGenerationDigestV1, eligible: bool, check: &dyn Fn() -> Result<(), GraphDbError>, @@ -2444,7 +2458,19 @@ fn direct_seal( if !eligible { return Ok(None); } - database.seal_generation_from_manifest(manifest, expected, check) + database.seal_generation_directly(rows, expected, check) +} + +/// A row spill under `database`'s own scratch root for the generation a +/// journaled replay of `projection` publishes. +fn replay_row_spill( + database: &GraphDb, + projection: &GraphProjectionIdentityV1, +) -> Result { + database.generation_row_spill(GraphProjectionIdentity::new( + GraphNamespace::new(projection.namespace.as_str())?, + GraphProjectionId::new(projection.projection.as_str())?, + )) } /// Seats a verified lease for a historical (already durably linearized) @@ -3334,7 +3360,7 @@ mod historical_publication_reuse_tests { &mut fixture.authority, &context, &fixture.key, - Some(Arc::clone(&manifest)), + Some(Arc::clone(&manifest).into()), ) .unwrap(); assert_eq!(repaired.head, fixture.head); @@ -3410,7 +3436,7 @@ mod historical_publication_reuse_tests { &mut fixture.authority, &context, &fixture.key, - Some(manifest), + Some(manifest.into()), ), Err(GraphDbError::Conflict { .. }) )); diff --git a/crates/tracedecay-graph-db/src/runtime.rs b/crates/tracedecay-graph-db/src/runtime.rs index 11a0190c5a..25d07454a6 100644 --- a/crates/tracedecay-graph-db/src/runtime.rs +++ b/crates/tracedecay-graph-db/src/runtime.rs @@ -188,6 +188,7 @@ impl GraphDb { let opened = open_validated_graph(&validated, GraphEngineOpenSite::Eager)?; if let Some(path) = validated.config.path.as_deref() { crate::sealed_store::sweep_abandoned_sealed_staging(path); + crate::sealed_store::sweep_abandoned_row_spills(path); } markers.bind(opened.identity); let graph = Arc::new(Self { diff --git a/crates/tracedecay-graph-db/src/sealed_store.rs b/crates/tracedecay-graph-db/src/sealed_store.rs index 0da3f9ebd1..4a5f28c149 100644 --- a/crates/tracedecay-graph-db/src/sealed_store.rs +++ b/crates/tracedecay-graph-db/src/sealed_store.rs @@ -47,6 +47,7 @@ use std::collections::{BTreeMap, HashMap}; use std::panic::{AssertUnwindSafe, catch_unwind}; use std::path::{Path, PathBuf}; use std::sync::Arc; +use std::sync::atomic::{AtomicU64, Ordering as AtomicOrdering}; use grafeo_common::types::{EdgeId, NodeId, PropertyKey, Value}; use grafeo_core::graph::compact::IncrementalCompactStoreBuilder; @@ -71,7 +72,9 @@ use crate::state::{ use crate::{ GraphCommit, GraphDb, GraphDbError, GraphDbLocation, GraphDbOpenOptions, GraphDurability, GraphEntity, GraphFormatVersion, GraphGenerationManifest, GraphGenerationManifestIdentity, - GraphNamespace, GraphProjectionId, GraphRelation, GraphWriteBatch, NeverCancelled, + GraphGenerationRowSpill, GraphGenerationRows, GraphNamespace, GraphProjectionId, + GraphProjectionIdentity, GraphRelation, GraphWriteBatch, NeverCancelled, + SpilledGraphGeneration, }; /// Opens and verifies one dependency-free sealed generation without opening @@ -496,6 +499,41 @@ pub(crate) fn sweep_abandoned_sealed_staging(database_path: &Path) { } } +/// Prefix of a batch producer's row spill under the sealed root. +const ROW_SPILL_PREFIX: &str = ".rows-"; + +/// Removes every row spill another process left under the store's sealed +/// root. Run at the eager open, whose exclusive store lock means no other +/// process is producing rows for this store. Spills named for this process +/// are skipped: a remount inside a live daemon may overlap its own publisher, +/// whose spill removes itself when it drops. +pub(crate) fn sweep_abandoned_row_spills(database_path: &Path) { + let root = sealed_store_root(database_path); + let Ok(entries) = std::fs::read_dir(&root) else { + return; + }; + let own = format!("{ROW_SPILL_PREFIX}{}-", std::process::id()); + for entry in entries.flatten() { + if entry + .file_name() + .to_str() + .is_some_and(|name| name.starts_with(ROW_SPILL_PREFIX) && !name.starts_with(&own)) + { + let path = entry.path(); + if let Err(error) = std::fs::remove_dir_all(&path) + && error.kind() != std::io::ErrorKind::NotFound + { + tracing::warn!( + event = "graph_row_spill_sweep_failed", + path = %path.display(), + error = %error, + "abandoned graph row spill could not be removed" + ); + } + } + } +} + fn remove_sealed_directory(directory: &Path) { match std::fs::remove_dir_all(directory) { Ok(()) => {} @@ -976,9 +1014,9 @@ impl GraphDb { /// (kill-switch set, memory-backed, or no reopen configuration), so the /// caller must stage and prove the generation the ordinary way. #[hotpath::measure(label = "graph_db.sealed_store.seal_direct", impl_type = "GraphDb")] - pub(crate) fn seal_generation_from_manifest( + pub(crate) fn seal_generation_directly( &self, - manifest: &GraphGenerationManifest, + rows: &GraphGenerationRows, expected: &GraphRecoveredGenerationDigestV1, check: &dyn Fn() -> Result<(), GraphDbError>, ) -> Result, GraphDbError> { @@ -992,8 +1030,14 @@ impl GraphDb { return Ok(None); }; check()?; - manifest.validate_checked(check)?; - let identity = manifest.identity(); + let source = match rows { + GraphGenerationRows::Manifest(manifest) => { + manifest.validate_checked(check)?; + SealedRowSource::Manifest(manifest) + } + GraphGenerationRows::Spilled(spilled) => SealedRowSource::Spilled(spilled), + }; + let identity = rows.identity(); if !identity.dependencies.is_empty() { return Err(GraphDbError::invalid( "a direct sealed build requires a dependency-free generation", @@ -1010,13 +1054,8 @@ impl GraphDb { // restage gets, not a silent rebuild underneath its readers. return Err(refusal); } - let (store, _) = build_or_open_sealed_store( - SealedRowSource::Manifest(manifest), - &identity, - expected, - &database_path, - check, - )?; + let (store, _) = + build_or_open_sealed_store(source, &identity, expected, &database_path, check)?; self.install_sealed_generation_store(locator.clone(), store)?; // The generation normally exists only as this sealed artifact: it is // sealed-only from its first instant, and no lease remembered for it @@ -1052,6 +1091,33 @@ impl GraphDb { Ok(Some(commit)) } + /// A row spill for one generation of `projection`, scratch space under + /// this store's sealed root. Registered stores are always persistent; a + /// memory-backed database has no disk to spill to and refuses typed. + pub(crate) fn generation_row_spill( + &self, + projection: GraphProjectionIdentity, + ) -> Result { + static NEXT_SPILL: AtomicU64 = AtomicU64::new(0); + let database_path = self + .inner + .reopen + .as_ref() + .and_then(|reopen| reopen.config.path.as_deref()) + .ok_or_else(|| { + GraphDbError::unavailable("a memory-backed graph store has no row spill root") + })?; + let root = sealed_store_root(database_path); + std::fs::create_dir_all(&root) + .map_err(|error| sealed_store_io_failure("row spill root create failed", error))?; + let directory = root.join(format!( + "{ROW_SPILL_PREFIX}{}-{}", + std::process::id(), + NEXT_SPILL.fetch_add(1, AtomicOrdering::Relaxed) + )); + GraphGenerationRowSpill::create(directory, projection) + } + /// Opens an existing sealed store for `identity` without building one. /// /// Used on the recovery path: a matching artifact on disk is installed @@ -1275,6 +1341,9 @@ pub(crate) enum SealedRowSource<'a> { /// or read. The journal and the code generation it names remain the /// recovery source for every failure boundary of the build. Manifest(&'a GraphGenerationManifest), + /// The same rows, merged on disk by a batch producer. Always + /// dependency-free, and recoverable from the same journal. + Spilled(&'a SpilledGraphGeneration), } /// Builds (or adopts) the sealed store for `identity` and returns the @@ -1405,6 +1474,16 @@ fn build_sealed_container( )?; Ok((counts.0, counts.1, BTreeMap::new())) } + SealedRowSource::Spilled(spilled) => { + let counts = push_spilled_rows( + spilled, + identity, + &physical_namespace, + &mut sealed, + check, + )?; + Ok((counts.0, counts.1, BTreeMap::new())) + } } })?; @@ -1565,6 +1644,119 @@ fn push_manifest_rows( Ok((entities.len(), manifest.relations.len())) } +/// Pushes a spilled generation's merged rows in the same order and shape as +/// [`push_manifest_rows`]: entities stream in identity order and take the +/// first sealed node ids, then relations stream in identity order with each +/// endpoint resolved to its entity's position in the resident identity list. +/// Only one window of decoded rows is held at a time. +fn push_spilled_rows( + spilled: &SpilledGraphGeneration, + identity: &GraphGenerationManifestIdentity, + physical_namespace: &GraphNamespace, + sealed: &mut SealedCompactRows, + check: &dyn Fn() -> Result<(), GraphDbError>, +) -> Result<(usize, usize), GraphDbError> { + let projection = &identity.projection.projection; + let workers = rayon::current_thread_index() + .map(|_| rayon::current_num_threads()) + .unwrap_or(1); + let row_window = workers.max(1).saturating_mul(512); + hotpath::gauge!("code_index.seal.encode.effective_workers").set(workers); + let (entity_count, relation_count) = spilled.row_counts(); + hotpath::measure_block!("code_index.seal.encode.entities", { + let mut rows = spilled.entities()?; + let mut pushed = 0usize; + loop { + let window = rows + .by_ref() + .take(row_window) + .map(|row| row.map(|(entity, _)| entity)) + .collect::, _>>()?; + if window.is_empty() { + break; + } + check()?; + let prepared = collect_prepared_rows_ordered(&window, |_, entity| { + Ok(SealedCompactRows::prepare_entity( + physical_namespace, + projection, + entity, + )) + })?; + for prepared in prepared { + let node = sealed.push_prepared_node(prepared)?; + if usize::try_from(node.as_u64()).ok() != Some(pushed) { + return Err(GraphDbError::Corrupt { + message: "sealed build entity ids diverged from spilled order".to_owned(), + }); + } + pushed += 1; + } + } + if pushed != entity_count { + return Err(GraphDbError::Corrupt { + message: "sealed build read a different entity count than was spilled".to_owned(), + }); + } + Ok::<(), GraphDbError>(()) + })?; + hotpath::measure_block!("code_index.seal.encode.relations", { + let mut rows = spilled.relations()?; + let mut pushed = 0usize; + loop { + let window = rows + .by_ref() + .take(row_window) + .collect::, _>>()?; + if window.is_empty() { + break; + } + check()?; + let start = pushed; + let prepared = collect_prepared_rows_ordered( + &window, + |offset, (relation, endpoints)| { + let mut nodes = [NodeId::new(0); 2]; + for (slot, endpoint) in nodes.iter_mut().zip(endpoints) { + let index = spilled.entity_index(endpoint).ok_or_else(|| { + GraphDbError::Corrupt { + message: format!( + "local relation endpoint `{endpoint}` is absent from the candidate generation" + ), + } + })?; + *slot = NodeId::new(u64::try_from(index).map_err(|_| { + GraphDbError::unavailable("sealed entity count exceeds u64") + })?); + } + let edge_index = u64::try_from(start.saturating_add(offset)).map_err(|_| { + GraphDbError::unavailable("sealed relation count exceeds u64") + })?; + let stored = relation.storage_relation()?; + let prepared = SealedCompactRows::prepare_relation( + physical_namespace, + projection, + &stored, + EdgeId::new(edge_index), + )?; + Ok((prepared, nodes)) + }, + )?; + for (prepared, nodes) in prepared { + sealed.push_prepared_relation(prepared, nodes[0], nodes[1])?; + pushed += 1; + } + } + if pushed != relation_count { + return Err(GraphDbError::Corrupt { + message: "sealed build read a different relation count than was spilled".to_owned(), + }); + } + Ok::<(), GraphDbError>(()) + })?; + Ok((entity_count, relation_count)) +} + /// Pushes a staged generation's rows out of the shared staging database. /// /// Each staging read guard is held for one bounded chunk so concurrent diff --git a/crates/tracedecay-graph-db/tests/graph_db_suite/verified_generation_contract.rs b/crates/tracedecay-graph-db/tests/graph_db_suite/verified_generation_contract.rs index b3a85b1b54..10d9bffb2a 100644 --- a/crates/tracedecay-graph-db/tests/graph_db_suite/verified_generation_contract.rs +++ b/crates/tracedecay-graph-db/tests/graph_db_suite/verified_generation_contract.rs @@ -811,7 +811,7 @@ fn sealed_code_generation_publishes_with_its_supplied_manifest() { &mut authority, &context, &record.publication.key, - Some(Arc::new(foreign)), + Some(Arc::new(foreign).into()), ) .unwrap_err(), GraphDbError::conflict("replay.validate_publication_manifest_identity") @@ -826,7 +826,7 @@ fn sealed_code_generation_publishes_with_its_supplied_manifest() { &mut authority, &context, &record.publication.key, - Some(Arc::new(sealed_manifest.clone())), + Some(Arc::new(sealed_manifest.clone()).into()), ) .expect("the exact supplied sealed projection manifest must publish"); assert_eq!(commit.head.key, record.publication.key); diff --git a/crates/tracedecay-graph-db/tests/graph_db_suite/verified_generation_contract/metadata_replay.rs b/crates/tracedecay-graph-db/tests/graph_db_suite/verified_generation_contract/metadata_replay.rs index 412ed216ee..ac304c1484 100644 --- a/crates/tracedecay-graph-db/tests/graph_db_suite/verified_generation_contract/metadata_replay.rs +++ b/crates/tracedecay-graph-db/tests/graph_db_suite/verified_generation_contract/metadata_replay.rs @@ -65,7 +65,7 @@ fn omits_native_rows_and_recovers_only_persisted_native_rows() { &mut authority, &context, &record.publication.key, - Some(Arc::new(mismatched_manifest)), + Some(Arc::new(mismatched_manifest).into()), ), Err(GraphDbError::Conflict { .. }) )); @@ -77,7 +77,7 @@ fn omits_native_rows_and_recovers_only_persisted_native_rows() { &mut authority, &context, &record.publication.key, - Some(Arc::new(row_manifest.clone())), + Some(Arc::new(row_manifest.clone()).into()), ) .unwrap(); assert_eq!( @@ -111,7 +111,7 @@ fn omits_native_rows_and_recovers_only_persisted_native_rows() { &mut authority, &replay_context, &record.publication.key, - Some(Arc::new(row_manifest)), + Some(Arc::new(row_manifest).into()), ) .unwrap(); assert_eq!(exact_replay.head, expected_head); @@ -163,7 +163,7 @@ fn incomplete_pending_generation_cannot_advance_verified_head() { &mut authority, &context, &record.publication.key, - Some(Arc::new(incomplete)), + Some(Arc::new(incomplete).into()), ), Err(GraphDbError::Conflict { .. }) )); diff --git a/crates/tracedecay-graph-db/tests/graph_db_suite/verified_generation_contract/sealed_store.rs b/crates/tracedecay-graph-db/tests/graph_db_suite/verified_generation_contract/sealed_store.rs index c848a79e7a..5b45318d1d 100644 --- a/crates/tracedecay-graph-db/tests/graph_db_suite/verified_generation_contract/sealed_store.rs +++ b/crates/tracedecay-graph-db/tests/graph_db_suite/verified_generation_contract/sealed_store.rs @@ -6,7 +6,10 @@ use std::path::{Path, PathBuf}; use std::sync::atomic::AtomicBool; -use tracedecay_graph_db::{GraphTraversalDirection, TraversalRequest}; +use tracedecay_graph_db::{ + GraphGenerationRowSpill, GraphGenerationRows, GraphLabel, GraphTraversalDirection, + TraversalRequest, +}; use super::*; @@ -209,7 +212,7 @@ fn publish_sealed( authority, &context, &record.publication.key, - Some(Arc::new(manifest.clone())), + Some(Arc::new(manifest.clone()).into()), ) .unwrap() } @@ -701,7 +704,7 @@ fn missing_sealed_only_artifact_requires_reset_and_allows_republish() { &mut authority, &context, &record.publication.key, - Some(Arc::new(manifest.clone())), + Some(Arc::new(manifest.clone()).into()), ) .unwrap(); assert_snapshot_reads(&republished.snapshot, &identity, "restaged"); @@ -1730,7 +1733,7 @@ fn sealed_artifact_open_probe() { &mut authority, &context, &record.publication.key, - Some(Arc::new(manifest)), + Some(Arc::new(manifest).into()), ) .unwrap(); let seal_wall = seal_started.elapsed(); @@ -1854,3 +1857,310 @@ fn sealed_artifact_open_probe() { assert!(staging_visits > 1); assert_eq!(sealed_visits, staging_visits); } + +/// A generation wide enough that its rows span many spill batches: 300 +/// entities with distinct labels and payloads, and 450 relations whose +/// endpoints cross every batch boundary. +fn spill_fixture_manifest(identity: GraphProjectionIdentity) -> GraphGenerationManifest { + let entities = (0..300) + .map(|index| { + GraphEntity::new( + GraphEntityId::new(format!("entity:{index:03}")).unwrap(), + BTreeSet::from([ + GraphLabel::new(if index % 3 == 0 { "File" } else { "Symbol" }).unwrap(), + ]), + BTreeMap::from([( + GraphPropertyName::new("marker").unwrap(), + GraphProperty::String(format!("payload-{}", index * 7919 % 1000)), + )]), + ) + .unwrap() + }) + .collect::>(); + let relations = (0..450) + .map(|index| { + GraphGenerationRelation::new( + GraphRelationId::new(format!("relation:{index:03}")).unwrap(), + GraphEntityRef::new( + identity.clone(), + GraphEntityId::new(format!("entity:{:03}", index % 300)).unwrap(), + ), + GraphEntityRef::new( + identity.clone(), + GraphEntityId::new(format!("entity:{:03}", (index * 37 + 11) % 300)).unwrap(), + ), + GraphRelationKind::new(if index % 2 == 0 { "calls" } else { "uses" }).unwrap(), + BTreeMap::new(), + ) + .unwrap() + }) + .collect::>(); + GraphGenerationManifest::new( + identity, + GraphGenerationId::new("spill-g1").unwrap(), + SourceGeneration::new("source:spill-g1").unwrap(), + GraphWatermark::new("watermark:spill-g1").unwrap(), + Vec::new(), + entities, + relations, + ) + .unwrap() +} + +/// The manifest's rows pushed in three batches, in reverse and interleaved +/// order, with one entity and one relation repeated across batches. +fn spill_manifest_rows( + spill: &mut GraphGenerationRowSpill, + manifest: &GraphGenerationManifest, +) -> Result<(), GraphDbError> { + let mut entities = manifest.entities.clone(); + entities.reverse(); + let mut relations = manifest.relations.clone(); + relations.reverse(); + let (first_entities, rest_entities) = entities.split_at(120); + let (first_relations, rest_relations) = relations.split_at(200); + spill.push_batch(first_entities.to_vec(), rest_relations.to_vec(), &|| Ok(()))?; + let mut repeated_entities = rest_entities.to_vec(); + repeated_entities.push(first_entities[7].clone()); + spill.push_batch(repeated_entities, Vec::new(), &|| Ok(()))?; + let mut repeated_relations = first_relations.to_vec(); + repeated_relations.push(rest_relations[3].clone()); + spill.push_batch(Vec::new(), repeated_relations, &|| Ok(())) +} + +/// The sealed container's bytes past its headers. The file header and the +/// two checkpoint headers (the first 12,288 bytes) carry the wall-clock time +/// the container was written; every section after them is a pure function of +/// the rows. +fn sealed_container_sections(root: &Path) -> (u64, Vec) { + const CONTAINER_DATA_OFFSET: usize = 3 * 4096; + let entries = std::fs::read_dir(sealed_store_root(root)) + .unwrap() + .map(Result::unwrap) + .filter(|entry| entry.path().join("generation.grafeo").is_file()) + .collect::>(); + assert_eq!(entries.len(), 1, "exactly one sealed generation"); + let bytes = std::fs::read(entries[0].path().join("generation.grafeo")).unwrap(); + (bytes.len() as u64, bytes[CONTAINER_DATA_OFFSET..].to_vec()) +} + +fn row_spills(root: &Path) -> Vec { + std::fs::read_dir(sealed_store_root(root)) + .map(|entries| { + entries + .map(Result::unwrap) + .filter_map(|entry| entry.file_name().to_str().map(str::to_owned)) + .filter(|name| name.starts_with(".rows-")) + .collect() + }) + .unwrap_or_default() +} + +/// Rows pushed through a spill in shuffled batches, repeats included, seal +/// the same recovered digest, receipt, and container sections as the +/// manifest holding the same rows, and the spill leaves no scratch behind. +/// +/// Fails if the merge drops or duplicates a row (the receipt counts and the +/// digest move), if relation endpoints resolve to the wrong sealed node (the +/// container sections differ), or if the spill directory outlives +/// publication. +#[test] +fn spilled_rows_seal_the_same_generation_as_their_manifest() { + let identity = projection("sealed-store:spill", "code"); + let manifest = spill_fixture_manifest(identity.clone()); + + let from_manifest = TempDir::new().unwrap(); + let manifest_graph = RegisteredGraph::new_mounted(from_manifest.path()).unwrap(); + let mut manifest_authority = RelationalAuthority::default(); + let record = stage_sealed_manifest( + &mut manifest_authority, + &manifest_graph.binding, + &manifest, + "publish:spill-g1", + None, + '5', + ); + publish_sealed( + &manifest_graph, + from_manifest.path(), + &mut manifest_authority, + &record, + &manifest, + ); + + let from_spill = TempDir::new().unwrap(); + let spill_graph = RegisteredGraph::new_mounted(from_spill.path()).unwrap(); + let mut spill = spill_graph + .registry + .generation_row_spill( + registration(spill_graph.binding.clone(), from_spill.path()), + identity.clone(), + ) + .unwrap(); + assert_eq!(row_spills(from_spill.path()).len(), 1); + spill_manifest_rows(&mut spill, &manifest).unwrap(); + assert_eq!(spill.distinct_entities(), 300); + let spilled = spill.finish(manifest.identity(), &|| Ok(())).unwrap(); + assert_eq!(spilled.row_counts(), (300, 450)); + assert_eq!( + spilled.expected_recovered_digest(), + &manifest.expected_recovered_digest(&|| Ok(())).unwrap() + ); + let rows = GraphGenerationRows::from(spilled); + let mut spill_authority = RelationalAuthority::default(); + let source = SealedCodeGenerationReplay { + repository: RepositoryId::new("repository.graph-staging-release").unwrap(), + generation: CodeGenerationId::new("code-generation.spill-g1").unwrap(), + sealed_state_digest: SealedGraphStateDigest::try_from(format!("sha256:{}", "5".repeat(64))) + .unwrap(), + projector_revision: GraphProjectorRevision::try_from( + "projector.graph-staging-release".to_owned(), + ) + .unwrap(), + }; + let spill_record = spill_authority.stage( + rows.relational_sealed_replay( + spill_graph.binding.shard_id.clone(), + GraphIdempotencyKey::new("publish:spill-g1").unwrap(), + digest('5'), + None, + source, + &|| Ok(()), + ) + .unwrap(), + ); + assert_eq!( + spill_record.publication.canonical_replay_source, + record.publication.canonical_replay_source + ); + assert_eq!( + spill_record.publication.expected_recovered_digest, + record.publication.expected_recovered_digest + ); + let (control, probe) = control_and_probe(); + let context = GraphPublicationOperationContextV1::new(&control, &probe).unwrap(); + spill_graph + .registry + .publish_verified( + registration(spill_graph.binding.clone(), from_spill.path()), + &mut spill_authority, + &context, + &spill_record.publication.key, + Some(rows), + ) + .unwrap(); + + assert_eq!( + receipt_for_generation(from_spill.path(), "spill-g1"), + receipt_for_generation(from_manifest.path(), "spill-g1"), + ); + assert!( + receipt_for_generation(from_spill.path(), "spill-g1") + .unwrap() + .contains("\"relations\": 450") + ); + assert_eq!( + sealed_container_sections(from_spill.path()), + sealed_container_sections(from_manifest.path()) + ); + assert_eq!(row_spills(from_spill.path()), Vec::::new()); +} + +/// Two rows that share an identity with different content, or a relation +/// whose endpoint no batch pushed, refuse the spilled generation typed, the +/// same verdicts the manifest constructor gives the same rows. +#[test] +fn spilled_rows_refuse_conflicting_repeats_and_dangling_endpoints() { + let temp = TempDir::new().unwrap(); + let registered = RegisteredGraph::new_mounted(temp.path()).unwrap(); + let identity = projection("sealed-store:spill-refusal", "code"); + let manifest = spill_fixture_manifest(identity.clone()); + let new_spill = || { + registered + .registry + .generation_row_spill( + registration(registered.binding.clone(), temp.path()), + identity.clone(), + ) + .unwrap() + }; + + let mut conflicting = new_spill(); + spill_manifest_rows(&mut conflicting, &manifest).unwrap(); + conflicting + .push_batch( + vec![entity("entity:042", "a different payload")], + Vec::new(), + &|| Ok(()), + ) + .unwrap(); + let refused = conflicting + .finish(manifest.identity(), &|| Ok(())) + .unwrap_err(); + assert_eq!( + refused, + GraphDbError::invalid("a graph generation repeats an entity or relation identity") + ); + + let mut dangling = new_spill(); + spill_manifest_rows(&mut dangling, &manifest).unwrap(); + dangling + .push_batch( + Vec::new(), + vec![ + GraphGenerationRelation::new( + GraphRelationId::new("relation:dangling").unwrap(), + GraphEntityRef::new( + identity.clone(), + GraphEntityId::new("entity:000").unwrap(), + ), + GraphEntityRef::new( + identity.clone(), + GraphEntityId::new("entity:999").unwrap(), + ), + GraphRelationKind::new("calls").unwrap(), + BTreeMap::new(), + ) + .unwrap(), + ], + &|| Ok(()), + ) + .unwrap(); + let refused = dangling + .finish(manifest.identity(), &|| Ok(())) + .unwrap_err(); + assert_eq!( + refused, + GraphDbError::invalid( + "local relation endpoint `entity:999` is absent from the candidate generation" + ) + ); + + let mut exact = new_spill(); + spill_manifest_rows(&mut exact, &manifest).unwrap(); + assert_eq!( + exact + .finish(manifest.identity(), &|| Ok(())) + .unwrap() + .row_counts(), + (300, 450) + ); + assert_eq!(row_spills(temp.path()), Vec::::new()); +} + +/// A row spill a killed process left under the sealed root is removed the +/// next time the store opens; a spill named for the opening process is its +/// own live publisher's and stays. +#[test] +fn store_open_sweeps_row_spills_abandoned_by_another_process() { + let temp = TempDir::new().unwrap(); + let root = sealed_store_root(temp.path()); + std::fs::create_dir_all(root.join(".rows-4194305-0")).unwrap(); + std::fs::write(root.join(".rows-4194305-0/entities-0.run"), b"abandoned").unwrap(); + let own = format!(".rows-{}-999999", std::process::id()); + std::fs::create_dir_all(root.join(&own)).unwrap(); + + let _registered = RegisteredGraph::new_mounted(temp.path()).unwrap(); + + assert_eq!(row_spills(temp.path()), vec![own]); +} diff --git a/crates/tracedecay-graph-db/tests/graph_db_suite/verified_generation_contract/staging_footprint.rs b/crates/tracedecay-graph-db/tests/graph_db_suite/verified_generation_contract/staging_footprint.rs index e440a6a7c5..c51969135f 100644 --- a/crates/tracedecay-graph-db/tests/graph_db_suite/verified_generation_contract/staging_footprint.rs +++ b/crates/tracedecay-graph-db/tests/graph_db_suite/verified_generation_contract/staging_footprint.rs @@ -7,9 +7,9 @@ use std::path::{Path, PathBuf}; use std::sync::mpsc; use tracedecay_graph_db::{ - GraphGenerationManifestProvider, GraphTraversalDirection, + GraphGenerationManifestProvider, GraphGenerationRowSpill, GraphTraversalDirection, MAX_NATIVE_GENERATION_STAGE_MUTATIONS, MAX_VERIFIED_GENERATION_BATCH_MUTATIONS, - TraversalRequest, + SpilledGraphGeneration, TraversalRequest, }; use super::*; @@ -235,7 +235,7 @@ fn publish_sealed( authority, &context, &record.publication.key, - Some(Arc::new(manifest.clone())), + Some(Arc::new(manifest.clone()).into()), ) .unwrap() } @@ -346,9 +346,15 @@ impl GraphGenerationManifestProvider for RemountSealedProvider { &self, _owner: &tracedecay_store::GraphProjectionIdentityV1, _source: &SealedCodeGenerationReplay, - _check: &dyn Fn() -> Result<(), GraphDbError>, - ) -> Result { - Ok(self.manifest.clone()) + mut spill: GraphGenerationRowSpill, + check: &dyn Fn() -> Result<(), GraphDbError>, + ) -> Result { + spill.push_batch( + self.manifest.entities.clone(), + self.manifest.relations.clone(), + check, + )?; + spill.finish(self.manifest.identity(), check) } } @@ -1512,7 +1518,7 @@ fn corrupt_sealed_artifact_repair_case(staged_rows_retained: bool) { &mut authority, &context, &record.publication.key, - Some(Arc::new(manifest.clone())), + Some(Arc::new(manifest.clone()).into()), ) .expect("republishing the adopted head must repair the corrupt artifact"); assert_snapshot_reads(&repaired.snapshot, &identity, "repaired"); @@ -1729,7 +1735,7 @@ fn a_cancelled_release_never_leaves_a_row_set_recovery_cannot_serve() { &mut authority, &context, &record.publication.key, - Some(Arc::new(manifest.clone())), + Some(Arc::new(manifest.clone()).into()), ) .unwrap_or_else(|republish| { panic!( @@ -1932,7 +1938,7 @@ fn a_partially_released_generation_is_repaired_from_its_manifest_and_swept() { &mut authority, &context, &record.publication.key, - Some(Arc::new(manifest.clone())), + Some(Arc::new(manifest.clone()).into()), ) .unwrap_or_else(|republish| { panic!( diff --git a/crates/tracedecay-graph-db/tests/graph_db_suite/verified_generation_contract/verify_once.rs b/crates/tracedecay-graph-db/tests/graph_db_suite/verified_generation_contract/verify_once.rs index c6808045fe..eeb37b70ce 100644 --- a/crates/tracedecay-graph-db/tests/graph_db_suite/verified_generation_contract/verify_once.rs +++ b/crates/tracedecay-graph-db/tests/graph_db_suite/verified_generation_contract/verify_once.rs @@ -388,7 +388,7 @@ fn activation_verify_cost_probe() { &mut authority, &context, &key, - Some(Arc::clone(&generation)), + Some(Arc::clone(&generation).into()), ) .unwrap(); let publish_wall = publish_started.elapsed(); diff --git a/crates/tracedecay-store-runtime/src/session_registry/code_graph.rs b/crates/tracedecay-store-runtime/src/session_registry/code_graph.rs index 62fc8a183f..57211d5a72 100644 --- a/crates/tracedecay-store-runtime/src/session_registry/code_graph.rs +++ b/crates/tracedecay-store-runtime/src/session_registry/code_graph.rs @@ -9,10 +9,10 @@ use std::time::{Duration, Instant}; use tracedecay_domain::{CodeGenerationId, RefId, RepositoryId, WorktreeId, canonical_sha256}; use tracedecay_graph_db::{ GraphBudgetKind, GraphCancellation, GraphDbError, GraphDbOwnerAttachmentV1, - GraphDbRegistration, GraphGenerationManifest, GraphGenerationReplaySource, GraphIdempotencyKey, - GraphProjectionIdentity, GraphProjectorRevision, GraphPublicationPreparationV1, - GraphReplayCollectionOutcome, SealedCodeGenerationReplay, VerifiedGraphCommit, - VerifiedGraphSnapshot, + GraphDbRegistration, GraphGenerationManifest, GraphGenerationManifestIdentity, + GraphGenerationReplaySource, GraphGenerationRows, GraphIdempotencyKey, GraphProjectionIdentity, + GraphProjectorRevision, GraphPublicationPreparationV1, GraphReplayCollectionOutcome, + SealedCodeGenerationReplay, VerifiedGraphCommit, VerifiedGraphSnapshot, }; use tracedecay_runtime_core::operation_task_owner::RuntimeOperationTaskOwnerV1; use tracedecay_runtime_core::shard_runtime::registry::{ @@ -696,7 +696,6 @@ fn release_publish_transient_memory() { pub(crate) struct RetainedCodeGraphRuntimeV1 { graph_registry: tracedecay_graph_db::GraphDbRegistry, - graph_manifest_provider: Arc, _manifest_route: super::code_graph_manifest::CodeGraphManifestRouteV1, authority: Arc, project_database: Arc, @@ -712,37 +711,12 @@ pub(crate) struct RetainedCodeGraphRuntimeV1 { /// `DaemonSessionRuntimeRegistryV1::code_graph_publication_gates`. publication_locks: Arc, /// The measured-RSS admission cell sealed publication answers to. Bound - /// from the manifest provider so the decoded offers and the corpus-sized - /// build obey one authority; tests substitute an isolated cell. + /// from the manifest provider so every corpus-sized graph build obeys one + /// authority; tests substitute an isolated cell. resident_memory_pressure: Arc, } -/// Retirement releases the decoded-generation offer this runtime commissioned. -/// -/// The offer exists to spare the activation window a second decode of bytes -/// that stay durable on disk. Once this runtime retires, no consumer can reach -/// that window again, so continuing to retain a whole decoded generation is -/// pure resident cost, and before this nothing removed an offer at all, which -/// is one of the holders that let a 16GiB admission limit sit inside a 42GiB -/// process. Dropping it never loses truth: the canonical seal read remains the -/// authority and reconstructs the same payload. -impl Drop for RetainedCodeGraphRuntimeV1 { - fn drop(&mut self) { - let released_bytes = self - .graph_manifest_provider - .release_decoded_offer(&self.authority.binding().shard_id); - if released_bytes > 0 { - tracing::debug!( - event = "code_graph_decoded_offer_released", - released_bytes, - generation = %self.generation_id.as_str(), - "released the retiring runtime's decoded generation offer" - ); - } - } -} - /// Memory-shard publication runtime for immutable non-code graph journeys. /// /// Code and journey projections share the daemon's sole `GraphDbRegistry` and @@ -1283,7 +1257,8 @@ enum SealedPublicationClassificationV1 { struct PreparedSealedPublicationV1 { projection_deadline: Duration, deadline_at: Instant, - manifest: Arc, + identity: GraphGenerationManifestIdentity, + projector_revision: GraphProjectorRevision, relational_projection: GraphProjectionIdentityV1, source: SealedCodeGenerationReplay, idempotency_key: GraphIdempotencyKey, @@ -1307,24 +1282,25 @@ impl RetainedCodeGraphRuntimeV1 { self } + /// Publishes this runtime's sealed generation as its verified graph head. + /// + /// The graph rows are built from the sealed file segments on disk, one + /// window of files at a time, and only when the durable journal says this + /// publication has not landed yet; a retry or a twin publisher of an + /// already-published generation recovers the head without reading a + /// segment. #[hotpath::measure(label = "daemon.session_registry.publish_snapshot")] pub fn publish_verified_snapshot( &self, - generation: &tracedecay_code_index::production::CodeIndexPublishedGenerationV1, request_cancelled: Arc, ) -> std::result::Result { - if generation.manifest().generation_id != self.generation_id { - return Err(GraphDbError::conflict( - "code_graph.publish_verified_snapshot_with_stage_boundary", - )); - } - // The project-shard build permit is claimed before manifest - // projection and held through publication so 1/2/4/8 worktree scopes - // cannot overlap corpus-sized transients. Same-generation seat and - // reconcile publishers still share one memoized projection once the - // winner finishes; they wait here instead of projecting in parallel. - // The deadline window consequently also spans the build wait; under - // the background budget, cancellation stays the governing mechanism. + // The project-shard build permit is claimed before any row is built + // and held through publication so 1/2/4/8 worktree scopes cannot + // overlap corpus-sized transients. A same-generation twin waits here + // and then recovers the head the winner published instead of + // building the rows again. The deadline window consequently also + // spans the build wait; under the background budget, cancellation + // stays the governing mechanism. let projection_deadline = sealed_projection_deadline(); let deadline_at = Instant::now() + projection_deadline; let graph_generation = tracedecay_code_index::graph_projection::code_graph_generation_id( @@ -1398,29 +1374,12 @@ impl RetainedCodeGraphRuntimeV1 { let projector_revision = GraphProjectorRevision::try_from( tracedecay_code_index::graph_projection::CODE_GRAPH_PROJECTOR_REVISION.to_owned(), )?; - #[cfg(any(test, feature = "test-helpers"))] - { - let overlapping = PUBLICATION_PROJECTION_IN_FLIGHT.fetch_add(1, Ordering::AcqRel) + 1; - PUBLICATION_PROJECTION_OVERLAP_PEAK.fetch_max(overlapping, Ordering::AcqRel); - } - let manifest = - tracedecay_code_index::graph_projection::build_published_code_graph_manifest_checked( - projection.clone(), - generation, - &projector_revision, - &|| match probe.interruption() { - Some(RuntimeInterruptionV1::Cancelled) => Err(GraphDbError::Cancelled), - Some(RuntimeInterruptionV1::DeadlineExceeded) => { - Err(GraphDbError::DeadlineExceeded) - } - None => Ok(()), - }, - ); - #[cfg(any(test, feature = "test-helpers"))] - PUBLICATION_PROJECTION_IN_FLIGHT.fetch_sub(1, Ordering::AcqRel); - let manifest = manifest - .map_err(map_code_graph_error) - .map_err(refuse_if_resident_memory)?; + let identity = tracedecay_code_index::graph_projection::code_graph_manifest_identity( + projection.clone(), + &self.generation_id, + &projector_revision, + ) + .map_err(map_code_graph_error)?; let relational_projection = GraphProjectionIdentityV1 { shard_id: self.authority.binding().shard_id.clone(), namespace: tracedecay_store::GraphNamespaceV1::new(self.authority.namespace().as_str()) @@ -1443,7 +1402,7 @@ impl RetainedCodeGraphRuntimeV1 { .map_err(map_code_graph_error)?; let publication_key = GraphPublicationKeyV1::new( relational_projection.clone(), - GraphGenerationIdV1::new(manifest.generation.as_str()) + GraphGenerationIdV1::new(identity.generation.as_str()) .map_err(|error| GraphDbError::invalid(error.to_string()))?, GraphPublicationIdempotencyKeyV1::new(idempotency_key.as_str()) .map_err(|error| GraphDbError::invalid(error.to_string()))?, @@ -1451,7 +1410,8 @@ impl RetainedCodeGraphRuntimeV1 { let prepared = PreparedSealedPublicationV1 { projection_deadline, deadline_at, - manifest, + identity, + projector_revision, relational_projection, source, idempotency_key, @@ -1469,9 +1429,9 @@ impl RetainedCodeGraphRuntimeV1 { }) .map_err(|error| GraphDbError::unavailable(error.to_string()))? .map_err(refuse_if_resident_memory); - // Everything corpus-sized this publication built, the projection - // manifest, the staged relational rows, the sealed copy buffers, is - // dead by here. Free it, release the duplicate staging rows the seal + // Everything corpus-sized this publication built, the spilled graph + // rows, the staged relational rows, the sealed copy buffers, is dead + // by here. Free it, release the duplicate staging rows the seal // made redundant, and return the emptied arenas to the OS *before* // the build permit goes to the next scope. Deferring any of that past // the permit is what made peak RSS grow with the number of published @@ -1976,13 +1936,51 @@ impl RetainedCodeGraphRuntimeV1 { )?; revalidate_stable_sealed_source(&proof, &self.replay_root, &check) }; + // The generation's graph rows, built from its sealed segments the + // first time an arm needs them and shared by every later use. + let built_rows = std::cell::OnceCell::new(); + let rows = || -> std::result::Result { + if let Some(rows) = built_rows.get() { + return Ok(GraphGenerationRows::clone(rows)); + } + let check = || match probe.interruption() { + Some(RuntimeInterruptionV1::Cancelled) => Err(GraphDbError::Cancelled), + Some(RuntimeInterruptionV1::DeadlineExceeded) => { + Err(GraphDbError::DeadlineExceeded) + } + None => Ok(()), + }; + let spill = self + .graph_registry + .generation_row_spill(registration(), prepared.identity.projection.clone())?; + #[cfg(any(test, feature = "test-helpers"))] + { + let overlapping = + PUBLICATION_PROJECTION_IN_FLIGHT.fetch_add(1, Ordering::AcqRel) + 1; + PUBLICATION_PROJECTION_OVERLAP_PEAK.fetch_max(overlapping, Ordering::AcqRel); + } + let spilled = super::code_graph_manifest::spill_sealed_generation_graph_from_roots( + &self.generations_root, + &self.replay_root, + &self.sealed_state_digest, + &self.generation_id, + prepared.identity.projection.clone(), + &prepared.projector_revision, + spill, + &check, + ); + #[cfg(any(test, feature = "test-helpers"))] + PUBLICATION_PROJECTION_IN_FLIGHT.fetch_sub(1, Ordering::AcqRel); + let rows = GraphGenerationRows::from(spilled?); + Ok(GraphGenerationRows::clone(built_rows.get_or_init(|| rows))) + }; let mut storage = self .project_database .graph_publication_storage() .map_err(|error| GraphDbError::unavailable(error.to_string()))?; let publish = |storage: &mut dyn GraphPublicationStoreV1, key: &GraphPublicationKeyV1, - manifest: Option>| + manifest: Option| -> std::result::Result<_, GraphDbError> { let deadline_at = Instant::now() + prepared.projection_deadline; let cancellation_identity = RuntimeCancellationIdentityV1 { @@ -2126,12 +2124,8 @@ impl RetainedCodeGraphRuntimeV1 { "verified head matched the partitioned manifest but its derived \ Grafeo state was invalid; replaying the canonical generation" ); - return publish( - &mut storage, - &prepared.publication_key, - Some(Arc::clone(&prepared.manifest)), - ) - .map(|publication| publication.snapshot); + return publish(&mut storage, &prepared.publication_key, Some(rows()?)) + .map(|publication| publication.snapshot); } Err(error) => return Err(error), } @@ -2156,12 +2150,8 @@ impl RetainedCodeGraphRuntimeV1 { "verified Grafeo staging state was invalid; replaying the \ canonical partitioned generation" ); - return publish( - &mut storage, - &prepared.publication_key, - Some(Arc::clone(&prepared.manifest)), - ) - .map(|publication| publication.snapshot); + return publish(&mut storage, &prepared.publication_key, Some(rows()?)) + .map(|publication| publication.snapshot); } Err(error) => return Err(error), } @@ -2181,11 +2171,7 @@ impl RetainedCodeGraphRuntimeV1 { drop(replay_pool_lock); match observe_code_graph_publication( CodeGraphPublicationConflictStageV1::ActiveReplayPublish, - publish( - &mut storage, - &prepared.publication_key, - Some(Arc::clone(&prepared.manifest)), - ), + publish(&mut storage, &prepared.publication_key, Some(rows()?)), ) { Ok(publication) => { *staging_release = Some(prepared.relational_projection.clone()); @@ -2223,17 +2209,20 @@ impl RetainedCodeGraphRuntimeV1 { } SealedPublicationClassificationV1::AppendAndPublish => {} } + // The journal binds the rows' digest, so they are built first, before + // the replay-pool lock is taken for the append. + let publication_rows = rows()?; let replay_pool_lock = verify_durable_source()?; let input = canonical_sha256(&( "tracedecay.code-graph-publication-input.v1", &prepared.source, - &prepared.manifest.generation, - &prepared.manifest.source_generation, - &prepared.manifest.watermark, + &prepared.identity.generation, + &prepared.identity.source_generation, + &prepared.identity.watermark, )) .map_err(|error| GraphDbError::invalid(error.to_string()))?; let build_replay = |prior: Option| { - prepared.manifest.relational_sealed_replay( + publication_rows.relational_sealed_replay( self.authority.binding().shard_id.clone(), prepared.idempotency_key.clone(), GraphPublicationInputDigestV1::new(input.as_str()) @@ -2382,11 +2371,7 @@ impl RetainedCodeGraphRuntimeV1 { drop(replay_pool_lock); let publication = observe_code_graph_publication( CodeGraphPublicationConflictStageV1::FinalPublish, - publish( - &mut storage, - &replay.key, - Some(Arc::clone(&prepared.manifest)), - ), + publish(&mut storage, &replay.key, Some(rows()?)), )?; *staging_release = Some(prepared.relational_projection.clone()); Ok(publication.snapshot) @@ -2513,14 +2498,6 @@ impl DaemonSessionRuntimeRegistryV1 { generation_id: CodeGenerationId, project_database: Arc, replay_binding: CodeGraphReplayBindingV1, - // The generation the code index just decoded to serve queries, when the - // caller has one. Offering it to the manifest provider is what makes - // cold activation parse the sealed payload once instead of twice - // (plan 40, stage 1). `None` simply leaves the provider reading the - // canonical seal exactly as before. - decoded_generation: Option< - Arc, - >, ) -> Result { let project_shard = StoreShardIdV1::project( self.identity.brain_id().clone(), @@ -2596,23 +2573,6 @@ impl DaemonSessionRuntimeRegistryV1 { .map_err(|error| { session_registry_error("bind code graph replay route", error.to_string()) })?; - // Offer the already-decoded seal before any publication or recovery can - // reach the manifest provider. The offer is keyed by the exact shard the - // provider resolves bindings under, and is only ever served on an exact - // generation-and-digest match, so a stale offer cannot displace the - // canonical seal. - if let Some(decoded_generation) = decoded_generation { - self.graph_manifest_provider - .offer_decoded_code_generation( - authority.binding().shard_id.clone(), - generation_id.clone(), - replay_binding.sealed_state_digest.clone(), - decoded_generation, - ) - .map_err(|error| { - session_registry_error("offer decoded code generation", error.to_string()) - })?; - } let operation_runtime = tokio::runtime::Handle::try_current().map_err(|error| { session_registry_error("retain code graph operation runtime", error.to_string()) })?; @@ -2622,7 +2582,6 @@ impl DaemonSessionRuntimeRegistryV1 { resident_memory_pressure: Arc::clone( self.graph_manifest_provider.resident_memory_pressure(), ), - graph_manifest_provider: Arc::clone(&self.graph_manifest_provider), _manifest_route: manifest_route, authority, project_database, @@ -2979,13 +2938,12 @@ impl CodeGraphSeatLeaseV1 for RetainedCodeGraphRuntimeV1 { fn publish_verified_snapshot( &self, - generation: &tracedecay_code_index::production::CodeIndexPublishedGenerationV1, request_cancelled: Arc, ) -> std::result::Result< tracedecay_graph_db::VerifiedGraphSnapshot, tracedecay_graph_db::GraphDbError, > { - Self::publish_verified_snapshot(self, generation, request_cancelled) + Self::publish_verified_snapshot(self, request_cancelled) } fn recover_verified_snapshot_from_head( @@ -3019,9 +2977,6 @@ impl CodeGraphSeatRuntimePortV1 for DaemonSessionRuntimeRegistryV1 { generation_id: CodeGenerationId, project_database: Arc, replay_binding: CodeGraphReplayBindingV1, - decoded_generation: Option< - Arc, - >, ) -> std::pin::Pin< Box< dyn std::future::Future>> @@ -3039,7 +2994,6 @@ impl CodeGraphSeatRuntimePortV1 for DaemonSessionRuntimeRegistryV1 { generation_id, project_database, replay_binding, - decoded_generation, ) .await?; Ok(Box::new(retained) as Box) diff --git a/crates/tracedecay-store-runtime/src/session_registry/code_graph/sealed_publication_tests.rs b/crates/tracedecay-store-runtime/src/session_registry/code_graph/sealed_publication_tests.rs index 3aa922df40..4920c85c4b 100644 --- a/crates/tracedecay-store-runtime/src/session_registry/code_graph/sealed_publication_tests.rs +++ b/crates/tracedecay-store-runtime/src/session_registry/code_graph/sealed_publication_tests.rs @@ -107,14 +107,27 @@ fn with_publication_context( operation(&context) } -fn publication_replay( +/// A fresh row spill for a test build, removed with the spill. +fn test_row_spill( + projection: tracedecay_graph_db::GraphProjectionIdentity, +) -> tracedecay_graph_db::GraphGenerationRowSpill { + static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + tracedecay_graph_db::GraphGenerationRowSpill::create( + std::env::temp_dir().join(format!( + "tracedecay-publication-spill-{}-{}", + std::process::id(), + NEXT.fetch_add(1, Ordering::Relaxed) + )), + projection, + ) + .expect("test row spill") +} + +/// The relational key this runtime's sealed generation publishes under, +/// derived from its identity alone. +fn publication_key( runtime: &RetainedCodeGraphRuntimeV1, - generation: &tracedecay_code_index::production::CodeIndexPublishedGenerationV1, -) -> ( - GraphProjectionIdentityV1, - GraphPublicationKeyV1, - tracedecay_store::GraphPublicationReplayV1, -) { +) -> (GraphProjectionIdentityV1, GraphPublicationKeyV1) { let projector_revision = GraphProjectorRevision::try_from( tracedecay_code_index::graph_projection::CODE_GRAPH_PROJECTOR_REVISION.to_owned(), ) @@ -123,14 +136,6 @@ fn publication_replay( runtime.authority.namespace().clone(), ) .expect("code graph projection"); - let manifest = - tracedecay_code_index::graph_projection::build_published_code_graph_manifest_checked( - projection.clone(), - generation, - &projector_revision, - &|| Ok(()), - ) - .expect("published graph manifest"); let relational_projection = GraphProjectionIdentityV1 { shard_id: runtime.authority.binding().shard_id.clone(), namespace: tracedecay_store::GraphNamespaceV1::new(runtime.authority.namespace().as_str()) @@ -138,6 +143,11 @@ fn publication_replay( projection: GraphProjectionIdV1::new(projection.projection.as_str()) .expect("relational projection"), }; + let generation = tracedecay_code_index::graph_projection::code_graph_generation_id( + &runtime.generation_id, + &projector_revision, + ) + .expect("code graph generation"); let idempotency_key = tracedecay_code_index::graph_projection::code_graph_idempotency_key( &runtime.generation_id, &projector_revision, @@ -145,10 +155,50 @@ fn publication_replay( .expect("publication idempotency key"); let publication_key = GraphPublicationKeyV1::new( relational_projection.clone(), - GraphGenerationIdV1::new(manifest.generation.as_str()).expect("relational generation"), + GraphGenerationIdV1::new(generation.as_str()).expect("relational generation"), GraphPublicationIdempotencyKeyV1::new(idempotency_key.as_str()) .expect("relational idempotency key"), ); + (relational_projection, publication_key) +} + +/// The journaled replay this runtime's publication appends, built from the +/// sealed segments on disk the way the publisher builds it. +fn publication_replay( + runtime: &RetainedCodeGraphRuntimeV1, +) -> ( + GraphProjectionIdentityV1, + GraphPublicationKeyV1, + tracedecay_store::GraphPublicationReplayV1, +) { + let projector_revision = GraphProjectorRevision::try_from( + tracedecay_code_index::graph_projection::CODE_GRAPH_PROJECTOR_REVISION.to_owned(), + ) + .expect("projector revision"); + let projection = tracedecay_code_index::graph_projection::code_graph_projection_identity( + runtime.authority.namespace().clone(), + ) + .expect("code graph projection"); + let rows = tracedecay_graph_db::GraphGenerationRows::from( + super::super::code_graph_manifest::spill_sealed_generation_graph_from_roots( + &runtime.generations_root, + &runtime.replay_root, + &runtime.sealed_state_digest, + &runtime.generation_id, + projection.clone(), + &projector_revision, + test_row_spill(projection), + &|| Ok(()), + ) + .expect("sealed graph rows"), + ); + let identity = rows.identity(); + let (relational_projection, publication_key) = publication_key(runtime); + let idempotency_key = tracedecay_code_index::graph_projection::code_graph_idempotency_key( + &runtime.generation_id, + &projector_revision, + ) + .expect("publication idempotency key"); let source = SealedCodeGenerationReplay { repository: runtime.repository_id.clone(), generation: runtime.generation_id.clone(), @@ -158,12 +208,12 @@ fn publication_replay( let input = canonical_sha256(&( "tracedecay.code-graph-publication-input.v1", &source, - &manifest.generation, - &manifest.source_generation, - &manifest.watermark, + &identity.generation, + &identity.source_generation, + &identity.watermark, )) .expect("publication input digest"); - let replay = manifest + let replay = rows .relational_sealed_replay( runtime.authority.binding().shard_id.clone(), idempotency_key, @@ -178,10 +228,9 @@ fn publication_replay( fn assert_unverified_publication_state( runtime: &RetainedCodeGraphRuntimeV1, - generation: &tracedecay_code_index::production::CodeIndexPublishedGenerationV1, expected_replay: bool, ) { - let (projection, key, _) = publication_replay(runtime, generation); + let (projection, key) = publication_key(runtime); with_publication_context("inspect-sealed-publication", |context| { let mut storage = runtime .project_database @@ -242,11 +291,8 @@ fn resident_memory_trip_is_permanent_for_one_publication_attempt() { ); } -fn journal_publication_without_head( - runtime: &RetainedCodeGraphRuntimeV1, - generation: &tracedecay_code_index::production::CodeIndexPublishedGenerationV1, -) { - let (_, _, replay) = publication_replay(runtime, generation); +fn journal_publication_without_head(runtime: &RetainedCodeGraphRuntimeV1) { + let (_, _, replay) = publication_replay(runtime); with_publication_context("journal-sealed-publication", |context| { let mut storage = runtime .project_database @@ -344,7 +390,6 @@ async fn unreadable_pending_replay_is_discarded_before_fresh_publication() { sealed_state_digest: SealedGraphStateDigest::try_from(pointer.state_digest) .expect("fresh sealed state digest"), }, - None, ) .await .expect("retain fresh code graph runtime"); @@ -376,7 +421,7 @@ async fn unreadable_pending_replay_is_discarded_before_fresh_publication() { } let historical_repository = RepositoryId::new("repository.production").expect("historical repository id"); - let _historical_route = runtime + let _historical_route = registry .graph_manifest_provider .bind( runtime.authority.binding().shard_id.clone(), @@ -387,7 +432,7 @@ async fn unreadable_pending_replay_is_discarded_before_fresh_publication() { ) .expect("bind historical generation source"); - let (_, fresh_key, fresh_replay) = publication_replay(&runtime, latest.generation()); + let (_, fresh_key, fresh_replay) = publication_replay(&runtime); let historical_generation = CodeGenerationId::new( "generation.v1.d7eb9547.00000002.c221a7303ac5f89c1b1a553f26217136fda771a17cc1578d4cb232ef7a5f32c2", ) @@ -430,11 +475,17 @@ async fn unreadable_pending_replay_is_discarded_before_fresh_publication() { // so the current reader refuses it at the revision gate, before the row // evidence it also predates, and before the source-commitment check. The // code-index suite pins the same refusal against these bytes. - let refused = runtime + let refused = registry .graph_manifest_provider .hydrate_sealed_code_generation( &fresh_key.projection, &historical_sealed_source, + test_row_spill( + tracedecay_code_index::graph_projection::code_graph_projection_identity( + runtime.authority.namespace().clone(), + ) + .expect("code graph projection"), + ), &|| Ok(()), ) .expect_err("historical seal must remain unavailable to current readers"); @@ -473,7 +524,7 @@ async fn unreadable_pending_replay_is_discarded_before_fresh_publication() { }); let snapshot = runtime - .publish_verified_snapshot(latest.generation(), Arc::new(AtomicBool::new(false))) + .publish_verified_snapshot(Arc::new(AtomicBool::new(false))) .expect("fresh publication discards the permanently incompatible predecessor"); assert_eq!(snapshot.verified_head().key, fresh_key); with_publication_context("inspect-historical-pending-replay", |context| { @@ -602,27 +653,26 @@ async fn sealed_generation_publishes_and_republishes_without_eager_replay_payloa // No decoded-seal offer: this suite asserts the on-disk seal // verification contract, so every read must reach the canonical // root. - None, ) .await .expect("retain code graph runtime"); std::fs::write(&canonical_seal, &mutated_seal).expect("mutate sealed generation in place"); assert!(matches!( - runtime.publish_verified_snapshot(latest.generation(), Arc::new(AtomicBool::new(false))), + runtime.publish_verified_snapshot(Arc::new(AtomicBool::new(false))), Err(GraphDbError::Corrupt { .. }) )); - assert_unverified_publication_state(&runtime, latest.generation(), false); + assert_unverified_publication_state(&runtime, false); std::fs::write(&canonical_seal, &intact_seal).expect("restore sealed generation bytes"); let retained_seal = canonical_seal.with_extension("retained-test-evidence"); std::fs::rename(&canonical_seal, &retained_seal).expect("retain original sealed inode"); std::fs::write(&canonical_seal, &mutated_seal).expect("replace canonical sealed inode"); assert!(matches!( - runtime.publish_verified_snapshot(latest.generation(), Arc::new(AtomicBool::new(false))), + runtime.publish_verified_snapshot(Arc::new(AtomicBool::new(false))), Err(GraphDbError::Corrupt { .. }) )); - assert_unverified_publication_state(&runtime, latest.generation(), false); + assert_unverified_publication_state(&runtime, false); std::fs::remove_file(&canonical_seal).expect("remove replacement sealed inode"); std::fs::rename(&retained_seal, &canonical_seal).expect("restore original sealed inode"); @@ -633,24 +683,23 @@ async fn sealed_generation_publishes_and_republishes_without_eager_replay_payloa std::fs::rename(&canonical_seal, &retained_seal).expect("retain symlink target evidence"); symlink(&retained_seal, &canonical_seal).expect("swap canonical seal for symlink"); assert!(matches!( - runtime - .publish_verified_snapshot(latest.generation(), Arc::new(AtomicBool::new(false))), + runtime.publish_verified_snapshot(Arc::new(AtomicBool::new(false))), Err(GraphDbError::Corrupt { .. }) )); - assert_unverified_publication_state(&runtime, latest.generation(), false); + assert_unverified_publication_state(&runtime, false); std::fs::remove_file(&canonical_seal).expect("remove sealed generation symlink"); std::fs::rename(&retained_seal, &canonical_seal) .expect("restore sealed generation after symlink refusal"); } - journal_publication_without_head(&runtime, latest.generation()); + journal_publication_without_head(&runtime); std::fs::write(&canonical_seal, &mutated_seal) .expect("mutate source before active replay completion"); assert!(matches!( - runtime.publish_verified_snapshot(latest.generation(), Arc::new(AtomicBool::new(false))), + runtime.publish_verified_snapshot(Arc::new(AtomicBool::new(false))), Err(GraphDbError::Corrupt { .. }) )); - assert_unverified_publication_state(&runtime, latest.generation(), true); + assert_unverified_publication_state(&runtime, true); std::fs::write(&canonical_seal, &intact_seal) .expect("restore source before active replay completion"); @@ -661,7 +710,7 @@ async fn sealed_generation_publishes_and_republishes_without_eager_replay_payloa // manufactured stage-boundary `DeadlineExceeded`, no scheduler retry // pass, no conflict against its own journal. let snapshot = runtime - .publish_verified_snapshot(latest.generation(), Arc::new(AtomicBool::new(false))) + .publish_verified_snapshot(Arc::new(AtomicBool::new(false))) .expect("first activation must resume the journaled replay and publish in one call"); let expected_generation = tracedecay_code_index::graph_projection::code_graph_generation_id( &generation_id, @@ -714,12 +763,11 @@ async fn sealed_generation_publishes_and_republishes_without_eager_replay_payloa // No decoded-seal offer: this suite asserts the on-disk seal // verification contract, so every read must reach the canonical // root. - None, ) .await .expect("retain code graph runtime again"); let resumed = retried - .publish_verified_snapshot(latest.generation(), Arc::new(AtomicBool::new(false))) + .publish_verified_snapshot(Arc::new(AtomicBool::new(false))) .expect("a repeated activation must resume the exact publication"); assert_eq!(resumed.generation(), &expected_generation); assert_eq!(resumed.verified_head(), &head); @@ -792,12 +840,11 @@ async fn sealed_generation_publishes_and_republishes_without_eager_replay_payloa next.generation().manifest().generation_id.clone(), project_database, next_binding, - None, ) .await .expect("retain next code graph runtime"); let next_snapshot = next_runtime - .publish_verified_snapshot(next.generation(), Arc::new(AtomicBool::new(false))) + .publish_verified_snapshot(Arc::new(AtomicBool::new(false))) .expect("a fresh small projection must publish in one call"); assert_eq!( next_snapshot.generation().as_str(), @@ -821,7 +868,7 @@ async fn sealed_generation_publishes_and_republishes_without_eager_replay_payloa let next_head = next_snapshot.verified_head().clone(); drop(next_snapshot); let manifest_provider: Arc = - next_runtime.graph_manifest_provider.clone(); + registry.graph_manifest_provider.clone(); let cold_graph_registry = tracedecay_graph_db::GraphDbRegistry::new_with_manifest_provider( tracedecay_graph_db::GraphDbRegistryConfig { max_open: 1 }, manifest_provider, @@ -878,7 +925,6 @@ struct SealedGenerationFixture { project_database: Arc, registry: DaemonSessionRuntimeRegistryV1, project_id: ProjectId, - latest: tracedecay_code_index_runtime::code_index_scheduler::LatestCompleteCodeIndexV1, generation_id: CodeGenerationId, scoped_store: PathBuf, generations_root: PathBuf, @@ -971,7 +1017,6 @@ async fn sealed_generation_fixture(project: &str, source: &str) -> SealedGenerat generations_root: generations_root.clone(), sealed_state_digest, }, - None, ) .await .expect("retain code graph runtime"); @@ -980,7 +1025,6 @@ async fn sealed_generation_fixture(project: &str, source: &str) -> SealedGenerat project_database, registry, project_id, - latest, generation_id, scoped_store, generations_root, @@ -1006,10 +1050,7 @@ async fn sealing_keeps_symbol_records_only_in_the_graph_store() { .await; let snapshot = fixture .runtime - .publish_verified_snapshot( - fixture.latest.generation(), - Arc::new(AtomicBool::new(false)), - ) + .publish_verified_snapshot(Arc::new(AtomicBool::new(false))) .expect("seal the code graph"); let file_names = |root: &Path| { @@ -1109,10 +1150,7 @@ async fn graph_reads_during_engine_warm_up_are_typed_pending_and_warmed_reads_su .await; let snapshot = fixture .runtime - .publish_verified_snapshot( - fixture.latest.generation(), - Arc::new(AtomicBool::new(false)), - ) + .publish_verified_snapshot(Arc::new(AtomicBool::new(false))) .expect("seal the code graph"); let store = CodeGraphProjectionStore::from_verified_snapshot(snapshot, fixture.generation_id.clone()) @@ -1189,10 +1227,7 @@ async fn a_released_serving_engine_closes_and_rewarms_on_the_next_read() { .await; let snapshot = fixture .runtime - .publish_verified_snapshot( - fixture.latest.generation(), - Arc::new(AtomicBool::new(false)), - ) + .publish_verified_snapshot(Arc::new(AtomicBool::new(false))) .expect("seal the code graph"); let store = CodeGraphProjectionStore::from_verified_snapshot(snapshot, fixture.generation_id.clone()) @@ -1250,19 +1285,17 @@ async fn a_released_serving_engine_closes_and_rewarms_on_the_next_read() { ); } -/// Stage 1 of `docs/plans/tracedecay-v2/40`: cold activation decodes the sealed -/// payload once to serve queries, and graph hydration reuses that decode -/// instead of reading and parsing the identical bytes a second time. -/// -/// The assertion is falsifiable by construction rather than by timing: BOTH -/// seal roots handed to the provider are empty, so hydration can only succeed -/// by consuming the offered decode. The first probe proves the roots really are -/// unreadable, and the last probe proves a foreign sealed digest is never -/// answered from the offer. +/// A pending predecessor owns the projector revision its durable replay +/// recorded, even after the current reader advanced. The provider rebuilds +/// that exact historical generation's rows from the seal on disk, and the +/// rebuilt rows bind the digests the predecessor journaled, which is what +/// lets an interrupted predecessor finish before the current publication +/// appends. A foreign sealed digest is refused, and nothing is served once +/// the seal is gone. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn offered_decode_hydrates_without_reading_the_sealed_payload_again() { +async fn historical_predecessor_rows_rebuild_from_the_seal_at_their_journaled_revision() { use tracedecay_graph_db::{ - GraphGenerationManifest, GraphGenerationManifestProvider, GraphNamespace, + GraphGenerationManifestProvider, GraphGenerationRows, GraphNamespace, SealedGraphStateDigest, }; use tracedecay_store::{ @@ -1286,12 +1319,12 @@ async fn offered_decode_hydrates_without_reading_the_sealed_payload_again() { ); std::fs::write( project_root.join("src/lib.rs"), - "pub fn offered_decode_value() -> usize { 7 }\n", + "pub fn predecessor_value() -> usize { 7 }\n", ) .expect("project source"); git(&project_root, &["add", "."]); - git(&project_root, &["commit", "-qm", "offered decode fixture"]); - let project_id = ProjectId::new("project.offered-decode").expect("project id"); + git(&project_root, &["commit", "-qm", "predecessor fixture"]); + let project_id = ProjectId::new("project.predecessor-rows").expect("project id"); tracedecay_runtime_core::storage::pin_fixture_repository_identity( &project_root, project_id.as_str(), @@ -1299,8 +1332,6 @@ async fn offered_decode_hydrates_without_reading_the_sealed_payload_again() { .expect("project enrollment"); let canonical_project = project_root.canonicalize().expect("canonical project root"); - // Seal one real generation through the production worktree scheduler, then - // take the exact handle the code index would serve queries from. let store_root = root.join("code-index-store"); let scoped_store = scoped_code_index_store_root(&store_root, &canonical_project); let mut scheduler = CodeIndexWorktreeSchedulerV1::open( @@ -1312,11 +1343,10 @@ async fn offered_decode_hydrates_without_reading_the_sealed_payload_again() { .expect("open worktree scheduler"); scheduler.reconcile_now().expect("seal the generation"); let latest = scheduler.latest_complete().expect("complete generation"); - let decoded = latest.generation_handle(); - let generation_id = decoded.manifest().generation_id.clone(); - let repository_id = decoded.snapshot().repository.clone(); + let generation_id = latest.generation().manifest().generation_id.clone(); + let repository_id = latest.generation().snapshot().repository.clone(); + drop(latest); drop(scheduler); - let pointer: DurablePublicationPointerV1 = serde_json::from_slice( &std::fs::read(scoped_store.join("active-code-generation-v1.json")) .expect("active generation pointer"), @@ -1324,31 +1354,26 @@ async fn offered_decode_hydrates_without_reading_the_sealed_payload_again() { .expect("decode active generation pointer"); let sealed_state_digest = SealedGraphStateDigest::try_from(pointer.state_digest.clone()) .expect("sealed state digest"); - - // Deliberately empty: the provider has nothing it could read from either the - // canonical root or the replay pool. - let absent_generations_root = root.join("absent-generations"); - let absent_replay_root = root.join("absent-replay"); - std::fs::create_dir_all(&absent_generations_root).expect("absent generations root"); - std::fs::create_dir_all(&absent_replay_root).expect("absent replay root"); + let generations_root = scoped_store.join("code-generations-v1"); + let replay_root = root.join("replay-pool"); + std::fs::create_dir_all(&replay_root).expect("replay root"); let shard = StoreShardIdV1::project( - BrainId::new("brain.offered-decode").expect("brain id"), - UserProfileId::new("profile.offered-decode").expect("profile id"), + BrainId::new("brain.predecessor-rows").expect("brain id"), + UserProfileId::new("profile.predecessor-rows").expect("profile id"), project_id.clone(), ); let provider = Arc::new(DaemonCodeGraphManifestProviderV1::default()); let _route = provider .bind( shard.clone(), - project_id.clone(), + project_id, repository_id.clone(), - absent_generations_root, - absent_replay_root, + generations_root.clone(), + replay_root, ) .expect("bind code generation source"); - - let namespace = GraphNamespace::new("namespace.offered-decode").expect("graph namespace"); + let namespace = GraphNamespace::new("namespace.predecessor-rows").expect("graph namespace"); let projection = tracedecay_code_index::graph_projection::code_graph_projection_identity(namespace.clone()) .expect("code graph projection"); @@ -1358,55 +1383,34 @@ async fn offered_decode_hydrates_without_reading_the_sealed_payload_again() { projection: GraphProjectionIdV1::new(projection.projection.as_str()) .expect("relational projection"), }; - let source = SealedCodeGenerationReplay { - repository: repository_id.clone(), - generation: generation_id.clone(), - sealed_state_digest: sealed_state_digest.clone(), - projector_revision: GraphProjectorRevision::try_from( - tracedecay_code_index::graph_projection::CODE_GRAPH_PROJECTOR_REVISION.to_owned(), - ) - .expect("projector revision"), - }; - - // The seal really is unreadable from both roots, so any success below is - // evidence that the offered decode was consumed. - provider - .hydrate_sealed_code_generation(&owner, &source, &|| Ok(())) - .expect_err("hydration must fail while no seal is readable and nothing is offered"); - - provider - .offer_decoded_code_generation( - shard.clone(), - generation_id.clone(), - sealed_state_digest.clone(), - Arc::clone(&decoded), - ) - .expect("offer the decoded generation"); - let manifest = provider - .hydrate_sealed_code_generation(&owner, &source, &|| Ok(())) - .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 { + repository: repository_id, + generation: generation_id.clone(), + sealed_state_digest, projector_revision: legacy_revision.clone(), - ..source.clone() }; - let legacy_manifest = - tracedecay_code_index::graph_projection::build_published_code_graph_manifest_checked( - projection, - &decoded, + + let journaled = GraphGenerationRows::from( + provider + .hydrate_sealed_code_generation( + &owner, + &legacy_source, + test_row_spill(projection.clone()), + &|| Ok(()), + ) + .expect("the predecessor's rows rebuild from its seal"), + ); + assert_eq!( + journaled.identity().generation, + tracedecay_code_index::graph_projection::code_graph_generation_id( + &generation_id, &legacy_revision, - &|| Ok(()), ) - .expect("build the predecessor manifest"); - let legacy_replay = legacy_manifest + .expect("predecessor graph generation") + ); + let legacy_replay = journaled .relational_sealed_replay( shard, tracedecay_code_index::graph_projection::code_graph_idempotency_key( @@ -1417,82 +1421,46 @@ async fn offered_decode_hydrates_without_reading_the_sealed_payload_again() { GraphPublicationInputDigestV1::new(format!("sha256:{}", "c".repeat(64))) .expect("predecessor input digest"), None, - legacy_source, + legacy_source.clone(), &|| Ok(()), ) .expect("predecessor relational replay"); - let reconstructed = - GraphGenerationManifest::from_replay(&legacy_replay, provider.as_ref(), &|| Ok(())) - .expect("the exact historical predecessor must hydrate and verify"); - assert_eq!(reconstructed, *legacy_manifest); + let rebuilt = provider + .hydrate_sealed_code_generation( + &owner, + &legacy_source, + test_row_spill(projection.clone()), + &|| Ok(()), + ) + .expect("the predecessor's rows rebuild again"); + assert_eq!( + rebuilt.expected_recovered_digest(), + &legacy_replay.expected_recovered_digest + ); + assert_eq!(rebuilt.row_counts(), journaled.row_counts()); - // 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))) .expect("foreign sealed digest"), - ..source.clone() + ..legacy_source.clone() }; provider - .hydrate_sealed_code_generation(&owner, &foreign, &|| Ok(())) - .expect_err("a foreign sealed digest must never be served from the offer"); - - // Both consumers above were served from the one offer, which is exactly why - // the offer is not taken on first read. Its lifetime bound is retirement. - assert_eq!( - provider.retained_decoded_offer_count(), - 1, - "the offer survives its consumers so the predecessor path can reuse it" - ); - let census_bytes = provider.retained_decoded_offer_bytes(); - assert!( - census_bytes > 0, - "a retained offer reports the sealed source census it holds" - ); - - // Retirement releases it. Before this, nothing removed an offer at all. - let retirement_shard = owner.shard_id.clone(); - assert_eq!( - provider.release_decoded_offer(&retirement_shard), - census_bytes - ); - assert_eq!(provider.retained_decoded_offer_count(), 0); - assert_eq!(provider.retained_decoded_offer_bytes(), 0); - provider - .hydrate_sealed_code_generation(&owner, &source, &|| Ok(())) - .expect_err("a released offer falls back to the canonical seal, which is unreadable here"); - - // Pressure backstop, driven by an injected measured-RSS series on an - // isolated cell: no `/proc` read, and no interference with other cases. - let pressure = std::sync::Arc::new( - tracedecay_runtime_core::resident_memory::ResidentMemoryPressureV1::new( - std::num::NonZeroU64::new(1024 * 1024 * 1024).expect("nonzero pressure limit"), - ), - ); - let pressured = DaemonCodeGraphManifestProviderV1::with_pressure(&pressure); - pressured - .offer_decoded_code_generation( - retirement_shard.clone(), - generation_id.clone(), - sealed_state_digest.clone(), - Arc::clone(&decoded), + .hydrate_sealed_code_generation( + &owner, + &foreign, + test_row_spill(projection.clone()), + &|| Ok(()), ) - .expect("offer the decoded generation to the pressured provider"); - assert_eq!(pressured.retained_decoded_offer_count(), 1); - - pressure.publish_observed_resident_bytes(pressure.low_watermark_bytes()); - assert_eq!( - pressured.retained_decoded_offer_count(), - 1, - "nominal measured RSS keeps the accelerator" - ); + .expect_err("a foreign sealed digest is never served"); - pressure.publish_observed_resident_bytes(pressure.high_watermark_bytes() + 1); - assert_eq!( - pressured.retained_decoded_offer_count(), - 0, - "measured RSS over the high watermark drops the retained decode" - ); - assert_eq!(pressured.retained_decoded_offer_bytes(), 0); + let digest = sha256_hex_suffix(&pointer.state_digest).expect("sha256 digest"); + std::fs::remove_file(generations_root.join(format!("generation-{digest}.json"))) + .expect("remove the seal"); + provider + .hydrate_sealed_code_generation(&owner, &legacy_source, test_row_spill(projection), &|| { + Ok(()) + }) + .expect_err("a removed seal rebuilds nothing"); } /// The per-shard publication gate is one shared cell across retained runtime @@ -1594,7 +1562,6 @@ async fn concurrent_sealed_publishers_share_one_gate_and_converge_on_one_head() generation_id.clone(), Arc::clone(&project_database), replay_binding(), - None, ) .await .expect("retain the seat-pass code graph runtime"); @@ -1607,7 +1574,6 @@ async fn concurrent_sealed_publishers_share_one_gate_and_converge_on_one_head() generation_id, project_database, replay_binding(), - None, ) .await .expect("retain the reconcile code graph runtime"); @@ -1631,27 +1597,22 @@ async fn concurrent_sealed_publishers_share_one_gate_and_converge_on_one_head() .lock() .expect("hold the publication gate"); let outcome = std::thread::scope(|scope| { - let worker = scope.spawn(|| { - reconcile.publish_verified_snapshot(latest.generation(), Arc::clone(&cancelled)) - }); + let worker = scope.spawn(|| reconcile.publish_verified_snapshot(Arc::clone(&cancelled))); cancelled.store(true, Ordering::Release); drop(held); worker.join().expect("join the cancelled publisher") }); assert!(matches!(outcome, Err(GraphDbError::Cancelled))); - assert_unverified_publication_state(&reconcile, latest.generation(), false); + assert_unverified_publication_state(&reconcile, false); // The seat pass and the background reconcile publish the same sealed // generation concurrently: the loser waits out the winner, then resumes // the winner's exact publication instead of conflicting. let (seat_outcome, reconcile_outcome) = std::thread::scope(|scope| { - let seat_worker = scope.spawn(|| { - seat.publish_verified_snapshot(latest.generation(), Arc::new(AtomicBool::new(false))) - }); - let reconcile_worker = scope.spawn(|| { - reconcile - .publish_verified_snapshot(latest.generation(), Arc::new(AtomicBool::new(false))) - }); + let seat_worker = + scope.spawn(|| seat.publish_verified_snapshot(Arc::new(AtomicBool::new(false)))); + let reconcile_worker = + scope.spawn(|| reconcile.publish_verified_snapshot(Arc::new(AtomicBool::new(false)))); ( seat_worker.join().expect("join the seat publisher"), reconcile_worker @@ -1670,7 +1631,7 @@ async fn concurrent_sealed_publishers_share_one_gate_and_converge_on_one_head() // The verified head advanced exactly once and the journal retains exactly // the winner's active replay: the loser recovered the published head // rather than appending a duplicate or double-advancing the head. - let (projection, key, _) = publication_replay(&seat, latest.generation()); + let (projection, key) = publication_key(&seat); with_publication_context("inspect-converged-publication", |context| { let mut storage = seat .project_database @@ -1797,7 +1758,6 @@ async fn sealed_publication_refuses_over_the_resident_memory_watermark() { generation_id, project_database, replay_binding, - None, ) .await .expect("retain the code graph runtime") @@ -1816,9 +1776,8 @@ async fn sealed_publication_refuses_over_the_resident_memory_watermark() { let _ = take_publication_projection_overlap_peak(); let not_cancelled = Arc::new(AtomicBool::new(false)); let refused = std::thread::scope(|scope| { - let publisher = scope.spawn(|| { - runtime.publish_verified_snapshot(latest.generation(), Arc::clone(¬_cancelled)) - }); + let publisher = + scope.spawn(|| runtime.publish_verified_snapshot(Arc::clone(¬_cancelled))); let projection_deadline = Instant::now() + Duration::from_secs(10); loop { let build_claimed = runtime.publication_locks.build.try_lock().is_err(); @@ -1854,7 +1813,7 @@ async fn sealed_publication_refuses_over_the_resident_memory_watermark() { ); // The refusal is the same abort path a request cancellation takes: no // journal append, no verified head, nothing a retry has to repair. - assert_unverified_publication_state(&runtime, latest.generation(), false); + assert_unverified_publication_state(&runtime, false); // The scheduler-facing classification names this budget as a graph // refusal that keeps text serving, never a retryable activation fault. @@ -1875,7 +1834,7 @@ async fn sealed_publication_refuses_over_the_resident_memory_watermark() { // keeps its own identity: the caller cancelled, so it is told so. let cancelled = Arc::new(AtomicBool::new(true)); assert!(matches!( - runtime.publish_verified_snapshot(latest.generation(), cancelled), + runtime.publish_verified_snapshot(cancelled), Err(GraphDbError::Cancelled) )); @@ -1883,7 +1842,7 @@ async fn sealed_publication_refuses_over_the_resident_memory_watermark() { // it just refused; the refusal poisoned nothing. pressure.publish_observed_resident_bytes(pressure.low_watermark_bytes()); let published = runtime - .publish_verified_snapshot(latest.generation(), Arc::new(AtomicBool::new(false))) + .publish_verified_snapshot(Arc::new(AtomicBool::new(false))) .expect("nominal measured RSS publishes the sealed generation"); let projector_revision = GraphProjectorRevision::try_from( tracedecay_code_index::graph_projection::CODE_GRAPH_PROJECTOR_REVISION.to_owned(), @@ -1899,7 +1858,7 @@ async fn sealed_publication_refuses_over_the_resident_memory_watermark() { expected_generation.as_str(), "the refused generation publishes unchanged once memory is nominal" ); - let (projection, key, _) = publication_replay(&runtime, latest.generation()); + let (projection, key) = publication_key(&runtime); with_publication_context("inspect-refused-then-published", |context| { let mut storage = runtime .project_database @@ -2062,7 +2021,6 @@ async fn worktree_scopes_share_one_project_publication_build_permit() { generation_id.clone(), Arc::clone(&project_database), replay_binding(), - None, ) .await .expect("retain a worktree-scoped code graph runtime"), @@ -2094,7 +2052,6 @@ async fn worktree_scopes_share_one_project_publication_build_permit() { struct PublicationMeasurementScopeV1 { canonical_root: PathBuf, - generation: Arc, generation_id: CodeGenerationId, repository_id: RepositoryId, reference: Option, @@ -2288,11 +2245,11 @@ async fn concurrent_worktree_scopes_publish_with_one_corpus_build_and_bounded_rs let latest = scheduler .latest_complete() .expect("complete publication scope generation"); - let generation = latest.generation_handle(); - let generation_id = generation.manifest().generation_id.clone(); - let repository_id = generation.snapshot().repository.clone(); - let reference = generation.snapshot().reference.clone(); + let generation_id = latest.generation().manifest().generation_id.clone(); + let repository_id = latest.generation().snapshot().repository.clone(); + let reference = latest.generation().snapshot().reference.clone(); let worktree_id = scheduler.identity().worktree_id().clone(); + drop(latest); drop(scheduler); let pointer: DurablePublicationPointerV1 = serde_json::from_slice( &std::fs::read(scoped_store.join("active-code-generation-v1.json")) @@ -2304,7 +2261,6 @@ async fn concurrent_worktree_scopes_publish_with_one_corpus_build_and_bounded_rs sha256_hex_suffix(&pointer.state_digest).expect("sha256 publication scope digest"); measurement_scopes.push(PublicationMeasurementScopeV1 { canonical_root, - generation, generation_id, repository_id, reference, @@ -2408,7 +2364,6 @@ async fn concurrent_worktree_scopes_publish_with_one_corpus_build_and_bounded_rs generations_root: publication_generations_root.clone(), sealed_state_digest: scope.sealed_state_digest.clone(), }, - Some(Arc::clone(&scope.generation)), ) .await .expect("retain publication scope runtime"), @@ -2449,17 +2404,12 @@ async fn concurrent_worktree_scopes_publish_with_one_corpus_build_and_bounded_rs let wall_started = Instant::now(); let outcomes = std::thread::scope(|thread_scope| { let mut workers = Vec::with_capacity(scope_count); - for (scope_index, (runtime, measurement_scope)) in - runtimes.iter().zip(&measurement_scopes).enumerate() - { + for (scope_index, runtime) in runtimes.iter().enumerate() { let worker_barrier = &barrier; workers.push(thread_scope.spawn(move || { worker_barrier.wait(); let started = Instant::now(); - let result = runtime.publish_verified_snapshot( - &measurement_scope.generation, - Arc::new(AtomicBool::new(false)), - ); + let result = runtime.publish_verified_snapshot(Arc::new(AtomicBool::new(false))); let elapsed_ms = u64::try_from(started.elapsed().as_millis()).expect("publish milliseconds"); (scope_index, result, elapsed_ms) @@ -2640,7 +2590,6 @@ fn off_thread_staging_release_retains_its_permit_and_leases_until_terminal_drain generation_id, project_database, replay_binding, - None, ) .await .expect("retain real code graph runtime"); diff --git a/crates/tracedecay-store-runtime/src/session_registry/code_graph_manifest.rs b/crates/tracedecay-store-runtime/src/session_registry/code_graph_manifest.rs index 22e6d8092b..ce76aedada 100644 --- a/crates/tracedecay-store-runtime/src/session_registry/code_graph_manifest.rs +++ b/crates/tracedecay-store-runtime/src/session_registry/code_graph_manifest.rs @@ -5,8 +5,12 @@ use std::path::PathBuf; use std::sync::{Arc, RwLock}; use sha2::{Digest, Sha256}; -use tracedecay_code_index::graph_projection::CodeGraphProjectionError; -use tracedecay_code_index::production::CodeIndexProductionErrorV1; +use tracedecay_code_index::graph_projection::{ + CodeGraphProjectionError, SealedCodeGraphRowsError, build_sealed_code_graph_rows, +}; +use tracedecay_code_index::production::{ + CodeIndexProductionErrorV1, SealedGenerationFileWindowsV1, SealedGenerationSegmentReadV1, +}; use tracedecay_code_index_retention::code_index_generations::{ CodeGenerationStoreLockV1, GRAPH_REPLAY_POOL_ACQUIRE_POLL, code_generation_segments_root, try_acquire_code_generation_store_lock, @@ -14,13 +18,11 @@ use tracedecay_code_index_retention::code_index_generations::{ use tracedecay_domain::canonical_text::encode_lowercase_hex; use tracedecay_domain::{ManifestDigest, ProjectId, RepositoryId, sha256_hex_suffix}; use tracedecay_graph_db::{ - GraphBudgetKind, GraphDbError, GraphGenerationManifest, GraphGenerationManifestProvider, + GraphBudgetKind, GraphDbError, GraphGenerationManifestProvider, GraphGenerationRowSpill, GraphNamespace, GraphProjectionId, GraphProjectionIdentity, GraphProjectorRevision, - SealedCodeGenerationReplay, SealedGraphStateDigest, -}; -use tracedecay_runtime_core::resident_memory::{ - ResidentMemoryPressureRegistrationV1, ResidentMemoryPressureV1, + SealedCodeGenerationReplay, SealedGraphStateDigest, SpilledGraphGeneration, }; +use tracedecay_runtime_core::resident_memory::ResidentMemoryPressureV1; use tracedecay_store::{GraphProjectionIdentityV1, StoreShardIdV1}; const SEAL_READ_CHECK_BYTES: usize = 64 * 1024; @@ -97,31 +99,6 @@ fn same_file_identity(left: &std::fs::Metadata, right: &std::fs::Metadata) -> bo } } -fn same_unlinked_file_identity(left: &std::fs::Metadata, right: &std::fs::Metadata) -> bool { - #[cfg(unix)] - { - use std::os::unix::fs::MetadataExt; - - left.dev() == right.dev() - && left.ino() == right.ino() - && left.len() == right.len() - && left.mtime() == right.mtime() - && left.mtime_nsec() == right.mtime_nsec() - } - #[cfg(windows)] - { - use std::os::windows::fs::MetadataExt; - - left.file_size() == right.file_size() - && left.last_write_time() == right.last_write_time() - && left.creation_time() == right.creation_time() - } - #[cfg(not(any(unix, windows)))] - { - false - } -} - /// Confirms the opened handle and the path still denote the same file, via /// the stable GetFileInformationByHandle authority instead of the unstable /// `windows_by_handle` metadata surface. @@ -372,32 +349,18 @@ fn acquire_generation_bundle_lock( } } -fn decode_verified_seal( +/// Reads the partitioned manifest at `path` and proves it is the seal named +/// by `expected_digest`. The lock proves the pathname is live while the +/// bytes are read; it drops with the returned manifest in hand, because the +/// segments it names are content-addressed and every segment read verifies +/// its own address. +#[hotpath::measure(label = "daemon.session_registry.seal.read_manifest")] +fn read_verified_seal_manifest( path: &std::path::Path, - segments_root: &std::path::Path, expected_digest: &str, check: &dyn Fn() -> Result<(), GraphDbError>, lifetime_lock: CodeGenerationStoreLockV1, -) -> Result { - decode_verified_seal_with_bundle_barrier( - path, - &[segments_root.to_path_buf()], - expected_digest, - check, - lifetime_lock, - || {}, - ) -} - -#[hotpath::measure(label = "daemon.session_registry.seal.decode")] -fn decode_verified_seal_with_bundle_barrier( - path: &std::path::Path, - segment_roots: &[PathBuf], - expected_digest: &str, - check: &dyn Fn() -> Result<(), GraphDbError>, - lifetime_lock: CodeGenerationStoreLockV1, - bundle_barrier: impl FnOnce(), -) -> Result { +) -> Result, GraphDbError> { (check)()?; let path_metadata = path.symlink_metadata().map_err(|error| { GraphDbError::unavailable(format!( @@ -424,126 +387,143 @@ fn decode_verified_seal_with_bundle_barrier( message: "sealed code generation identity changed while it was opened".to_owned(), }); } - let expected_digest = - ManifestDigest::new(format!("sha256:{expected_digest}")).map_err(|error| { - GraphDbError::Corrupt { - message: format!( - "sealed code generation filename digest is not canonical: {error}" - ), - } - })?; - (check)()?; #[cfg(feature = "hotpath")] hotpath::gauge!("session_registry.seal.decode.bytes_total").inc(admitted_len); - let mut lifetime_lock = Some(lifetime_lock); - let generation = { - let mut manifest = Vec::new(); - file.by_ref() - .take(admitted_len) - .read_to_end(&mut manifest) - .map_err(|error| GraphDbError::Corrupt { - message: format!("sealed generation manifest read failed: {error}"), - })?; - if encode_lowercase_hex(&Sha256::digest(&manifest)) - != expected_digest - .hex_suffix() - .unwrap_or(expected_digest.as_str()) - { - return Err(GraphDbError::Corrupt { - message: "sealed generation manifest filename digest does not match its bytes" - .to_owned(), - }); - } - let mut pinned_evidence = None; - let mut bundle_barrier = Some(bundle_barrier); - let mut interruption = None; - let decoded = tracedecay_code_index::production::CodeIndexPublishedGenerationV1::decode_partitioned_sealed( - &manifest, - |request, buffer| { - if let Err(error) = (check)() { - if matches!(error, GraphDbError::Cancelled | GraphDbError::DeadlineExceeded) { - interruption = Some(error.clone()); - } - return Err( - tracedecay_code_index::production::CodeIndexProductionErrorV1::Contract( - error.to_string(), - ), - ); - } - match request { - tracedecay_code_index::production::SealedGenerationSegmentReadV1::Whole { - .. - } => read_partitioned_segment(select_partitioned_segment_root(segment_roots, request)?, request, buffer), - tracedecay_code_index::production::SealedGenerationSegmentReadV1::Range { - .. - } => { - if pinned_evidence.is_none() { - pinned_evidence = Some(open_partitioned_segment( - select_partitioned_segment_root(segment_roots, request)?, - request, - )?); - // The manifest/pool lock proves the pack pathname is live - // through this open. From here the file handle owns the - // evidence lifetime, so retention may unlink both names. - drop(lifetime_lock.take()); - if let Some(barrier) = bundle_barrier.take() { - barrier(); - } - } - read_pinned_partitioned_segment( - pinned_evidence.as_mut().ok_or_else(|| { - tracedecay_code_index::production::CodeIndexProductionErrorV1::Contract( - "sealed generation evidence handle was not pinned".to_owned(), - ) - })?, - request, - buffer, - ) - } - } - }, - ); - if let Some(interruption) = interruption { - return Err(interruption); - } - decoded.map_err(|error| classify_sealed_generation_decode_error(error, &expected_digest))? - }; - (check)()?; - let final_file_metadata = file.metadata().map_err(|error| GraphDbError::Corrupt { - message: format!("sealed code generation metadata cannot be revalidated: {error}"), - })?; - let manifest_handle_unchanged = if lifetime_lock.is_some() { - same_file_identity(&opened_metadata, &final_file_metadata) - } else { - same_unlinked_file_identity(&opened_metadata, &final_file_metadata) - }; - if !manifest_handle_unchanged { + let mut manifest = Vec::new(); + file.by_ref() + .take(admitted_len) + .read_to_end(&mut manifest) + .map_err(|error| GraphDbError::Corrupt { + message: format!("sealed generation manifest read failed: {error}"), + })?; + if u64::try_from(manifest.len()).ok() != Some(admitted_len) + || encode_lowercase_hex(&Sha256::digest(&manifest)) != expected_digest + { return Err(GraphDbError::Corrupt { - message: "sealed code generation identity or length changed while it was read" + message: "sealed generation manifest filename digest does not match its bytes" .to_owned(), }); } - if lifetime_lock.is_some() { - let final_path_metadata = - path.symlink_metadata() - .map_err(|error| GraphDbError::Corrupt { - message: format!("sealed code generation path cannot be revalidated: {error}"), - })?; - if !same_file_identity(&opened_metadata, &final_path_metadata) { - return Err(GraphDbError::Corrupt { - message: "sealed code generation identity or length changed while it was read" - .to_owned(), - }); + drop(lifetime_lock); + (check)()?; + Ok(manifest) +} + +/// Builds the code graph of the authenticated seal `manifest` into `spill`, +/// streaming its file segments from `segment_roots` one window at a time. +#[hotpath::measure(label = "daemon.session_registry.seal.spill_graph")] +fn spill_verified_seal_graph( + source: &SealedGenerationFileWindowsV1, + sealed_state_digest: &ManifestDigest, + segment_roots: &[PathBuf], + projection: GraphProjectionIdentity, + projector_revision: &GraphProjectorRevision, + spill: GraphGenerationRowSpill, + check: &dyn Fn() -> Result<(), GraphDbError>, +) -> Result { + let mut interruption = None; + let mut read_segment = |request: SealedGenerationSegmentReadV1<'_>, + buffer: &mut Vec| + -> Result<(), CodeIndexProductionErrorV1> { + if let Err(error) = (check)() { + if matches!( + error, + GraphDbError::Cancelled | GraphDbError::DeadlineExceeded + ) { + interruption = Some(error.clone()); + } + return Err(CodeIndexProductionErrorV1::Contract(error.to_string())); } + read_partitioned_segment( + select_partitioned_segment_root(segment_roots, request)?, + request, + buffer, + ) + }; + let built = build_sealed_code_graph_rows( + projection, + source, + &mut read_segment, + projector_revision, + spill, + check, + ); + if let Some(interruption) = interruption { + return Err(interruption); } - #[cfg(windows)] - if lifetime_lock.is_some() && !same_windows_handle_identity(&file, path)? { - return Err(GraphDbError::Corrupt { - message: "sealed code generation identity or length changed while it was read" - .to_owned(), - }); + built.map_err(|error| match error { + SealedCodeGraphRowsError::Source(error) => { + classify_sealed_generation_decode_error(error, sealed_state_digest) + } + SealedCodeGraphRowsError::Projection(error) => { + classify_sealed_projection_build_error(error) + } + }) +} + +/// Authenticates a seal's partitioned manifest for a streaming graph build. +fn open_verified_seal( + manifest: &[u8], + expected_digest: &str, +) -> Result<(SealedGenerationFileWindowsV1, ManifestDigest), GraphDbError> { + let sealed_state_digest = + ManifestDigest::new(format!("sha256:{expected_digest}")).map_err(|error| { + GraphDbError::Corrupt { + message: format!( + "sealed code generation filename digest is not canonical: {error}" + ), + } + })?; + let source = SealedGenerationFileWindowsV1::open(manifest) + .map_err(|error| classify_sealed_generation_decode_error(error, &sealed_state_digest))?; + Ok((source, sealed_state_digest)) +} + +/// Builds the code graph of the seal `sealed_state_digest` names, read from +/// the canonical generations root or, once retention moved it, the replay +/// pool, into `spill`. The seal must hold `generation`. +#[allow(clippy::too_many_arguments)] +pub(super) fn spill_sealed_generation_graph_from_roots( + generations_root: &std::path::Path, + replay_root: &std::path::Path, + sealed_state_digest: &SealedGraphStateDigest, + generation: &tracedecay_domain::CodeGenerationId, + projection: GraphProjectionIdentity, + projector_revision: &GraphProjectorRevision, + spill: GraphGenerationRowSpill, + check: &dyn Fn() -> Result<(), GraphDbError>, +) -> Result { + let digest = sha256_hex_suffix(sealed_state_digest.as_str()) + .ok_or_else(|| GraphDbError::invalid("sealed state digest is not sha256"))?; + let seal_file = format!("generation-{digest}.json"); + let segments_root = code_generation_segments_root( + generations_root + .parent() + .ok_or_else(|| GraphDbError::invalid("generation root has no store parent"))?, + ); + let manifest = with_verified_seal_from_roots( + &generations_root.join(&seal_file), + &replay_root.join(&seal_file), + digest, + check, + read_verified_seal_manifest, + )?; + let (source, sealed_state_digest) = open_verified_seal(&manifest, digest)?; + drop(manifest); + if source.generation_id() != generation { + return Err(GraphDbError::conflict( + "code_graph_manifest.spill_sealed_generation_graph", + )); } - Ok(generation) + spill_verified_seal_graph( + &source, + &sealed_state_digest, + &[segments_root], + projection, + projector_revision, + spill, + check, + ) } struct PinnedPartitionedSegmentV1 { @@ -932,274 +912,16 @@ impl Drop for CodeGraphManifestRouteV1 { } if binding.routes.is_empty() { sources.remove(&self.shard); - self.provider.decoded.release_shard(&self.shard); } } } } -/// One already-decoded sealed generation, offered by the code-index -/// activation path or retained from this provider's own verified disk decode, -/// addressed by the exact identity that authorizes it. -/// -/// The producing side decoded these bytes only after verifying that their -/// SHA-256 equals `sealed_state_digest`, so an entry that matches a replay's -/// `generation` *and* `sealed_state_digest` denotes the same immutable payload -/// the canonical seal file holds. Matching on the digest, never on the -/// generation id alone, is what keeps a superseded or foreign decode from -/// being served in place of the requested seal. -#[derive(Clone)] -struct DecodedSealedCodeGenerationV1 { - generation: tracedecay_domain::CodeGenerationId, - sealed_state_digest: SealedGraphStateDigest, - decoded: Arc, - /// Sealed source-byte census of the decode this offer retains. A checked - /// fact from the generation itself, used only to report retained offer - /// bytes; a census that cannot be computed reports zero rather than - /// refusing the offer, because the offer is an accelerator and the census - /// is telemetry. - source_total_bytes: u64, -} - -impl DecodedSealedCodeGenerationV1 { - /// Census the decode as it is retained, so the byte accounting a release - /// reports is fixed at retention time rather than recomputed from a - /// payload that may already be gone. - fn retained( - generation: tracedecay_domain::CodeGenerationId, - sealed_state_digest: SealedGraphStateDigest, - decoded: Arc, - ) -> Self { - let source_total_bytes = decoded - .generation_statistics() - .map_or(0, |statistics| statistics.source_total_bytes); - Self { - generation, - sealed_state_digest, - decoded, - source_total_bytes, - } - } -} - -/// The decodes one shard may reuse instead of re-reading its sealed payload: -/// the decode offered by the activating code index (plan 40, stage 1) and the -/// provider's own most recent digest-verified disk decode. Both are pure -/// accelerators matched on the exact generation AND sealed-state digest; a -/// miss always falls through to the canonical-then-pool disk read, and the -/// durable-source verification in -/// [`verify_sealed_generation_source_from_roots`] never consults them. -/// -/// Both slots are bounded the same two ways. Supersession bounds them inside a -/// shard: a fresh activation offer drops the hydration it replaces. Release -/// bounds them across the daemon: the retirement of the commissioning runtime -/// and the resident-memory pressure backstop each drop the whole shard entry. -#[derive(Default)] -struct ShardDecodedSealsV1 { - offered: Option, - hydrated: Option, -} - -impl ShardDecodedSealsV1 { - /// The decode for this exact replay identity held in either slot. - fn matching( - &self, - generation: &tracedecay_domain::CodeGenerationId, - sealed_state_digest: &SealedGraphStateDigest, - ) -> Option> { - self.retained() - .find(|candidate| { - candidate.generation == *generation - && candidate.sealed_state_digest == *sealed_state_digest - }) - .map(|candidate| Arc::clone(&candidate.decoded)) - } - - fn retained(&self) -> impl Iterator { - [self.offered.as_ref(), self.hydrated.as_ref()] - .into_iter() - .flatten() - } - - fn retained_decodes(&self) -> usize { - self.retained().count() - } - - fn retained_bytes(&self) -> u64 { - self.retained().fold(0_u64, |total, retained| { - total.saturating_add(retained.source_total_bytes) - }) - } -} - -/// The retained decoded seals, owned separately from the provider so a -/// resident-memory pressure reclaimer can hold a `Weak` to exactly this state -/// and nothing else. -/// -/// Every retained slot holds a whole decoded generation. Until release landed, -/// nothing ever removed one: a decode stayed live for the lifetime of the -/// daemon's session registry, invisible to the resident-memory admission -/// authority, which is one of the unaccounted holders behind a 16GiB limit -/// sitting inside a 42GiB process. -#[derive(Default)] -pub(super) struct DecodedCodeGenerationOffersV1 { - seals: RwLock>, -} - -impl DecodedCodeGenerationOffersV1 { - /// Record the decode the activating code index offered for this shard. - /// - /// A fresh activation offer supersedes whatever this provider retained - /// from an older hydration; dropping that hydration bounds decode - /// retention to the seals still in play for the shard. - fn offer( - &self, - project_shard: StoreShardIdV1, - offered: DecodedSealedCodeGenerationV1, - ) -> Result<(), GraphDbError> { - let mut seals = self.write()?; - let slot = seals.entry(project_shard).or_default(); - slot.offered = Some(offered); - slot.hydrated = None; - Self::publish_retained_gauge(&seals); - Ok(()) - } - - /// Record the digest-verified decode this provider just paid a full disk - /// pass for, so a repeated hydration of the same replay reuses it instead - /// of reading and parsing the sealed payload a second time. - fn retain_hydrated( - &self, - project_shard: StoreShardIdV1, - hydrated: DecodedSealedCodeGenerationV1, - ) -> Result<(), GraphDbError> { - let mut seals = self.write()?; - seals.entry(project_shard).or_default().hydrated = Some(hydrated); - Self::publish_retained_gauge(&seals); - Ok(()) - } - - /// The retained decode for this exact replay identity, if one is held. - /// - /// Deliberately not take-on-read. One activation has two legitimate - /// consumers of the same decode, the current-revision publication and the - /// interrupted-predecessor recovery that rebuilds a historical manifest at - /// its own projector revision, so consuming on first read would force the - /// second to re-read and re-parse exactly the bytes this decode exists to - /// spare. The lifetime bound is supersession and release, not first read. - fn matching( - &self, - project_shard: &StoreShardIdV1, - generation: &tracedecay_domain::CodeGenerationId, - sealed_state_digest: &SealedGraphStateDigest, - ) -> Result< - Option>, - GraphDbError, - > { - let seals = self.seals.read().map_err(|_| { - GraphDbError::unavailable("code generation manifest provider lock is poisoned") - })?; - Ok(seals - .get(project_shard) - .and_then(|slot| slot.matching(generation, sealed_state_digest))) - } - - /// Drop one shard's retained decodes at retirement and report the census - /// bytes released. - /// - /// This is the primary retention fix. A retained decode is an - /// activation-scoped accelerator over bytes that stay durable on disk; - /// once the runtime that commissioned it retires, nothing can consume it - /// again, so holding whole decoded generations past that point is pure - /// resident cost. Before this, nothing removed them at all. Both slots go - /// together: the hydration was retained to serve the same activation - /// window as the offer. - fn release_shard(&self, project_shard: &StoreShardIdV1) -> u64 { - let Ok(mut seals) = self.write() else { - return 0; - }; - let released_bytes = seals - .remove(project_shard) - .map_or(0, |slot| slot.retained_bytes()); - Self::publish_retained_gauge(&seals); - released_bytes - } - - /// Drop every retained decode and report the census bytes released. - /// - /// The pressure backstop. Dropping a retained decode never loses truth: - /// the sealed payload stays on disk and the canonical read reconstructs - /// it, so this costs one re-decode and never revokes work that is already - /// admitted. - fn release_all(&self) -> u64 { - let Ok(mut seals) = self.write() else { - return 0; - }; - let released_bytes = Self::retained_bytes_of(&seals); - seals.clear(); - Self::publish_retained_gauge(&seals); - released_bytes - } - - #[cfg(test)] - fn retained_offer_count(&self) -> usize { - self.write() - .map_or(0, |seals| Self::retained_decodes_of(&seals)) - } - - #[cfg(test)] - fn retained_bytes(&self) -> u64 { - self.write() - .map_or(0, |seals| Self::retained_bytes_of(&seals)) - } - - fn write( - &self, - ) -> Result< - std::sync::RwLockWriteGuard<'_, BTreeMap>, - GraphDbError, - > { - self.seals.write().map_err(|_| { - GraphDbError::unavailable("code generation manifest provider lock is poisoned") - }) - } - - fn retained_decodes_of(seals: &BTreeMap) -> usize { - seals - .values() - .map(ShardDecodedSealsV1::retained_decodes) - .sum() - } - - fn retained_bytes_of(seals: &BTreeMap) -> u64 { - seals.values().fold(0_u64, |total, slot| { - total.saturating_add(slot.retained_bytes()) - }) - } - - fn publish_retained_gauge(seals: &BTreeMap) { - hotpath::gauge!("daemon.memory.decoded_offers_bytes") - .set(Self::retained_bytes_of(seals) as f64); - hotpath::gauge!("daemon.memory.decoded_offers") - .set(Self::retained_decodes_of(seals) as f64); - } -} - pub(super) struct DaemonCodeGraphManifestProviderV1 { sources: RwLock>, - /// Per-shard decoded seals, the activation offer (plan 40, stage 1) and - /// this provider's own last verified disk decode, so graph publication - /// and the recovery branches reuse an already-verified decode instead of - /// re-reading and re-parsing the same sealed payload. Held behind an - /// `Arc` so the pressure reclaimer can reach exactly this state through a - /// `Weak` without keeping the provider alive. - decoded: Arc, - /// The measured-RSS cell this provider's offers answer to. Sealed - /// publication consults the same cell so the one admission authority - /// governs both the retained accelerators and the corpus-sized build. + /// The measured-RSS cell sealed publication answers to, so the one + /// admission authority governs the corpus-sized build. pressure: Arc, - /// Keeps the pressure reclaimer registered for this provider's lifetime. - _pressure_registration: Option, } impl Default for DaemonCodeGraphManifestProviderV1 { @@ -1211,29 +933,15 @@ impl Default for DaemonCodeGraphManifestProviderV1 { } impl DaemonCodeGraphManifestProviderV1 { - /// Bind the offer store to a measured-RSS pressure cell. + /// Bind the provider to a measured-RSS pressure cell. /// /// Production passes the process cell fed by the daemon's `VmRSS` sampler. - /// Tests pass an isolated cell so a fake RSS series drives the backstop + /// Tests pass an isolated cell so a fake RSS series drives the refusal /// without touching `/proc` or other cases. pub(super) fn with_pressure(pressure: &Arc) -> Self { - let decoded = Arc::new(DecodedCodeGenerationOffersV1::default()); - let reclaim_target = Arc::downgrade(&decoded); - let registration = pressure - .register_pressure_reclaimer( - DECODED_OFFER_PRESSURE_PRIORITY_V1, - Arc::new(move |_request| { - reclaim_target - .upgrade() - .map_or(0, |offers| offers.release_all()) - }), - ) - .ok(); Self { sources: RwLock::new(BTreeMap::new()), - decoded, pressure: Arc::clone(pressure), - _pressure_registration: registration, } } @@ -1241,13 +949,7 @@ impl DaemonCodeGraphManifestProviderV1 { pub(super) fn resident_memory_pressure(&self) -> &Arc { &self.pressure } -} -/// Decoded offers release before anything a query is actively serving from: -/// they are pure accelerators over bytes that remain on disk. -const DECODED_OFFER_PRESSURE_PRIORITY_V1: u32 = 10; - -impl DaemonCodeGraphManifestProviderV1 { pub(super) fn bind( self: &Arc, project_shard: StoreShardIdV1, @@ -1288,102 +990,6 @@ impl DaemonCodeGraphManifestProviderV1 { route, }) } - - /// Offer the sealed generation this shard just decoded for query serving. - /// - /// Cold activation decodes the sealed payload once to serve queries; without - /// this offer the graph publication and recovery branches decode the very - /// same bytes a second time through [`decode_verified_seal`]. - /// The offer is a pure accelerator: it is consulted only on an exact - /// generation-and-digest match, and every miss falls through to the - /// canonical-then-pool read that remains the authority. - /// - /// The offer is released when the runtime that commissioned it retires, - /// and dropped early under measured memory pressure, so a shard that is - /// offered a decode nobody ever claims does not retain a whole generation - /// for the daemon's lifetime. - pub(super) fn offer_decoded_code_generation( - &self, - project_shard: StoreShardIdV1, - generation: tracedecay_domain::CodeGenerationId, - sealed_state_digest: SealedGraphStateDigest, - decoded: Arc, - ) -> Result<(), GraphDbError> { - // The offer supersedes any hydration this provider retained for the - // shard, and is censused as it lands so release can report the bytes - // it frees. - self.decoded.offer( - project_shard, - DecodedSealedCodeGenerationV1::retained(generation, sealed_state_digest, decoded), - ) - } - - /// An already-verified decode for this exact replay, the activation - /// offer or the provider's own last disk decode, or `None` to read from - /// disk. - /// - /// `None` is an abstention, never a verdict: it means "not already decoded - /// here", and the caller must still resolve the seal from the canonical - /// root or the replay pool. - fn reusable_decode( - &self, - owner: &GraphProjectionIdentityV1, - source: &SealedCodeGenerationReplay, - ) -> Result< - Option>, - GraphDbError, - > { - self.decoded.matching( - &owner.shard_id, - &source.generation, - &source.sealed_state_digest, - ) - } - - /// Retain the digest-verified decode this provider just paid a full disk - /// pass for, so a repeated hydration of the same replay (verified-snapshot - /// recovery, pending-predecessor completion retries) reuses it instead of - /// reading and parsing the sealed payload a second time. - fn retain_hydrated_decode( - &self, - project_shard: StoreShardIdV1, - source: &SealedCodeGenerationReplay, - decoded: Arc, - ) -> Result<(), GraphDbError> { - // Retirement must either follow this cache insertion and clear it, or - // win first and leave no runtime-owned decode behind. - let sources = self.sources.read().map_err(|_| { - GraphDbError::unavailable("code generation manifest provider lock is poisoned") - })?; - if !sources.contains_key(&project_shard) { - return Ok(()); - } - self.decoded.retain_hydrated( - project_shard, - DecodedSealedCodeGenerationV1::retained( - source.generation.clone(), - source.sealed_state_digest.clone(), - decoded, - ), - ) - } - - /// Release the decoded seals this shard's retiring runtime commissioned, - /// the activation offer and any hydration retained alongside it, - /// reporting the census bytes released. - pub(super) fn release_decoded_offer(&self, project_shard: &StoreShardIdV1) -> u64 { - self.decoded.release_shard(project_shard) - } - - #[cfg(test)] - pub(super) fn retained_decoded_offer_count(&self) -> usize { - self.decoded.retained_offer_count() - } - - #[cfg(test)] - pub(super) fn retained_decoded_offer_bytes(&self) -> u64 { - self.decoded.retained_bytes() - } } impl GraphGenerationManifestProvider for DaemonCodeGraphManifestProviderV1 { @@ -1391,8 +997,9 @@ impl GraphGenerationManifestProvider for DaemonCodeGraphManifestProviderV1 { &self, owner: &GraphProjectionIdentityV1, source: &SealedCodeGenerationReplay, + spill: GraphGenerationRowSpill, check: &dyn Fn() -> Result<(), GraphDbError>, - ) -> Result { + ) -> Result { check()?; let binding = self .sources @@ -1429,145 +1036,107 @@ impl GraphGenerationManifestProvider for DaemonCodeGraphManifestProviderV1 { "code_graph_manifest.hydrate_sealed_code_generation", )); } - - // Reuse a decode whose SHA-256 was already proven equal to this - // replay's sealed-state digest, the one the activating code index - // offered (plan 40, stage 1) or the provider's own last verified disk - // decode. The reuse is matched on the exact generation AND sealed - // state digest, and the identity guards below still run against it, so - // the only difference from the disk path is that the identical bytes - // are not read and parsed a second time. - let reused = self.reusable_decode(owner, source)?; - let decoded_from_disk = reused.is_none(); - let generation = match reused { - Some(already_decoded) => { - #[cfg(feature = "hotpath")] - hotpath::gauge!("session_registry.seal.decode.reused_total").inc(1_u64); - already_decoded - } - None => { - let digest = sha256_hex_suffix(source.sealed_state_digest.as_str()) - .ok_or_else(|| GraphDbError::invalid("sealed state digest is not sha256"))?; - let seal_file = format!("generation-{digest}.json"); - let mut decoded = None; - let mut canonical_error = None; - let segment_roots = binding - .routes - .keys() - .filter(|route| route.repository == source.repository) - .map(|route| { - route - .generations_root - .parent() - .map(code_generation_segments_root) - .ok_or_else(|| { - GraphDbError::invalid( - "canonical generation root has no store parent", - ) - }) - }) - .collect::, _>>()?; - for route in binding - .routes - .keys() - .filter(|route| route.repository == source.repository) - { - check()?; - let store_root = route.generations_root.parent().ok_or_else(|| { + let digest = sha256_hex_suffix(source.sealed_state_digest.as_str()) + .ok_or_else(|| GraphDbError::invalid("sealed state digest is not sha256"))?; + let seal_file = format!("generation-{digest}.json"); + let routes = binding + .routes + .keys() + .filter(|route| route.repository == source.repository) + .collect::>(); + let segment_roots = routes + .iter() + .map(|route| { + route + .generations_root + .parent() + .map(code_generation_segments_root) + .ok_or_else(|| { GraphDbError::invalid("canonical generation root has no store parent") - })?; - let canonical = route.generations_root.join(&seal_file); - // Absence abstains before lock acquisition. Presence is only a - // prefilter: the decoder revalidates identity under the lock. - if !seal_is_present(&canonical)? { - continue; - } - let lock = acquire_generation_bundle_lock(store_root, check)?; - if !seal_is_present(&canonical)? { - // Retention may move the seal while this reader waits. - // The single replay-pool probe below resolves that move. - drop(lock); - continue; - } - match decode_verified_seal( - &canonical, - &code_generation_segments_root(store_root), - digest, - check, - lock, - ) { - Ok(generation) => decoded = Some(generation), - Err(error @ (GraphDbError::Cancelled | GraphDbError::DeadlineExceeded)) => { - return Err(error); - } - Err(error) => canonical_error = Some(error), - } - break; + }) + }) + .collect::, _>>()?; + let mut manifest = None; + let mut canonical_error = None; + for route in &routes { + check()?; + let store_root = route.generations_root.parent().ok_or_else(|| { + GraphDbError::invalid("canonical generation root has no store parent") + })?; + let canonical = route.generations_root.join(&seal_file); + // Absence abstains before lock acquisition. Presence is only a + // prefilter: the reader revalidates identity under the lock. + if !seal_is_present(&canonical)? { + continue; + } + let lock = acquire_generation_bundle_lock(store_root, check)?; + if !seal_is_present(&canonical)? { + // Retention may move the seal while this reader waits. + // The single replay-pool probe below resolves that move. + drop(lock); + continue; + } + match read_verified_seal_manifest(&canonical, digest, check, lock) { + Ok(bytes) => manifest = Some(bytes), + Err(error @ (GraphDbError::Cancelled | GraphDbError::DeadlineExceeded)) => { + return Err(error); } - let generation = match decoded { - Some(generation) => generation, - None => { - let pool = binding.replay_root.join(&seal_file); - if !seal_is_present(&pool)? { - return Err(canonical_error.unwrap_or_else(|| GraphDbError::unavailable( - "sealed code generation is absent from all active routes and replay pool", - ))); - } - let lock = acquire_generation_bundle_lock(&binding.replay_root, check)?; - decode_verified_seal_with_bundle_barrier( - &pool, - &segment_roots, - digest, - check, - lock, - || {}, + Err(error) => canonical_error = Some(error), + } + break; + } + let manifest = match manifest { + Some(manifest) => manifest, + None => { + let pool = binding.replay_root.join(&seal_file); + if !seal_is_present(&pool)? { + return Err(canonical_error.unwrap_or_else(|| { + GraphDbError::unavailable( + "sealed code generation is absent from all active routes and replay pool", ) - .map_err(|error| { - if matches!( - error, - GraphDbError::Cancelled | GraphDbError::DeadlineExceeded - ) { - error - } else { - canonical_error.unwrap_or(error) - } - })? + })); + } + let lock = acquire_generation_bundle_lock(&binding.replay_root, check)?; + read_verified_seal_manifest(&pool, digest, check, lock).map_err(|error| { + if matches!( + error, + GraphDbError::Cancelled | GraphDbError::DeadlineExceeded + ) { + error + } else { + canonical_error.unwrap_or(error) } - }; - Arc::new(generation) + })? } }; - if generation.manifest().project_id != binding.project_id - || generation.snapshot().repository != source.repository - || generation.manifest().generation_id != source.generation + let (sealed, sealed_state_digest) = open_verified_seal(&manifest, digest)?; + drop(manifest); + if sealed.manifest().project_id != binding.project_id + || sealed.snapshot().repository != source.repository + || sealed.generation_id() != &source.generation { return Err(GraphDbError::conflict( "code_graph_manifest.hydrate_sealed_code_generation", )); } - if decoded_from_disk { - self.retain_hydrated_decode(owner.shard_id.clone(), source, Arc::clone(&generation))?; - } - let projection = GraphProjectionIdentity::new( 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( + // to reconstruct its exact rows so the ordered journal can advance; + // the registry compares the rebuilt digests with the durable replay + // before any row is served. + spill_verified_seal_graph( + &sealed, + &sealed_state_digest, + &segment_roots, projection, - &generation, - &GraphProjectorRevision::try_from(source.projector_revision.as_str().to_owned())?, + &source.projector_revision, + spill, check, ) - .map(Arc::unwrap_or_clone) - .map_err(classify_sealed_projection_build_error) } } @@ -1594,7 +1163,6 @@ fn classify_sealed_projection_build_error(error: CodeGraphProjectionError) -> Gr #[cfg(test)] mod tests { - use std::collections::BTreeSet; use std::fmt::Write as _; use std::io::{Seek, SeekFrom, Write}; use std::path::Path; @@ -1605,16 +1173,14 @@ mod tests { use sha2::{Digest, Sha256}; use tempfile::TempDir; use tracedecay_code_index_retention::code_index_generations::{ - CodeGenerationRetentionModeV1, DurablePublicationPointerV1, - acquire_code_generation_store_lock, code_generation_segments_root, - run_code_generation_retention, - }; - use tracedecay_domain::{ - CodeGenerationId, ProjectId, RepositoryId, UtcMicros, sha256_hex_suffix, + DurablePublicationPointerV1, acquire_code_generation_store_lock, + code_generation_segments_root, }; + use tracedecay_domain::{CodeGenerationId, ProjectId, RepositoryId, sha256_hex_suffix}; use tracedecay_graph_db::{ - GraphDbError, GraphGenerationManifestProvider, GraphNamespace, GraphProjectorRevision, - SealedCodeGenerationReplay, SealedGraphStateDigest, + GraphDbError, GraphGenerationManifestProvider, GraphGenerationRowSpill, GraphNamespace, + GraphProjectionId, GraphProjectionIdentity, GraphProjectorRevision, + SealedCodeGenerationReplay, SealedGraphStateDigest, SpilledGraphGeneration, }; use tracedecay_store::{ BrainId, GraphNamespaceV1, GraphProjectionIdV1, GraphProjectionIdentityV1, StoreShardIdV1, @@ -1623,7 +1189,7 @@ mod tests { use super::{ DaemonCodeGraphManifestProviderV1, SEAL_READ_CHECK_BYTES, - decode_verified_seal_with_bundle_barrier, validate_sealed_generation_metadata, + spill_sealed_generation_graph_from_roots, validate_sealed_generation_metadata, verify_checked_seal, verify_checked_seal_bundle_with_evidence_barrier, verify_sealed_generation_source_from_roots, }; @@ -1631,6 +1197,33 @@ mod tests { CodeIndexWorktreeSchedulerV1, SharedCodeIndexBytePoolV1, scoped_code_index_store_root, }; + /// A fresh row spill for `owner`'s projection, removed with the spill. + fn spill_for(owner: &GraphProjectionIdentityV1) -> GraphGenerationRowSpill { + static NEXT: AtomicUsize = AtomicUsize::new(0); + GraphGenerationRowSpill::create( + std::env::temp_dir().join(format!( + "tracedecay-provider-spill-{}-{}", + std::process::id(), + NEXT.fetch_add(1, Ordering::Relaxed) + )), + GraphProjectionIdentity::new( + GraphNamespace::new(owner.namespace.as_str()).unwrap(), + GraphProjectionId::new(owner.projection.as_str()).unwrap(), + ), + ) + .unwrap() + } + + /// Hydrates `source` the way the registry does, into a fresh spill. + fn hydrate( + provider: &DaemonCodeGraphManifestProviderV1, + owner: &GraphProjectionIdentityV1, + source: &SealedCodeGenerationReplay, + check: &dyn Fn() -> Result<(), GraphDbError>, + ) -> Result { + provider.hydrate_sealed_code_generation(owner, source, spill_for(owner), check) + } + fn fixture( generations_root: std::path::PathBuf, replay_root: std::path::PathBuf, @@ -1697,23 +1290,21 @@ mod tests { ); assert!(matches!( - provider.hydrate_sealed_code_generation(&owner, &source, &|| Ok(())), + hydrate(&provider, &owner, &source, &|| Ok(())), Err(GraphDbError::Unavailable { .. }) )); let mut foreign = source.clone(); foreign.repository = RepositoryId::new("repository.foreign").unwrap(); assert!(matches!( - provider - .hydrate_sealed_code_generation(&owner, &foreign, &|| Ok(())) - .unwrap_err(), + hydrate(&provider, &owner, &foreign, &|| Ok(())).unwrap_err(), GraphDbError::Conflict { .. } )); // A retired seal that only survives in the replay pool is still read. std::fs::write(replay_root.join(&seal_file), b"corrupt").unwrap(); assert!(matches!( - provider.hydrate_sealed_code_generation(&owner, &source, &|| Ok(())), + hydrate(&provider, &owner, &source, &|| Ok(())), Err(GraphDbError::Corrupt { .. }) )); @@ -1721,7 +1312,7 @@ mod tests { std::fs::remove_file(replay_root.join(&seal_file)).unwrap(); std::fs::write(generations_root.join(&seal_file), b"corrupt").unwrap(); assert!(matches!( - provider.hydrate_sealed_code_generation(&owner, &source, &|| Ok(())), + hydrate(&provider, &owner, &source, &|| Ok(())), Err(GraphDbError::Corrupt { .. }) )); } @@ -1760,7 +1351,7 @@ mod tests { // Neither route holds the seal: the shard abstains rather than claiming // corruption. assert!(matches!( - provider.hydrate_sealed_code_generation(&owner, &source, &|| Ok(())), + hydrate(&provider, &owner, &source, &|| Ok(())), Err(GraphDbError::Unavailable { .. }) )); @@ -1771,7 +1362,7 @@ mod tests { ); std::fs::write(branch_generations.join(&seal_file), b"corrupt").unwrap(); assert!(matches!( - provider.hydrate_sealed_code_generation(&owner, &source, &|| Ok(())), + hydrate(&provider, &owner, &source, &|| Ok(())), Err(GraphDbError::Corrupt { .. }) )); @@ -1809,15 +1400,14 @@ mod tests { // pool copy (which would probe the closure again). let probes = AtomicUsize::new(0); assert_eq!( - provider - .hydrate_sealed_code_generation(&owner, &source, &|| { - if probes.fetch_add(1, Ordering::SeqCst) == 0 { - Ok(()) - } else { - Err(GraphDbError::Cancelled) - } - }) - .unwrap_err(), + hydrate(&provider, &owner, &source, &|| { + if probes.fetch_add(1, Ordering::SeqCst) == 0 { + Ok(()) + } else { + Err(GraphDbError::Cancelled) + } + }) + .unwrap_err(), GraphDbError::Cancelled ); assert_eq!(probes.load(Ordering::SeqCst), 2); @@ -2143,32 +1733,28 @@ mod tests { .join("code-generations-v1") .join(fixture.pool_manifest.file_name().unwrap()); std::fs::copy(&fixture.pool_manifest, &canonical).unwrap(); - let hydrate = |route_kind: &str| { + let hydrate_route = |route_kind: &str| { let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); - provider - .hydrate_sealed_code_generation(&owner, &source, &|| { - if std::time::Instant::now() >= deadline { - Err(GraphDbError::DeadlineExceeded) - } else { - Ok(()) - } - }) - .unwrap_or_else(|error| panic!("{route_kind} hydration failed: {error:?}")); + hydrate(&provider, &owner, &source, &|| { + if std::time::Instant::now() >= deadline { + Err(GraphDbError::DeadlineExceeded) + } else { + Ok(()) + } + }) + .unwrap_or_else(|error| panic!("{route_kind} hydration failed: {error:?}")); }; - hydrate("canonical"); - provider.release_decoded_offer(&owner.shard_id); + hydrate_route("canonical"); std::fs::remove_file(&canonical).unwrap(); - hydrate("pool"); + hydrate_route("pool"); // A verified pool copy also recovers a damaged canonical payload. - provider.release_decoded_offer(&owner.shard_id); std::fs::write(&canonical, b"corrupt").unwrap(); - hydrate("canonical recovery"); + hydrate_route("canonical recovery"); drop(equal_route); drop(absent_route); assert!(provider.sources.read().unwrap().is_empty()); - assert_eq!(provider.retained_decoded_offer_count(), 0); assert!(matches!( - provider.hydrate_sealed_code_generation(&owner, &source, &|| Ok(())), + hydrate(&provider, &owner, &source, &|| Ok(())), Err(GraphDbError::Unavailable { .. }) )); } @@ -2220,31 +1806,50 @@ mod tests { } } - fn decode_partitioned_with_interruption( + /// Builds the fixture's graph from its pool seal and interrupts the build + /// once `reads` segment reads have passed their check. + fn spill_partitioned_with_interruption( label: &str, + reads: usize, interruption: GraphDbError, ) -> GraphDbError { let fixture = partitioned_seal_fixture(label); - let evidence_ranges_started = AtomicBool::new(false); - let interrupted_range_checks = AtomicUsize::new(0); + let generations_root = fixture.scope_root.join("code-generations-v1"); let replay_root = fixture.pool_manifest.parent().unwrap(); - let error = decode_verified_seal_with_bundle_barrier( - &fixture.pool_manifest, - std::slice::from_ref(&fixture.segments_root), - &fixture.digest, + let owner = GraphProjectionIdentityV1 { + shard_id: StoreShardIdV1::project( + BrainId::new("brain.spill-interruption").unwrap(), + UserProfileId::new("profile.spill-interruption").unwrap(), + fixture.project.clone(), + ), + namespace: GraphNamespaceV1::new("namespace.spill-interruption").unwrap(), + projection: GraphProjectionIdV1::new("code-generation").unwrap(), + }; + let checks = AtomicUsize::new(0); + let error = spill_sealed_generation_graph_from_roots( + &generations_root, + replay_root, + &SealedGraphStateDigest::try_from(format!("sha256:{}", fixture.digest)).unwrap(), + &fixture.generation, + GraphProjectionIdentity::new( + GraphNamespace::new(owner.namespace.as_str()).unwrap(), + GraphProjectionId::new(owner.projection.as_str()).unwrap(), + ), + &GraphProjectorRevision::try_from( + tracedecay_code_index::graph_projection::CODE_GRAPH_PROJECTOR_REVISION.to_owned(), + ) + .unwrap(), + spill_for(&owner), &|| { - if evidence_ranges_started.load(Ordering::SeqCst) { - interrupted_range_checks.fetch_add(1, Ordering::SeqCst); + if checks.fetch_add(1, Ordering::SeqCst) >= reads { Err(interruption.clone()) } else { Ok(()) } }, - acquire_code_generation_store_lock(replay_root).unwrap(), - || evidence_ranges_started.store(true, Ordering::SeqCst), ) .unwrap_err(); - assert_eq!(interrupted_range_checks.load(Ordering::SeqCst), 1); + assert_eq!(checks.load(Ordering::SeqCst), reads + 1); error } @@ -2277,17 +1882,21 @@ mod tests { } #[test] - fn partitioned_decode_callback_preserves_cancellation() { + fn sealed_graph_build_preserves_cancellation_between_segment_reads() { assert_eq!( - decode_partitioned_with_interruption("decode-cancelled", GraphDbError::Cancelled), + spill_partitioned_with_interruption("spill-cancelled", 4, GraphDbError::Cancelled), GraphDbError::Cancelled ); } #[test] - fn partitioned_decode_callback_preserves_deadline() { + fn sealed_graph_build_preserves_deadline_between_segment_reads() { assert_eq!( - decode_partitioned_with_interruption("decode-deadline", GraphDbError::DeadlineExceeded,), + spill_partitioned_with_interruption( + "spill-deadline", + 4, + GraphDbError::DeadlineExceeded + ), GraphDbError::DeadlineExceeded ); } @@ -2308,125 +1917,12 @@ mod tests { ); } + /// Hydration rebuilds the sealed generation's graph rows from its segments + /// on disk every time, so a replay whose seal is gone fails typed rather + /// than being answered from memory, and a foreign sealed digest is never + /// served. #[test] - fn partitioned_replay_decode_pins_evidence_across_manifest_retirement() { - let temporary = TempDir::new().unwrap(); - let root = temporary.path().canonicalize().unwrap(); - let project_root = root.join("project"); - std::fs::create_dir_all(project_root.join("src")).unwrap(); - git(&project_root, &["init", "-q", "-b", "main"]); - git(&project_root, &["config", "user.name", "TraceDecay Test"]); - git( - &project_root, - &["config", "user.email", "tracedecay@example.invalid"], - ); - std::fs::write( - project_root.join("src/lib.rs"), - multi_page_evidence_source("pinned_evidence", '+'), - ) - .unwrap(); - git(&project_root, &["add", "."]); - git(&project_root, &["commit", "-qm", "pinned evidence fixture"]); - let project_id = ProjectId::new("project.manifest-pinned-evidence").unwrap(); - tracedecay_runtime_core::storage::pin_fixture_repository_identity( - &project_root, - project_id.as_str(), - ) - .unwrap(); - let canonical_project = project_root.canonicalize().unwrap(); - let store_root = root.join("code-index-store"); - let scoped_store = scoped_code_index_store_root(&store_root, &canonical_project); - let mut scheduler = CodeIndexWorktreeSchedulerV1::open( - project_id, - &canonical_project, - scoped_store.clone(), - Arc::new(SharedCodeIndexBytePoolV1::default()), - ) - .unwrap(); - publish_multi_page_evidence(&project_root, "pinned_evidence", &mut scheduler); - drop(scheduler); - - let pointer_path = scoped_store.join("active-code-generation-v1.json"); - let pointer: DurablePublicationPointerV1 = - serde_json::from_slice(&std::fs::read(&pointer_path).unwrap()).unwrap(); - let digest = sha256_hex_suffix(&pointer.state_digest).unwrap(); - let generations_root = scoped_store.join("code-generations-v1"); - let canonical_manifest = generations_root.join(&pointer.generation_file); - let manifest: serde_json::Value = - serde_json::from_slice(&std::fs::read(&canonical_manifest).unwrap()).unwrap(); - assert!( - manifest["generation"]["generation_evidence"]["pages"] - .as_array() - .unwrap() - .len() - > 1 - ); - let clean_parent = manifest["generation"]["manifest"]["parent_generation"] - .as_str() - .unwrap() - .to_owned(); - let evidence_digest = manifest["generation"]["generation_evidence"]["segment_digest"] - .as_str() - .unwrap(); - let evidence_digest = sha256_hex_suffix(evidence_digest).unwrap(); - let evidence_path = code_generation_segments_root(&scoped_store) - .join(format!("segment-{evidence_digest}.json")); - - let replay_root = root.join("replay-pool"); - tracedecay_private_fs::create_private_directory(&replay_root).unwrap(); - let staged_manifest = replay_root.join(format!(".generation-{digest}.unlink-123-456-1")); - { - let _pool_lock = acquire_code_generation_store_lock(&replay_root).unwrap(); - std::fs::rename(&canonical_manifest, &staged_manifest).unwrap(); - } - std::fs::remove_file(pointer_path).unwrap(); - - let segments_root = code_generation_segments_root(&scoped_store); - let decoded = decode_verified_seal_with_bundle_barrier( - &staged_manifest, - std::slice::from_ref(&segments_root), - digest, - &|| Ok(()), - acquire_code_generation_store_lock(&replay_root).unwrap(), - || { - std::fs::remove_file(&staged_manifest).unwrap(); - let report = run_code_generation_retention( - &scoped_store, - &BTreeSet::new(), - CodeGenerationRetentionModeV1::Apply, - UtcMicros(1), - Some(&replay_root), - ) - .unwrap(); - assert!( - report - .deleted_generations - .iter() - .all(|deleted| deleted.generation_id.as_str() == clean_parent), - "retention may retire only the fixture's clean parent generation" - ); - assert!( - !evidence_path.exists(), - "retention must remove the pack pathname while decode owns its lifetime" - ); - }, - ) - .expect("pinned evidence pack must survive pathname retirement"); - assert_eq!( - decoded.manifest().generation_id.as_str(), - pointer.generation_id - ); - assert!(!evidence_path.exists()); - } - - /// One disk pass hydrates a replay; the second hydration of the same - /// replay reuses that digest-verified decode and produces the identical - /// manifest. Falsifiable by construction: the sealed file is deleted - /// between the two hydrations, so any second read attempt fails, while - /// durable-source verification, which must never trust the retained - /// decode, is required to observe the loss. - #[test] - fn disk_hydration_is_single_pass_and_source_verification_stays_fail_closed() { + fn hydration_builds_the_sealed_graph_from_disk_and_fails_closed_once_it_is_gone() { let temporary = TempDir::new().unwrap(); let root = temporary.path().canonicalize().unwrap(); let project_root = root.join("project"); @@ -2464,9 +1960,9 @@ mod tests { .unwrap(); scheduler.reconcile_now().unwrap(); let latest = scheduler.latest_complete().unwrap(); - let decoded_handle = latest.generation_handle(); let generation_id = latest.generation().manifest().generation_id.clone(); let repository_id = latest.generation().snapshot().repository.clone(); + drop(latest); drop(scheduler); let pointer: DurablePublicationPointerV1 = serde_json::from_slice( &std::fs::read(scoped_store.join("active-code-generation-v1.json")).unwrap(), @@ -2499,49 +1995,30 @@ mod tests { ) .unwrap(); let owner = GraphProjectionIdentityV1 { - shard_id: shard.clone(), + shard_id: shard, namespace: GraphNamespaceV1::new(namespace.as_str()).unwrap(), projection: GraphProjectionIdV1::new(projection.projection.as_str()).unwrap(), }; let source = SealedCodeGenerationReplay { repository: repository_id, generation: generation_id, - sealed_state_digest: sealed_state_digest.clone(), + sealed_state_digest, projector_revision: GraphProjectorRevision::try_from( tracedecay_code_index::graph_projection::CODE_GRAPH_PROJECTOR_REVISION.to_owned(), ) .unwrap(), }; - // Nothing was offered, so the first hydration pays the one disk pass. - let first = provider - .hydrate_sealed_code_generation(&owner, &source, &|| Ok(())) - .expect("first hydration decodes the sealed payload from disk"); - - // Delete the seal from both roots so any further byte pass must fail. - let digest = sha256_hex_suffix(&pointer.state_digest).unwrap(); - let seal_file = format!("generation-{digest}.json"); - std::fs::remove_file(generations_root.join(&seal_file)).unwrap(); - - // Durable-source verification never trusts the retained decode. - assert!(matches!( - verify_sealed_generation_source_from_roots( - &generations_root, - &replay_root, - &sealed_state_digest, - &|| Ok(()), - ), - Err(GraphDbError::Unavailable { .. }) - )); - - // The same replay hydrates again from the retained decode, identical - // manifest, zero further byte passes. - let second = provider - .hydrate_sealed_code_generation(&owner, &source, &|| Ok(())) - .expect("repeated hydration reuses the verified decode"); - assert_eq!(first, second); + let first = hydrate(&provider, &owner, &source, &|| Ok(())) + .expect("hydration builds the sealed graph from disk"); + let second = hydrate(&provider, &owner, &source, &|| Ok(())) + .expect("a second hydration builds it again"); + assert_eq!(first.row_counts(), second.row_counts()); + assert_eq!( + first.expected_recovered_digest(), + second.expected_recovered_digest() + ); - // The retained decode never answers a foreign sealed digest. let foreign = SealedCodeGenerationReplay { sealed_state_digest: SealedGraphStateDigest::try_from(format!( "sha256:{}", @@ -2550,22 +2027,15 @@ mod tests { .unwrap(), ..source.clone() }; - provider - .hydrate_sealed_code_generation(&owner, &foreign, &|| Ok(())) - .expect_err("a foreign sealed digest must never be served from the retained decode"); - - // A fresh activation offer supersedes the retained decode, so the old - // replay can only be answered from disk again, which is now gone. - provider - .offer_decoded_code_generation( - shard, - CodeGenerationId::new("generation.superseding").unwrap(), - foreign.sealed_state_digest.clone(), - decoded_handle, - ) - .unwrap(); assert!(matches!( - provider.hydrate_sealed_code_generation(&owner, &source, &|| Ok(())), + hydrate(&provider, &owner, &foreign, &|| Ok(())), + Err(GraphDbError::Unavailable { .. }) + )); + + let digest = sha256_hex_suffix(&pointer.state_digest).unwrap(); + std::fs::remove_file(generations_root.join(format!("generation-{digest}.json"))).unwrap(); + assert!(matches!( + hydrate(&provider, &owner, &source, &|| Ok(())), Err(GraphDbError::Unavailable { .. }) )); } diff --git a/crates/tracedecay/src/daemon/code_index_runtime_graph_activation_tests.rs b/crates/tracedecay/src/daemon/code_index_runtime_graph_activation_tests.rs index 117b2ec398..bf8c07ff42 100644 --- a/crates/tracedecay/src/daemon/code_index_runtime_graph_activation_tests.rs +++ b/crates/tracedecay/src/daemon/code_index_runtime_graph_activation_tests.rs @@ -813,15 +813,11 @@ async fn restart_status_case(corrupt_graph: bool, dirty_before_restart: bool) { latest.generation().manifest().generation_id.clone(), Arc::clone(&project_database), replay_binding, - Some(latest.generation_handle()), ) .await .expect("retain seeded graph runtime"); let seeded_graph = retained - .publish_verified_snapshot( - latest.generation(), - Arc::new(std::sync::atomic::AtomicBool::new(false)), - ) + .publish_verified_snapshot(Arc::new(std::sync::atomic::AtomicBool::new(false))) .expect("publish graph head before restart"); drop(seeded_graph); drop(retained); @@ -1323,16 +1319,12 @@ async fn restart_seats_the_retained_graph_while_its_text_owner_still_projects() latest.generation().manifest().generation_id.clone(), Arc::clone(&project_database), replay_binding, - Some(latest.generation_handle()), ) .await .expect("retain seeded graph runtime"); drop( retained - .publish_verified_snapshot( - latest.generation(), - Arc::new(std::sync::atomic::AtomicBool::new(false)), - ) + .publish_verified_snapshot(Arc::new(std::sync::atomic::AtomicBool::new(false))) .expect("publish graph head before restart"), ); drop(retained); diff --git a/crates/tracedecay/src/daemon/store_runtime_tests.rs b/crates/tracedecay/src/daemon/store_runtime_tests.rs index 90deba8315..5281c774c6 100644 --- a/crates/tracedecay/src/daemon/store_runtime_tests.rs +++ b/crates/tracedecay/src/daemon/store_runtime_tests.rs @@ -1609,7 +1609,6 @@ async fn linked_worktree_generations_share_the_project_graph_runtime() { .expect("primary generation"), Arc::clone(&project_database), replay_binding(), - None, ) .await .expect("primary graph runtime"); @@ -1624,7 +1623,6 @@ async fn linked_worktree_generations_share_the_project_graph_runtime() { .expect("linked generation"), Arc::clone(&project_database), replay_binding(), - None, ) .await .expect("linked graph runtime");